Skip to content

Version Control — Landing Pages & Postcards

Persistent version history for landing pages and postcards. Today both store only the latest HTML in R2 and rely on an in-memory undo stack that dies on refresh. There is no rollback, no named checkpoint, no recovery of unsaved work, and no signal when an edit will hit a published page or a live ad campaign.

This design adds:

  • A durable, multi-region revision store (Cloudflare Workers Artifacts — one Git repo per entity).
  • Explicit Save / Recover / Cancel semantics with two-tier auto-save (browser + DO SQLite).
  • LLM-generated revision names, user-renameable.
  • Restore-any-revision, never destructive (restore = new commit from old tree).
  • A warning banner when editing content linked to a published URL, an ad campaign, or other postcards.

R2 stays as the customer-facing serving cache, so the published-site read path is unchanged. Artifacts only sits on save, restore, and viewing a non-current revision.

  • Every meaningful change is durably committed.
  • Drafts survive crashes and accidental tab closes.
  • Any past revision restorable in one click.
  • Editors warned when an edit affects live content.
  • Native Git escape hatch for ops, audit, and customer data export.

Non-goals. Real-time collaboration. Customer-visible Git tooling. In-product diff viewer (data is captured; viewer is a fast-follow).

The alternatives were a bespoke revisions table + R2 keys, or stuffing every revision blob into DO SQLite. Both reinvent versioning, branching, restore, and pruning by hand. Artifacts gives all of that as native Git primitives — commits, tags, fork — with one repo per entity as the documented unit of work, and Cloudflare advertises tens of millions of repos as the design target.

What we accept in exchange:

  • Beta product. SLA and pricing are not yet locked. We mitigate by keeping R2 as the canonical serving path (customer impact stays at zero during any Artifacts incident) and by exporting weekly Git bundles as disaster recovery.
  • Workers binding is control-plane onlycreate, get, fork, delete, token mint. Content IO goes through isomorphic-git over HTTPS, with a freshly minted, repo-scoped, short-TTL token per request.
  • ~150 KB added to the worker bundle. Validated by a benchmark gate before any schema change ships (see Worker placement).

Three planes, three responsibilities:

Plane Role What it holds
Artifacts (Git) Durable, versioned source of truth Full commit history, file trees, tags
R2 Serving cache for the active version current_html_path, current_front_path, current_back_path; lazy mirror of viewed old revisions
DO SQLite (revisions_index, auto_saves) Queryable index of the Git log + transient draft state One row per committed revision; one row per entity for live auto-save

The active commit is identified by current_revision_uuid on the entity row → revisions_index.commit_sha. Reads of the active version never walk the Git log.

┌───────────────────────┐
│ Cloudflare Artifacts │ durable, versioned source of truth
│ (1 repo per entity) │ commits = revisions, tags = publish snapshots
└──────────┬────────────┘
│ isomorphic-git over HTTPS
┌──────────▼────────────┐
│ Gateway Worker │
│ (apps/fullstack) │ RevisionsComponent on WorkspaceDO
└──┬─────────────────┬──┘
active HTML on │ │ one row per revision
save / restore ▼ ▼
┌───────────┐ ┌────────────────────┐
│ R2 │ │ WorkspaceDO │
│ serving │ │ Drizzle SQLite │
└───────────┘ └────────────────────┘
  • Namespace: hg-{env} (prod, staging, preview, dev). One namespace per environment for blast-radius isolation.

  • Repo name: lp-{uuid} (landing page) or pc-{uuid} (postcard). Single source of truth: repoNameFor(entity_type, uuid).

  • Default branch: main. Tags: refs/tags/publish-{n} for publish snapshots — cheap, native, and git log --tags enumerates published versions without touching the index.

  • File tree kept minimal so git diff is meaningful:

    Landing pages Postcards
    /index.html /front.html
    /metadata.json /back.html
    /metadata.json
  • metadata.json carries schema_version, entity_type, entity_uuid, workspace_id, slug, is_published_at_commit, and a 200-char ai_prompt_excerpt for AI-edit revisions. Readers tolerate unknown keys; new fields are additive; breaking changes bump schema_version and run a one-shot rewrite workflow.

Created eagerly at entity creation (predictable, avoids first-save latency spike). Initial commit holds the template HTML. Deleted via env.ARTIFACTS.delete(repoName) queued through ctx.waitUntil() after the existing R2 prefix-delete cascade. Repos are never recreated — if creation fails, the save endpoint returns 503 and the user retries. There is no fallback to bespoke R2 versioning, because divergence between two backends is exactly the failure mode this design eliminates.

A pre-implementation benchmark PR decides where isomorphic-git lives.

  • Path A — gateway-resident. Lazy-loaded via dynamic import() on the save / restore / cold-preview paths. No new worker. Default if every gate is green with margin.
  • Path B — dedicated apps/revisions worker. RPC service binding from gateway and workflows. Default if any benchmark number is borderline (within 10% of its threshold). Keeps the gateway’s startup budget intact.

Acceptance gates: cold-start delta ≤ 150 ms, peak commit heap ≤ 64 MB on 500 KB HTML, p95 commit ≤ 1 s warm, p95 CPU ≤ 50 ms per save, sub-requests ≤ 8. The benchmark also doubles as the cost-per-save measurement that replaces the back-of-envelope numbers below.

If Path B is chosen, the root package.json deploy:staging / :production scripts add --filter=honeygrid-revisions to the leaf parallel batch (alongside agents, workflows, mcp-server). Gateway still ships last. The root and fullstack AGENTS.md worker tables are updated in the same PR so the topology stays canonical.

{
"compatibility_date": "2026-04-25",
"artifacts": [{ "binding": "ARTIFACTS", "namespace": "hg-prod" }],
"ratelimits": [
{
"name": "REVISIONS_SAVE_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 30, "period": 60 },
},
{
"name": "REVISIONS_SAVE_USER_LIMIT",
"namespace_id": "1003",
"simple": { "limit": 10, "period": 60 },
},
{
"name": "REVISIONS_AUTOSAVE_LIMIT",
"namespace_id": "1002",
"simple": { "limit": 30, "period": 60 },
},
],
}

The compat-date bump from 2026-01-20 ships in its own prerequisite PR — date flips can change unrelated runtime semantics, so isolating it keeps regressions traceable.

Both tables live in the WorkspaceDO. New tables are exported from packages/honeygrid-types/schema/workspace/schema.ts (the file workspaceDO.drizzle.config.ts:5 reads). Drizzle-kit does not introspect the package barrel, so exporting from the barrel alone would silently skip migrations.

Queryable mirror of the Git log. Not the source of truth — losing it is recoverable by re-walking commits.

CREATE TABLE revisions_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
entity_type TEXT NOT NULL, -- 'landing_page' | 'postcard'
entity_id INTEGER NOT NULL,
repo_name TEXT NOT NULL,
commit_sha TEXT NOT NULL, -- 40-char SHA, the canonical pointer
ref_name TEXT, -- 'refs/tags/publish-{n}' if tagged
name TEXT, -- LLM-generated; user-renameable
type TEXT NOT NULL, -- 'manual_save'|'ai_edit'|'template_deploy'|'publish_snapshot'
based_on_commit_sha TEXT, -- restore lineage; powers "Restored from Version N" badge
snapshot_metadata TEXT, -- JSON: seo_score, page_speed_score, …
created_by TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX revisions_index_entity_idx ON revisions_index(entity_type, entity_id, id DESC);
CREATE INDEX revisions_index_type_idx ON revisions_index(type);
CREATE UNIQUE INDEX revisions_index_sha_uniq ON revisions_index(repo_name, commit_sha);

No version_number column — Git is the version source of truth; UI labels (Save #12) are computed via ROW_NUMBER(). No auto_save rows — auto-saves live in their own table so this one stays append-only.

Single row per entity. Auto-save drafts never become commits, so they don’t belong in the revision history.

CREATE TABLE auto_saves (
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
html_content TEXT, -- landing pages
front_html_content TEXT, -- postcards
back_html_content TEXT, -- postcards
based_on_commit_sha TEXT, -- the commit the user was editing on top of
updated_at TEXT NOT NULL, -- server-stamped
PRIMARY KEY (entity_type, entity_id)
);

Validators for the new endpoints are derived from the Drizzle schemas via drizzle-zod (createSelectSchema / createInsertSchema). Existing hand-rolled validators are not migrated; this is the new convention going forward, applied where drift between schema and validator would be costly.

Generated through the existing pnpm generateWorkspaceDOSchema wrapper (drizzle-kit + fix-drizzle-migrations.mjs). Output lands in WorkspaceDOdrizzleMigrations/ and is auto-applied in the WorkspaceDO constructor via ctx.blockConcurrencyWhile(). No hand-edited SQL.

A backfill workflow walks every existing landing page and postcard, creates the repo, commits the current R2 HTML as the initial template_deploy revision, and inserts a single revisions_index row. Throttled to 100 ops/s (half the per-namespace ceiling) so live traffic has headroom; idempotent on re-run.

  1. Frontend posts { html, idempotency_key, based_on_commit_sha } to POST /:wid/landing-pages/:id/revisions.
  2. Backend (RevisionsComponent on WorkspaceDO):
    • Edge rate limits → idempotency middleware → Zod-validate → sanitizeHtmlOutput.
    • Acquire per-entity mutex.
    • Tenant-isolation check, then mint a 120 s repo-scoped write token.
    • In-memory clone (depth 1, single branch) of the entity’s repo.
    • Write index.html (or front.html + back.html) and metadata.json to MemoryFS.
    • Commit and push, with non-fast-forward retry up to 3 times.
    • Write new HTML to R2 (current_*_path); update entity row.
    • If is_published, re-bake via the existing bakeLandingPageHtml pipeline.
    • Insert revisions_index row (type: 'manual_save', placeholder name Save #N).
    • Fire async LLM naming via ctx.waitUntil().
  3. Frontend shows “All changes saved” and refreshes the History panel.

Why this order. The Artifacts commit is the canonical anchor: a commit with no R2 write is harmless and naturally garbage-collected; an R2 write with no commit would be undetectable corruption. Restore follows the same rule.

Promise pipelining. The handler reuses getWorkspace() (which un-awaits initialize()), so the entity update and the index insert batch into one DO RPC round trip. The user-facing latency anchor is the Git push, not the DO chatter.

Two tiers, neither commits to Artifacts.

  • Tier 1 — localStorage, ~500 ms debounce. Sub-second recovery for tab crashes; cleared on Save or Cancel.
  • Tier 2 — DO SQLite auto_saves, 5–10 s debounce. Server-stamped updated_at (client clock is never trusted). On beforeunload the hook uses navigator.sendBeacon; if the payload exceeds 60 KB it gzips via CompressionStream and falls back to fetch({ keepalive: true }) if still too large.

Stale-draft guard. The endpoint rejects writes whose based_on_commit_sha is older than the entity’s current revision. UI shows “Your draft is based on an older version — refresh to continue editing” and keeps the local copy so the user can extract text first.

Recovery on editor mount. Read both stores; prefer the DO row when present (server-stamped, trustworthy); fall back to localStorage only when the DO row is absent. Compare draft hash to the latest commit’s index.html hash; differ → show Recover Draft dialog.

User clicks Restore on a row in the History panel. The backend reads the file tree at the chosen commit, writes those files at the head of main, commits with message Restore from "{name}", sets based_on_commit_sha, pushes, and follows the same R2 + re-bake path as Save. History is never rewritten — restore is a new commit that happens to copy old content. UI surfaces a “Restored from Version N” badge.

Discard in-memory edits, revert preview to the active R2 HTML, delete the auto_saves row.

Toolbar reflects state at all times: idledirtysavingsaved (auto-revert to idle after 2 s) → error. The error state shows specific copy per error code so users know whether the save is “definitely lost” (act now) or “still trying” (wait). Without this, the most common confusion is “did my work save?” while a tab closes mid-debounce.

useUndoHandler keeps working for in-session undo/redo. Undo cannot pass the state loaded at session start — to go further back, users use the History panel and Restore. The toolbar greys the undo button at the bottom of the stack with a tooltip pointing at History. Restore deliberately does not repopulate the undo stack: durability lives in revisions, not in undo.

Per-entity mutex in the WorkspaceDO + non-fast-forward retry loop at the push layer:

async function commitWithRetry(opts, attempts = 3): Promise<string> {
for (let i = 0; i < attempts; i++) {
try {
return await commitAndPush(opts)
} catch (err) {
if (isNonFastForward(err) && i < attempts - 1) continue
throw err
}
}
throw new Error('exhausted commit retries')
}

The mutex stops a thundering herd of double-clicks; the Git retry handles the rare across-region disagreement on tip.

Atomicity. Three stores are touched per save: Artifacts → R2 → DO SQLite (in that order, transactional within DO). Failure midway is detected by an hourly reconciliation job that walks recent commits per repo, inserts missing index rows, and hashes R2 against the latest commit’s index.html — divergence emits revisions.divergence_detected and (configurable) re-writes R2 from the commit.

Multi-tab in the same browser. Same-origin BroadcastChannel keyed revisions:{entity_type}:{entity_id}: if a second tab opens, it loads read-only with a banner. The first tab posts released on close so the next tab promotes. Cross-device concurrency is out of scope (single-user product surface today). The auto-save stale-draft guard is the defence in depth.

Token mint is gated on a tenant-isolation check that runs before every Artifacts call:

Resolve workspaceId from session (Stytch JWT in KV).
Load the entity, verify entity.workspace_id === session.workspaceId.
Verify the requested repo_name === repoNameFor(entity_type, entity.uuid).
Refuse with 403 + auth.token.refused if any check fails.

All repos in an environment share one namespace, so the cross-tenant boundary is enforced in the gateway, not in the Artifacts ACL. repoNameFor() is the only place repo names are constructed.

Tokens are repo-scoped, 120 s TTL, minted per request, never returned to the browser, never persisted. The 120 s window covers the commit-with-retry loop plus jitter; the gateway re-mints if a retry approaches expiry.

Rate limits use the Cloudflare RateLimit binding (edge-evaluated, no Worker invocation for rejected requests):

  • REVISIONS_SAVE_LIMIT — 30 / 60 s per workspace.
  • REVISIONS_SAVE_USER_LIMIT — 10 / 60 s per user, so one user can’t starve their workspace.
  • REVISIONS_AUTOSAVE_LIMIT — 30 / 60 s per workspace_id:entity_id. Auto-save’s debounce keeps normal traffic well under this; the limiter exists for buggy clients.

The binding fails closed — if RATE_LIMIT.limit() errors, the request returns 503 revisions/rate-limiter-unavailable. Failing open would defeat the protection on Artifacts ops.

Input size cap. 1 MiB hard limit on save / auto-save bodies, enforced by Hono middleware before sanitisation.

Idempotency. Save and Restore require a client-generated idempotency_key (UUID). Server middleware caches the response in KV for 24 h; replays return the cached response, mismatched bodies for the same key return 409 revisions/idempotency-conflict. Stops duplicate revisions from network retries, browser retries, and sendBeacon racing the visible click.

The save endpoint returns a typed RevisionsError so the UI knows whether to retry, refresh, or stop.

Code HTTP UI copy Retry behaviour
revisions/sanitization-failed 400 “Couldn’t save — content rejected by safety filter.” None. User edits.
revisions/payload-too-large 413 “Page too large to save.” None.
revisions/rate-limited 429 “Saving too quickly — try again in a few seconds.” Auto-retry per Retry-After, max 3.
revisions/rate-limiter-unavailable 503 “Saving — taking longer than usual.” Single retry after 5 s.
revisions/auth-mismatch 403 “You don’t have access to this entity.” None.
revisions/non-fast-forward-exhausted 409 “Couldn’t save — refresh to see the latest.” No auto-retry; user-merge via Refresh.
revisions/idempotency-conflict 409 “Couldn’t save — duplicate request.” None. Bug indicator.
revisions/artifacts-unavailable 503 “Saving — taking longer than usual.” Queued; reconciles when breaker recovers.
revisions/upstream-timeout 504 “Saving — taking longer than usual.” Auto-retry once with backoff.
revisions/internal 500 “Couldn’t save — we’ve been notified. Your draft is safe.” Auto-retry with backoff.

UI distinguishes “definitely-not-saved” (400/403/409/413) from “probably-saving” (429/503/504/500): only the former clears the dirty flag.

revisions_index is soft-capped at 200 rows per entity, excluding publish_snapshot rows (audit trail, never auto-pruned). At cap+1, the oldest non-publish row is evicted; the underlying Git commit is not rewritten.

The History panel paginates the index for the first 200 rows. Past the cap, it walks the Git log via isomorphic-git (Path A) or env.REVISIONS.walkLog() (Path B). Page size 20, lookback 5 pages per session, CPU-ms budget 30 ms per page (over-budget returns 503 revisions/log-walk-budget-exceeded). This keeps the product promise — persistent revisions, no data loss — without paying the cost of unbounded SQLite growth.

The lazy R2 mirror at revisions/{commit_sha}/index.html caches rendered HTML the first time an old revision is previewed; subsequent previews are R2 reads. Pruned by a scheduled task if cache size warrants it.

Per-entity delete cascades the repo via env.ARTIFACTS.delete(repoName) queued behind the existing R2 prefix-delete.

Workspace-level delete (right to erasure) is two-step:

  1. Synchronous: drop the WorkspaceDO SQLite database and clear R2 prefixes. Customer cannot reach the content from the moment the request returns 200; the statutory window starts here.
  2. Asynchronous: a workflow enumerates the workspace’s landing pages and postcards, calls env.ARTIFACTS.delete(repoNameFor(...)) for each, and verifies completion within 24 h. Unconfirmed repos after the verification window remain queued for retry; alerting on them is deferred to the observability follow-up.

Audit log entry workspace.deleted written to the immutable audit pipeline with workspace_id, requesting user, repo_count, R2 prefix, ISO 8601 timestamp.

Data export (right to portability) is separate from erasure. The export workflow does an in-memory clone --mirror per repo via isomorphic-git (no git binary in Workers), tarballs the .git/ directory to R2 at exports/{workspace_id}/{request_uuid}/{repo_name}.bundle.tar, and delivers a signed URL with 24 h TTL. R2 lifecycle deletes the export prefix after 7 d.

Encryption. Cloudflare encrypts Artifacts, R2, DO SQLite, and KV at rest. Application-layer encryption of revision content is not added — it would defeat git diff (the LLM-naming step depends on diffs being readable) and inflate every commit blob without meaningfully improving the security posture. Customer-managed keys are out of scope; if a future contract requires them, the single insertion point is commitOnto.

REVISIONS_BACKEND env var on the gateway, with per-workspace overrides in workspace settings. Values: artifacts, read_only, disabled.

Backend health Flag Behaviour
Healthy artifacts Full save/restore via Artifacts.
Slow / partial outage (≥3 consecutive 5xx OR p95 > 5 s) auto-flip to read_only per workspace Save writes R2 + entity row + auto_saves, returns 202 pending_revision: true. UI: “Saved locally — revision history paused.” Backfill drains pending edits when the breaker recovers.
Hard outage disabled (manual) Save writes R2 + entity row only. No index row. History panel read-only. Auto-save unaffected.

Customer-serving R2 path is never on the Artifacts code path — customers see no impact during any of the above.

Rollout. Single-stage flip to all workspaces. The per-tenant REVISIONS_BACKEND override remains the incident-response lever; we do not cohort the launch because the customer-serving R2 path is unaffected by Artifacts state.

Exit plan if Cloudflare deprecates or reprices Artifacts: flip the flag to disabled (R2 keeps serving customers), run the bundle-export workflow, migrate to the bespoke revisions table from earlier drafts (one PR away — schema is in git history). The weekly bundle export is the disaster-recovery snapshot; worst-case loss is one week of history, not all of it.

Out of scope. Artifacts and versioning ship without any new logging, metrics, or alerting infrastructure — the existing platform defaults are sufficient to operate the feature. Observability work is deferred to a separate follow-up if and when telemetry shows it’s needed.

All endpoints accept idempotency_key for state-mutating verbs.

Method Path Description
GET /:wid/landing-pages/:id/revisions Paginated list, newest first; cursor=git:{sha} walks past the cap
GET /:wid/landing-pages/:id/revisions/:revUuid Fetch HTML at a specific revision (lazy-mirror to R2)
POST /:wid/landing-pages/:id/revisions Create manual_save revision
POST /:wid/landing-pages/:id/revisions/auto-save Upsert auto_saves row (no commit)
POST /:wid/landing-pages/:id/revisions/:revUuid/restore Restore — new commit on main
PATCH /:wid/landing-pages/:id/revisions/:revUuid Rename a revision (index only)
DELETE /:wid/landing-pages/:id/revisions/auto-save Discard auto-save draft

Postcards: identical shape, landing-pagespostcards. GET /:wid/landing-pages/:id gains a linked_entities payload (is_published, public_url, campaign_id, linked_postcard_count) used by the warning banner. POST .../edit, .../replace, .../publish create ai_edit, manual_save, and publish_snapshot revisions respectively; publish also tags refs/tags/publish-{n}.

The editor renders a LinkedEntityWarningBanner from the linked_entities payload:

Condition (landing pages) Severity Message
is_published === true warning “This page is live at {public_url}. Saving will update the published site.”
campaign_id !== null warning “Linked to an ad campaign. Changes may impact SEO and ad quality scores.”
Postcards link to this page info {count} postcard(s) link to this page via QR code.”

linked_postcard_count runs SELECT COUNT(*) FROM postcards WHERE landing_page_id = ?; the required index already exists at packages/honeygrid-types/schema/workspace/postcards.ts:133. A worker test pins the query plan via EXPLAIN QUERY PLAN so future schema drift fails the build.

Postcards have no release warnings yet — there is no “Delivered” status to gate on. Added when delivery tracking lands.

After a manual_save or ai_edit, an async task via ctx.waitUntil calls AI Gateway (revision-naming-gateway) with a 2,000-char text-content diff between the previous and current revisions. Prompt: “Summarize this HTML change in 5 words or less.” Model: routed through the existing cf-workers-ai provider in the LLM registry — naming is a low-stakes, short-output task that does not warrant a frontier model. Final slug is selected from the current Workers AI catalog during implementation and validated by a short cost/quality A/B before launch. 15 s timeout — the call doesn’t block the save response; on timeout, the placeholder Save #N stays.

The Git commit message is the durable name; the index row carries the editable display name. Renames update the index only — Git history is not rewritten.

Worker (pnpm test:worker) covers atomicity (R2 failure leaves no orphan index row; index-insert failure caught by reconciliation), tenant-isolation guard, circuit breaker open/half-open/close, every error-taxonomy row, idempotency middleware (replay, mismatch, fresh), input size cap, rate-limit fail-closed, promise-pipelining (one DO RPC round trip on the happy path), metadata.json v1→v2 migration (forward + back compat), workspace-delete enumeration with post-delete sweep, linked_postcard_count EXPLAIN QUERY PLAN, and Git-walking pagination CPU budget.

UI (pnpm test:ui) covers useAutoSave debounce + sendBeacon compression + keepalive fallback, recovery preferring the DO row over localStorage, BroadcastChannel read-only takeover, and TanStack Query honouring Retry-After.

Storybook (pnpm test:storybook) has play() interactions on RevisionHistory, LinkedEntityWarningBanner, RecoverDraftDialog, and SaveStatusIndicator — one error story per row of the taxonomy.

Synthetic runs once a minute against a bench- workspace in staging: commit 50 KB → read back → list last 20 → cold-preview the 10th-newest. Asserts p95 commit < 1 s, full sequence < 3 s. Failure pages on-call.

Manual QA before launch: save → publish → restore → verify; tab-A/tab-B read-only takeover; throttled-network save still meets p95 after retries; killed-mid-save reconciliation within an hour; 31 saves in 60 s returns 429; export-on-request bundle delivered, URL expires at 24 h.

File Purpose
packages/honeygrid-types/schema/workspace/revisions-index.ts Drizzle schema
packages/honeygrid-types/schema/workspace/auto-saves.ts Drizzle schema
packages/honeygrid-types/schema/workspace/revisions-index.validators.ts drizzle-zod request/response schemas
packages/honeygrid-types/schema/workspace/auto-saves.validators.ts drizzle-zod request/response schemas
packages/honeygrid-types/mocks/revisions-index.mock.ts faker.js generator
packages/honeygrid-types/mocks/auto-saves.mock.ts faker.js generator
apps/fullstack/src/worker/api/durable-objects/components/workspace/RevisionsComponent.ts Index CRUD, auto-save upsert, prune, reconciliation cron
apps/fullstack/src/worker/api/utils/artifacts/commit.ts commitOnto with non-fast-forward retry
apps/fullstack/src/worker/api/utils/artifacts/read-revision.ts Read tree at SHA; lazy R2 mirror
apps/fullstack/src/worker/api/utils/artifacts/repo-name.ts repoNameFor() + namespaceFor()
apps/fullstack/src/worker/api/utils/artifacts/memory-fs.ts In-memory FS adapter for isomorphic-git
apps/fullstack/src/worker/api/utils/artifacts/error-codes.ts RevisionsError typed union
apps/fullstack/src/worker/api/middleware/idempotency.ts KV-backed idempotency
apps/fullstack/src/react-app/components/RevisionHistory.tsx History panel
apps/fullstack/src/react-app/components/LinkedEntityWarningBanner.tsx Warning banner
apps/fullstack/src/react-app/components/RecoverDraftDialog.tsx Recover-draft dialog
apps/fullstack/src/react-app/components/SaveStatusIndicator.tsx Toolbar indicator
apps/fullstack/src/react-app/hooks/useAutoSave.ts Two-tier auto-save
apps/fullstack/src/react-app/queries/revisions/useRevisionQueries.ts TanStack Query hooks
apps/fullstack/src/stories/{RevisionHistory,LinkedEntityWarningBanner,RecoverDraftDialog,SaveStatusIndicator}.stories.tsx Stories with play()
packages/scripts/synthetic-revisions.ts Synthetic load test
packages/scripts/revert-metadata-migration.ts Operator script (kept dormant)

apps/revisions/wrangler.jsonc, apps/revisions/src/index.ts, apps/revisions/package.json, apps/revisions/vitest.config.ts. The util files under apps/fullstack/src/worker/api/utils/artifacts/ move to apps/revisions/src/; gateway keeps only repo-name.ts, error-codes.ts, and a thin RPC client.

File Change
apps/fullstack/wrangler.jsonc artifacts, ratelimits; bump compatibility_date (in prerequisite PR); Path B: services binding
apps/workflows/wrangler.jsonc Path B only: services binding
apps/fullstack/package.json Path A: add isomorphic-git; both: add drizzle-zod
packages/honeygrid-types/schema/workspace/schema.ts Export RevisionsIndex, AutoSaves
packages/honeygrid-types/schema/workspace/landing-pages.ts Add repo_name (TEXT, nullable until backfilled)
packages/honeygrid-types/schema/workspace/postcards.ts Add repo_name (TEXT, nullable until backfilled)
LandingPageComponent.ts / PostcardsComponent.ts getLinkedEntities, getPostcardsByLandingPageId, ensure-repo on create, queue-delete on entity-delete
apps/fullstack/src/worker/api/routes/landing-pages.ts / postcards.ts Revision endpoints; linked_entities in GET; create revisions on edit / replace / publish
apps/fullstack/src/worker/api/routes/workspaces.ts Workspace-delete queues per-repo deletes; emits audit log
apps/fullstack/src/react-app/routes/LandingPages/components/LandingPagePanel/index.tsx Wire up Save, History, Banner, Recover dialog, status indicator
apps/fullstack/src/react-app/routes/Postcards/components/PostcardPanel/index.tsx Same integration
Root package.json Path B only: add --filter=honeygrid-revisions to deploy commands
AGENTS.md (root + apps/fullstack) Path B only: update worker table, deploy order, binding diagram
  • Beta backend. Mitigated by R2 staying as the customer-serving path, weekly bundle exports, and the per-tenant flag with degradation modes. Residual: up to 7 days of history loss if Artifacts is unreachable before the next bundle export. Customers are unaffected.
  • Worker bundle weight. ~150 KB of isomorphic-git. Decided by the benchmark gate before any schema work merges. Path B is the documented escape hatch and is the default whenever a number is borderline.
  • Cold-revision preview latency. First view of an old revision pays a Git round trip; subsequent views are R2 reads. Acceptable for power-user history; revisit if telemetry shows pain.
  • Per-namespace ceiling (2,000 ops / 10 s). Three orders of magnitude below worst-case projected scale. namespaceFor() is in place so hg-{env}-{shard} sharding is a config flip, with no repo migration.

Diff viewer, postcard print-file invalidation on restore, and Git GC are explicit fast-follows.