Workstreams
A workstream is a named branch of content changes: many entities, the navigation menus and the site settings edited together, previewed as a whole on a private URL, compared with the live site and merged or discarded in one action, now or on a schedule. Use one for a campaign launch, a redesign, a product release or any change that only makes sense as a set. The idea borrows from Pantheon's protected workstreams and Drupal's Workspaces.
The contract lives in packages/shared/src/index.ts (section "Workstreams"); the storage is packages/core/src/db/migrations/0020_workstreams.ts; the code is under packages/core/src/workstreams/.
How it works
Every edit on Spillway normally lands on the main line: a new revision becomes the entity's currentRevisionId, and a publish copies it to publishedRevisionId. A workstream puts a request on a branch instead:
- Send the header
X-Spillway-Workstream: <id or key>on any/api/*request (the admin's workstream switcher does this for every call while a workstream is selected). Preview URLs take?workstream=<id>instead. PUT /api/entities/:idthen writes a revision tagged with the workstream (Revision.workstreamId). The main line keeps its own current revision, title and data; nothing changes on the public site. The first branch edit of an entity records the main line's current revision id as the base.POST /api/entitiescreates an entity that only the workstream can see (Entity.workstreamId); it keeps its path reserved but the main line never lists it and the public site never serves it until the merge.DELETE /api/entities/:idrecords a delete intent; the entity stays live until the merge.PUT /api/menus/:idandPUT /api/siteare stored as overlays: what the branch changed, applied on top of the main-line row whenever the workstream reads them. Creating or deleting a menu, changing a menu key, or changing an entity's slug or locale is not possible inside a workstream (do it on the main line).- Publish, unpublish, submit for review and restore answer
409 workstream_scopeinside a workstream: the merge is how a branch reaches the live site.
Reads follow the header too: GET /api/entities/:id returns the branch version with inWorkstream: true and workstreamAction (update, create or delete); GET /api/entities lists the main line plus the entities created in the workstream and marks the ones the branch changes; /checks runs on the branch version; /revisions adds the branch revisions to the main-line list; GET /api/menus and GET /api/site return the overlaid versions. Without the header, none of this is visible.
A workstream has a status: open (editable), merging (inside the merge transaction), merged or discarded (both read-only; the record stays for the audit trail and can be deleted).
Preview
GET /_preview/ws/:id/<path> renders the site as it would look after the merge: branch revisions for the pages the workstream edits, the pages it creates, nothing (404) for its delete intents, the published snapshot for everything else, with the branch's menus and settings around it. /_preview/ws/:id/ is the home page. Internal links are rewritten to stay inside the preview, so a reviewer can browse the whole campaign. Responses are Cache-Control: no-store, X-Spillway-Cache: BYPASS, X-Robots-Tag: noindex and carry a noindex meta tag; the page cache is never read or written.
The preview needs a session with content.view_drafts, or a share link: POST /api/workstreams/:id/share-link returns { token, url, expiresAt }, a token valid for WORKSTREAM_SHARE_DAYS (7 days) that a stakeholder appends as ?token= without signing in. Only the sha256 of the token is stored; the rewritten links carry it along. A share token opens nothing but that workstream's preview.
/_preview/:entityId?workstream=<id> previews one entity's branch version (the page builder's preview button uses this when a workstream is selected).
Diff and conflicts
GET /api/workstreams/:id/diff returns WorkstreamDiff:
items[]: one row per change withkind(entity,menu,settings),targetId,title,action,fieldChanges(fields whose value differs between the base revision and the branch revision, fromcontent/diff.ts),layoutChanges(blocks added, removed or changed in the page builder document),checks(the pre-publish checks run on the branch version,review/checks.ts), and for entitiesrevisionId,baseRevisionIdandpath. The full field-level diff of an entity isGET /api/entities/:id/revisions/:baseRevisionId/diff/:revisionId(send the workstream header so the branch revision is in scope).conflicts[]:{ kind, targetId, reason }wherelive_changedmeans the main line moved since the branch was taken (the entity's current revision id, or the menu's or settings'updatedAt, differs from the recorded base) anddeletedmeans the target no longer exists.
Merge
POST /api/workstreams/:id/merge with an optional { "force": true } (permission content.publish):
- Refuses with
409 workstream_conflicts(details: the conflicts) when any item conflicts, unlessforce. - Refuses with
422 checks_failed(details:{ targetId, title, checks }[]) when any item has an error-level check, unlessforce. - In one transaction: for each entity item, writes a new main-line revision from the branch revision (note
Merged from workstream "<label>"), updates references, taxonomy rows and the search index, and publishes it through the same path asPOST /api/entities/:id/publish(statuspublished,entity.updateandentity.publishaudit rows, cache tags evicted,entity.publishednotification, moduleentity.afterSave/afterPublishhooks). An entity created in the branch loses itsworkstreamIdand becomes an ordinary published entity. A delete intent removes the entity (entity.delete). Menu and settings overlays are applied (menu.update,site.update, with themenu:<key>,site:<id>andthemetags evicted). The conflict check is repeated inside the transaction. - Marks the workstream
merged(mergedAt), clearspublishAt, auditsworkstream.mergeand queues theworkstream.mergednotification event.
A failure anywhere rolls the whole merge back. With force, a live_changed conflict is overwritten by the branch and a deleted target is skipped. After the merge the workstream is read-only: edits inside it answer 409 workstream_read_only.
Discard, delete, schedule
POST /api/workstreams/:id/discarddeletes the branch revisions, the entities created inside it, its items, overlays and share tokens, and marks itdiscarded. Nothing reaches the main line.DELETE /api/workstreams/:idremoves the record as well (purging the branch first when it is still open).POST /api/workstreams/:id/schedulewith{ "publishAt": "<ISO date>" }(ornullto clear;content.publish) merges it through the ticker: everySCHEDULE_TICK_MS(and onspillway run-scheduled) due workstreams are merged withforce: falseby thesystemactor. A refused merge (conflicts, checks) is logged, audited asworkstream.merge_failedonce per distinct error, recorded inlastMergeErrorand retried every tick until it merges; fixing the content inside the workstream is enough.
Permissions
| Action | Who |
|---|---|
| List and read workstreams, diff, preview | content.view_drafts |
| Create | content.create (the creator owns it) |
| Rename, discard, delete, share link | the owner, or anyone with content.publish |
| Edit content inside a workstream | the usual rules: content.edit_any, or content.edit_own on your own content; menus and settings need site.settings |
| Merge, schedule | content.publish |
An author can therefore prepare a whole campaign in a workstream, share the preview, and hand it to an editor to merge.
Audit and notifications
Audit rows: workstream.create, workstream.update, workstream.schedule / workstream.unschedule, workstream.share_link, workstream.merge, workstream.merge_failed, workstream.discard, workstream.delete; workstream.item.create / workstream.item.update / workstream.item.delete on the entity, menu or site target for every branch edit; and, at merge, the ordinary entity.update, entity.publish, entity.delete, menu.update and site.update rows (their after carries workstreamId). The scheduler writes scheduler.workstreams per pass that did something.
The notification event workstream.merged (rules under Site settings → Notifications) carries data.workstream: { id, key, label, itemCount, adminUrl } and the actor.
Storage
Migration 0020_workstreams:
workstream:id, site_id, key (unique per site), label, description, status, owner_id, publish_at, created_at, updated_at, merged_at, last_merge_error.workstream_item:(workstream_id, kind, target_id)withrevision_id(entities),action,base_version(the main line's current revision id, orupdated_atfor menus and settings, when the branch was taken),updated_at,author_id.workstream_overlay:(workstream_id, kind, target_id)→dataJSON (menu: the changedlabel/items; settings: the accumulatedPUT /api/sitepatch).workstream_share_token: sha256 id, workstream, creator, expiry.revision.workstream_id(branch revisions; never pruned by the revision-history trim, deleted with the workstream) andentity.workstream_id(entities created in a branch).
Limits
- Collections, calendars, search and term pages inside the preview list the main line's published entities; a page the workstream creates appears in them only after the merge.
- Slugs, locales, menu keys and new or deleted menus are main-line changes.
- Workstreams branch content, not structure: content types, vocabularies, forms, redirects and media are shared with the main line.
- Two workstreams may change the same entity; the second to merge conflicts (its base no longer matches) and needs
force.
Spillway 0.1.0 · Edit this page on GitHub