Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

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/:id then 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/entities creates 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/:id records a delete intent; the entity stays live until the merge.
  • PUT /api/menus/:id and PUT /api/site are 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_scope inside 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 with kind (entity, menu, settings), targetId, title, action, fieldChanges (fields whose value differs between the base revision and the branch revision, from content/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 entities revisionId, baseRevisionId and path. The full field-level diff of an entity is GET /api/entities/:id/revisions/:baseRevisionId/diff/:revisionId (send the workstream header so the branch revision is in scope).
  • conflicts[]: { kind, targetId, reason } where live_changed means 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) and deleted means the target no longer exists.

Merge

POST /api/workstreams/:id/merge with an optional { "force": true } (permission content.publish):

  1. Refuses with 409 workstream_conflicts (details: the conflicts) when any item conflicts, unless force.
  2. Refuses with 422 checks_failed (details: { targetId, title, checks }[]) when any item has an error-level check, unless force.
  3. 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 as POST /api/entities/:id/publish (status published, entity.update and entity.publish audit rows, cache tags evicted, entity.published notification, module entity.afterSave/afterPublish hooks). An entity created in the branch loses its workstreamId and becomes an ordinary published entity. A delete intent removes the entity (entity.delete). Menu and settings overlays are applied (menu.update, site.update, with the menu:<key>, site:<id> and theme tags evicted). The conflict check is repeated inside the transaction.
  4. Marks the workstream merged (mergedAt), clears publishAt, audits workstream.merge and queues the workstream.merged notification 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/discard deletes the branch revisions, the entities created inside it, its items, overlays and share tokens, and marks it discarded. Nothing reaches the main line.
  • DELETE /api/workstreams/:id removes the record as well (purging the branch first when it is still open).
  • POST /api/workstreams/:id/schedule with { "publishAt": "<ISO date>" } (or null to clear; content.publish) merges it through the ticker: every SCHEDULE_TICK_MS (and on spillway run-scheduled) due workstreams are merged with force: false by the system actor. A refused merge (conflicts, checks) is logged, audited as workstream.merge_failed once per distinct error, recorded in lastMergeError and retried every tick until it merges; fixing the content inside the workstream is enough.

Permissions

ActionWho
List and read workstreams, diff, previewcontent.view_drafts
Createcontent.create (the creator owns it)
Rename, discard, delete, share linkthe owner, or anyone with content.publish
Edit content inside a workstreamthe usual rules: content.edit_any, or content.edit_own on your own content; menus and settings need site.settings
Merge, schedulecontent.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) with revision_id (entities), action, base_version (the main line's current revision id, or updated_at for menus and settings, when the branch was taken), updated_at, author_id.
  • workstream_overlay: (workstream_id, kind, target_id) → data JSON (menu: the changed label/items; settings: the accumulated PUT /api/site patch).
  • workstream_share_token: sha256 id, workstream, creator, expiry.
  • revision.workstream_id (branch revisions; never pruned by the revision-history trim, deleted with the workstream) and entity.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

Was this page helpful?