Version Control — Landing Pages & Postcards
What this is
Section titled “What this is”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).
Why Cloudflare Workers Artifacts
Section titled “Why Cloudflare Workers Artifacts”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 only —
create,get,fork,delete, token mint. Content IO goes throughisomorphic-gitover 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).
Architecture
Section titled “Architecture”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 │ └───────────┘ └────────────────────┘Repo layout
Section titled “Repo layout”-
Namespace:
hg-{env}(prod,staging,preview,dev). One namespace per environment for blast-radius isolation. -
Repo name:
lp-{uuid}(landing page) orpc-{uuid}(postcard). Single source of truth:repoNameFor(entity_type, uuid). -
Default branch:
main. Tags:refs/tags/publish-{n}for publish snapshots — cheap, native, andgit log --tagsenumerates published versions without touching the index. -
File tree kept minimal so
git diffis meaningful:Landing pages Postcards/index.html /front.html/metadata.json /back.html/metadata.json -
metadata.jsoncarriesschema_version,entity_type,entity_uuid,workspace_id,slug,is_published_at_commit, and a 200-charai_prompt_excerptfor AI-edit revisions. Readers tolerate unknown keys; new fields are additive; breaking changes bumpschema_versionand run a one-shot rewrite workflow.
Repo lifecycle
Section titled “Repo lifecycle”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.
Worker placement
Section titled “Worker placement”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/revisionsworker. 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.
Wrangler configuration (gateway)
Section titled “Wrangler configuration (gateway)”{ "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.
Schemas
Section titled “Schemas”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.
revisions_index
Section titled “revisions_index”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.
auto_saves
Section titled “auto_saves”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
Section titled “Validators”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.
Migration
Section titled “Migration”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.
Save / Restore / Auto-save / Cancel
Section titled “Save / Restore / Auto-save / Cancel”Save (explicit)
Section titled “Save (explicit)”- Frontend posts
{ html, idempotency_key, based_on_commit_sha }toPOST /:wid/landing-pages/:id/revisions. - 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(orfront.html+back.html) andmetadata.jsonto 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 existingbakeLandingPageHtmlpipeline. - Insert
revisions_indexrow (type: 'manual_save', placeholder nameSave #N). - Fire async LLM naming via
ctx.waitUntil().
- Edge rate limits → idempotency middleware → Zod-validate →
- 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.
Auto-save (crash recovery)
Section titled “Auto-save (crash recovery)”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-stampedupdated_at(client clock is never trusted). Onbeforeunloadthe hook usesnavigator.sendBeacon; if the payload exceeds 60 KB it gzips viaCompressionStreamand falls back tofetch({ 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.
Restore
Section titled “Restore”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.
Cancel
Section titled “Cancel”Discard in-memory edits, revert preview to the active R2 HTML, delete the auto_saves row.
Save status indicator
Section titled “Save status indicator”Toolbar reflects state at all times: idle → dirty → saving → saved (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.
Undo boundary
Section titled “Undo boundary”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.
Concurrency, atomicity, multi-tab
Section titled “Concurrency, atomicity, multi-tab”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.
Auth, tenancy, and abuse protection
Section titled “Auth, tenancy, and abuse protection”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 perworkspace_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.
Error taxonomy
Section titled “Error taxonomy”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.
Retention and history pagination
Section titled “Retention and history pagination”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.
Workspace deletion, GDPR, encryption
Section titled “Workspace deletion, GDPR, encryption”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:
- 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.
- 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.
Feature flag, degradation, exit
Section titled “Feature flag, degradation, exit”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.
Observability
Section titled “Observability”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-pages → postcards. 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}.
Release warnings
Section titled “Release warnings”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.
LLM-generated revision names
Section titled “LLM-generated revision names”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.
Testing
Section titled “Testing”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 changes
Section titled “File changes”New (Path A)
Section titled “New (Path A)”| 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) |
New (Path B only)
Section titled “New (Path B only)”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.
Modified
Section titled “Modified”| 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 sohg-{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.