Give documents a draft/published lifecycle with two pointers: readers see a stable published version while authors edit a private draft, and publishing is one atomic swap.
The editor autosaves continuously — every keystroke lands in the stored
document. That’s exactly right for personal notes, but wrong for published
content: once readers can see a document, an author reopening it would be
live-editing in front of their audience, typos and half-finished thoughts
included.This tutorial adds a draft/published lifecycle on top of the
creation and
access-control flows. The pattern — taken from
how production hosts run Qirtaas — is that draft and published are two
documents, not a flag. Your table keeps two pointers:
lessons(…, qirtaas_doc_id, draft_doc_id)
qirtaas_doc_id — the published document readers see. "" until the
first publish, and the only pointer your read gate ever signs.
draft_doc_id — the work-in-progress the author edits. "" when there
is no draft.
Publishing promotes the draft pointer into the published slot — a pointer
swap in your database, no content copied. Revising clones the published
document and edits the clone. Autosave stays on the whole time; it just
writes to a document nobody else can read.The example continues the course platform from
access control: teachers author and revise
lesson documents, enrolled students only ever read the published version.
Everything the UI needs is derivable from the two pointers — don’t store a
separate status column that can drift:
type PublicationState = "draft" | "revising" | "published";function publicationState(lesson: Lesson): PublicationState { if (!lesson.qirtaas_doc_id) return "draft"; // never published if (lesson.draft_doc_id) return "revising"; // published, edits in progress return "published"; // published and clean}
Authoring is the creation flow unchanged —
mount <QirtaasEditor> without a documentId, and when onDocumentCreated
fires, record the new id. The only difference: store it in draft_doc_id,
so the lesson starts private.
Because qirtaas_doc_id is still "", the signature endpoint from the
access-control tutorial has nothing it will sign — an unpublished draft is
unreadable by construction, with no extra checks to write.
Publishing moves the draft pointer into the published slot in one
transaction. If the lesson was already published, the old published document
is now an orphan — return its id so the frontend can delete it over the
embed channel (your backend records pointers; it never brokers document
lifecycle).
The frontend calls publish, then cleans up the orphan best-effort — a failed
delete leaves an unreachable document, not a broken lesson:
import { qirtaas } from "./qirtaas";async function publish(lessonId: number) { const res = await fetch(`/api/lessons/${lessonId}/publish`, { method: "POST" }); if (!res.ok) throw new Error(`publish failed (${res.status})`); const { lesson, orphan_doc_id } = await res.json(); if (orphan_doc_id) await qirtaas.deleteDocument(orphan_doc_id).catch(() => {}); return lesson;}
Don’t skip the orphan cleanup. Every orphan is a full document that still
counts against the identity’s document quota — let them accumulate across
many publish cycles and you’ll start hitting rate limits and document caps
on an identity that looks nearly empty in your own tables. “Best-effort”
means a single failed delete is tolerable, not that deleting is optional.
To edit a published lesson without readers watching, clone the published
document with the client’s
duplicateDocument and register the clone as
the working draft. Students keep reading the stable published version;
publishing later promotes the clone via step 3.The draft endpoint accepts the clone’s id — and rejects a second concurrent
draft with a 409, returning the existing one instead:
app.post("/api/lessons/:id/draft", requireTeacher, async (req, res) => { const lesson = await db.lessons.findUnique({ where: { id: Number(req.params.id) }, }); if (!lesson) return res.status(404).json({ error: "Lesson not found" }); const draftId = String(req.body.draft_doc_id ?? "").trim(); if (!draftId) return res.status(400).json({ error: "draft_doc_id is required" }); if (lesson.draft_doc_id) // A draft already exists — return it rather than losing it to the clone. return res.status(409).json({ error: "draft_already_exists", lesson }); const updated = await db.lessons.update({ where: { id: lesson.id }, data: { draft_doc_id: draftId }, }); res.json({ lesson: updated });});
On the frontend, “Revise” is clone → register → open the editor on the
draft. On a 409, the clone you just made is redundant — delete it and open
the draft that already exists:
import { qirtaas, getToken } from "./qirtaas";async function startRevision(lesson: Lesson): Promise<string> { const { id: draftId } = await qirtaas.duplicateDocument(lesson.qirtaas_doc_id); const res = await fetch(`/api/lessons/${lesson.id}/draft`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ draft_doc_id: draftId }), }); if (res.status === 409) { // Someone got there first — use their draft, discard our clone. await qirtaas.deleteDocument(draftId).catch(() => {}); const { lesson: current } = await res.json(); return current.draft_doc_id; } if (!res.ok) throw new Error(`draft registration failed (${res.status})`); return draftId;}// …then mount the editor on the returned id:// <QirtaasEditor key={draftId} documentId={draftId} getToken={getToken} />
This is the security core of the pattern, and it costs nothing: the signature
endpoint from access control already signs
lesson.qirtaas_doc_id and nothing else. Keep it that way and drafts are
unreadable to students by construction — there is no “is this a draft?”
check to forget, because the draft pointer simply never reaches the signer.Two adjacent rules complete the gate:
Public lists exclude unpublished rows — filter out lessons where
qirtaas_doc_id = "" for non-teachers, so a never-published draft doesn’t
appear as an empty shell.
Detail views 404 unpublished lessons for non-teachers — don’t reveal
that a draft exists.
The author, meanwhile, needs no signature at all: their embed token covers
their own documents, so the editor opens the draft directly.
Lessons now carry a full lifecycle — authored privately, published
atomically, revised without readers noticing — and your backend still only
stores two pointers per row. The division of labor from the earlier
tutorials held throughout: document lifecycle (create, clone, delete) runs on
the frontend over the embed channel; your backend records pointers and
decides what to sign.