Skip to content

Drafts, Publishing & Snapshots

Dino Discover’s editing model is built on one invariant: the storefront only ever reads published snapshots, never drafts. Every other behaviour in the Builder and the REST API follows from that.

This page covers what that means in practice — for the merchant clicking through the Builder, and for anyone reading the codebase.

StateWhere it livesWho can read it
DraftLive in the dd_quizzes / dd_questions / dd_answers tables, plus the upvote and variant tables.The Builder (your admin session), and the admin draft-preview tab — nothing else.
PublishedA single row in dd_quiz_snapshots, immutable, sha256-stamped.Everyone — storefront sessions, the REST bootstrap endpoint, the analytics aggregator, the coverage refresh job.

A quiz can have many snapshots in its history. Only the one whose ID is in dd_quizzes.published_snapshot_id is the currently published snapshot. The rest are previous versions you can restore from.

When you click Publish in the Builder, four things happen in a single database transaction:

  1. Validation. The draft is checked against every quiz invariant — every answer routes somewhere; every referenced product still exists; every variant weight sums to a valid distribution; no question has zero answers. Validation failures show up in the publish modal with a per-field message; nothing changes on the database side.
  2. Hydration. The draft tables are walked into an in-memory tree — one quiz, with variants, with questions, with answers, with upvote mappings. This is what DraftHydrator does.
  3. Serialisation and hashing. The tree is serialised, hashed with SHA-256, and written as a single row to dd_quiz_snapshots.
  4. Atomic flip. dd_quizzes.published_snapshot_id is updated to point at the new row.

The dino_discover_quiz_published action fires after the flip with (int $quiz_id, int $snapshot_id). Integrations and observability hooks can subscribe here.

[SCREENSHOT: The Publish modal — note field, publish button, preview of the change summary.]

Why drafts and published are physically separate

Section titled “Why drafts and published are physically separate”

The split exists for three reasons:

  1. Storefront safety. Merchants reshape quizzes mid-day. The storefront should never see a half-finished question, a missing upvote, or a routing pill that points nowhere. Reading only from immutable snapshots makes that property a structural one, not a discipline one.
  2. Performance. A snapshot is a single row read. There’s no fan-out query across dd_questions + dd_answers + dd_upvotes on every storefront session.
  3. Rollback. Because each publish is a separate row, the entire History tab works as cheap undo. Restore is a copy-back into the draft tables — no replay of edits, no diff-merge.

The Builder has one path that does see drafts — the Preview tab. It’s the single exception to the published-only invariant, and it is narrowly gated:

  • The endpoint is GET /storefront/quizzes/{slug}/draft-preview-bootstrap.
  • It requires an admin session and a one-shot nonce from the Builder.
  • The nonce expires after 30 minutes.
  • Every preview view fires the dino_discover_draft_preview_accessed action; the default listener writes an audit entry to the WordPress error log.

Even with all those guardrails, the Preview endpoint lives in a separate file from the public storefront controller, and a custom PHPStan rule (PublishedSnapshotOnlyRule) enforces that no other public endpoint reads from drafts.

History is on every quiz. Open the History tab in the Builder; you get a list of every snapshot, newest first, with the merchant’s publish note, the author, and the timestamp.

To restore:

  1. Click Restore on the snapshot you want.
  2. Confirm. The Builder calls POST /quizzes/{id}/snapshots/{sid}/restore.
  3. The snapshot is unpacked back into the draft tables. The dino_discover_draft_replaced action fires.
  4. Your current draft state is replaced — but you can always restore the previous draft from the snapshot it came from (each restore creates an anchor point in the history).

Restoring doesn’t automatically republish. You’re back in draft, with the restored content. Click Publish when you’ve reviewed it.

Optimistic locking — two editors at once

Section titled “Optimistic locking — two editors at once”

If two admins open the same quiz, both edits don’t blindly clobber. Every PATCH /quizzes/{id} and POST /quizzes/{id}/publish carries an expected_draft_version integer. The server compares it to the current draft version. If they don’t match, the request returns 409 dd_draft_version_mismatch and the Builder shows a “the draft moved on, refresh to see the latest” banner.

There is also an advisory draft_lock_user_id / draft_lock_acquired_at that the Builder uses to warn before you start editing. The lock has a 30-minute TTL and does not prevent publish — it’s a cooperative indicator, not a hard gate.