Accepted
ADR 0015 — Draft and published body coexistence
Editing a published document never disturbs the live page, and preview shows the work in progress
When an editor opens a published document and starts editing, the live public page must not change until they publish. At the same time, preview links must show the work in progress, not the live version. Cusp keeps both bodies on the same document row.
Storage: parallel columns
The documents table carries the live body alongside a parallel draft body: draft_blocks_json holds the work in progress in the same shape as blocks_json, draft_blocks_r2_key holds the R2 key when the draft exceeds the spillover threshold, and draft_updated_at records when the draft was last touched. The R2 spillover rule is the same as the live body's: bodies over the threshold spill to R2 and the row stores only the key.
A null draft body means no draft is in progress. A published document with no draft shows its published body in the editor and writes to the draft on the first edit.
Read and write rules
The mental model: blocks_json is the body the public sees right now; draft_blocks_json is the body the editor is editing. For a brand-new draft document the public never sees the body, so it lives in blocks_json directly until first publish.
- Editor on a draft document — reads and writes blocks_json directly.
- Editor on a published or scheduled document — reads draft_blocks_json falling back to blocks_json; writes draft_blocks_json.
- Public render (no preview) — always reads blocks_json.
- Public render with a valid preview token — reads draft_blocks_json falling back to blocks_json.
- Publish — promotes the draft into blocks_json and nulls the draft columns.
- Discard unpublished changes — nulls the draft columns and leaves blocks_json (the live body) untouched, reverting the editor to the published version.
Publishing promotes the draft to the live body atomically. The publish step copies the draft body into the live body (or swaps the R2 key), clears the draft columns, and writes one version row through the versioned-write helper, all in a single update. This database flip is synchronous so the admin sees an immediate response; the render pipeline that follows runs as the PublishDocument workflow. Unpublish does not touch bodies — status flips to draft and any in-progress draft survives; the first save after unpublish reconciles the draft back into the live body.
Discarding unpublished changes
The inverse of publish. POST /api/admin/documents/:id/discard-draft nulls the draft columns through the versioned-write helper, leaving blocks_json — the live site — untouched, so the editor reverts to the published body. The discard is recorded as a version, so it is auditable and the discarded draft is recoverable from version history. It is a no-op (409 NO_UNPUBLISHED_CHANGES) on a plain draft, which has no separate published body to revert to.
Because the DocumentSession durable object is the source of truth for a connected document's body, discarding in D1 alone is not enough — the DO would re-flush its in-memory draft. The discard route therefore resets the DocumentSession after the D1 write: the DO re-loads the published body, clears its op log, cancels the pending flush alarm, and broadcasts a reset envelope so every connected editor snaps to the published body live (each client drops its pending op queue on reset).
Versioning and write discipline
A version snapshot includes both bodies (and their R2 keys), so restoring version N is unambiguous — both bodies are exactly as they were at version N. Restoring an old version writes to the draft body for a published document (leaving the live URL untouched) and to the live body for a draft.
Both document mutation paths go through documentsRepo and respect the read/write rules above: the admin API PATCH and the DocumentSession durable object flushing on idle, op count, or disconnect. Neither path issues a direct UPDATE on the documents table from application code. The PATCH path records a version through the versioned-write helper; the DO flush deliberately does not — a version per flush would flood the history with meaningless auto-save entries, so the flush only updates the draft columns and leaves the live body and the version log untouched. While a client is connected, the durable object is the source of truth for that document's body.
Consequences
- The live published body is protected from in-progress edits — the headline correctness fix.
- Preview links show the work in progress with no separate revisions table and no draft flag.
- The publish promotion is one column copy plus a null.
- Both bodies live in version history, so a restore is unambiguous.
Considered and rejected: a separate revisions table with a draft flag (adds a join on every render and duplicates the version snapshot), a two-row-per-document model (doubles row count and complicates every query), and storing the draft as a replayable op log (forces replay code into every body read). Materialising the draft body on flush is the right place to land.