Skip to content

Instantly share code, notes, and snippets.

@cameronapak
Created July 11, 2026 23:47
Show Gist options
  • Select an option

  • Save cameronapak/23b75b34e78c88ea1b41fc9a80c8eac8 to your computer and use it in GitHub Desktop.

Select an option

Save cameronapak/23b75b34e78c88ea1b41fc9a80c8eac8 to your computer and use it in GitHub Desktop.
Dotflowy: backup & restore story for per-user Durable Objects (wayfinder #155)

Backup & restore for per-user Durable Objects — research summary

Wayfinder ticket #155, map #151 (alpha → beta).

Question: disaster-recovery story for outlines living in per-user DO SQLite, and the smallest thing worth building for beta.

TL;DR

  • The durability floor is already excellent and free. Every SQLite-backed DO has automatic 30-day Point-in-Time Recovery — on by default, covers all SQL rows + KV, whole-DB rollback per object. Since DO = user, per-DO recovery is per-user recovery. No infrastructure to build to have it.
  • The only gap for beta is usability: there's no button to invoke a restore. The smallest worthwhile build is an admin-gated "restore one user" tool that drives PITR for a single DO. ~a day of work; turns "theoretically recoverable" into "the operator can actually recover a user."
  • Off-platform backup (R2) is the belt-and-suspenders tier — answers ">30 days" and "what if the account itself is lost," and unlocks a stronger legal promise. Cheap, but not beta-blocking. Defer to its own ticket.
  • Legal pages can honestly promise 30-day recovery today; can promise off-site backups only after R2 export ships; must keep saying the operator can access data (backup tooling makes that more true, not less).

1. What Cloudflare gives for free — the durability floor

SQLite-backed Durable Objects (new_sqlite_classes, which is what UserOutlineDO uses) get automatic Point-in-Time Recovery over a rolling 30-day window. This is not a feature you enable or a product you buy — it's inherent to the storage class.

  • Scope: the object's entire SQLite database — both sql.exec rows and KV put() values. Restore is whole-DB, not per-table. For us that means all of nodes / kv / meta / changelog for one user, atomically.
  • Per-object = per-user. Our routing key is user.id (plus the 'default' owner DO). PITR is per-object, so restoring one user never touches another — the "restore ONE user without touching others" requirement is satisfied by construction.
  • API (on ctx.storage):
    • getCurrentBookmark(): Promise<string> — a handle to "now."
    • getBookmarkForTime(timestamp: number | Date): Promise<string> — a handle to any point in the last 30 days.
    • onNextSessionRestoreBookmark(bookmark: string): Promise<string> — arms a restore on next restart; returns the pre-recovery bookmark, so a botched restore is itself reversible. Apply it by calling ctx.abort() to restart the object.
  • Limits: 30-day retention; not available in local dev (no change log locally); restore is code-driven (no dashboard button).

What PITR does NOT cover:

  • Retention beyond 30 days.
  • Account-level catastrophe (Cloudflare loses it, account compromised/closed) — the change log lives in the same account.
  • Hard object/namespace deletion (we don't delete DOs today).
  • Any self-serve or automated restore — it's operator-invoked code.

Verdict: accidental loss (user or MCP agent nukes an outline, a bad migration corrupts rows) is already recoverable for 30 days with zero infra. The gap is tooling, not durability.

2. Scheduled per-user export to R2 (the off-platform tier)

Feasible and cheap, but the "walk DO ids" framing has a simpler answer than the ticket assumed.

  • Enumerating DOs: a REST List Objects endpoint exists — GET /accounts/{account_id}/workers/durable_objects/namespaces/{namespace_id}/objects, cursor-paginated, with a hasStoredData flag per object. But we don't need it. We already hold the authoritative id set: DO name = user.id, and the user table lives in D1. A cron Worker can SELECT id FROM user, getByName(userId), and back up exactly the live set — more reliable than the eventually-consistent enumeration API, and no extra account API token to manage. (Keep the List Objects endpoint in mind only as a reconciliation/audit check for orphaned DOs.)
  • Export shape: the DO already exposes getNodes() and getKv(collection). Add one exportSnapshot(): { nodes, kv, meta, version } RPC returning JSON. A snapshot is a few KB/user.
  • Sink: R2 bucket (no binding today — needs adding to wrangler.jsonc), keyed backups/<userId>/<YYYY-MM-DD>.json, with a lifecycle rule to expire old snapshots. Daily cron is ample for beta.
  • Why bother, given PITR: this is the only answer to ">30 days" and to "the Cloudflare account itself is the blast radius." A backup that shares the account it's backing up is weak; R2 is at least a separate service, and the JSON is portable to a third provider later if desired.

3. Operator restore tooling — two paths, both per-user by construction

Mirror the existing /admin/waitlist pattern: an ADMIN_EMAILS-gated Worker route (fail-closed, non-admins get 404). Because the route addresses one DO by user.id, no other user is ever touched.

  • PITR restore (within 30 days, no data movement) — the fast path. Admin route → target user's DO → RPC restoreToTime(timestamp) that does getBookmarkForTimeonNextSessionRestoreBookmarkctx.abort(). Returns the pre-recovery bookmark so the operator can undo. This is the direct answer to scenario #1 (a user lost their outline yesterday).
  • R2 restore (from a snapshot, any age) — the deep path. Admin route → read backups/<userId>/<date>.json → DO RPC restoreSnapshot(data) that truncates + reseeds. Needs a force variant of the existing seed() (which guards on isSeeded). Pairs with §2; only useful once exports exist.

4. Recommendation — tier the build

Build for beta (small, high-value):

  • Operator "restore one user" via PITR. Admin-gated route + DO restoreToTime RPC. This is the smallest thing worth building — it makes the free 30-day PITR actually usable under pressure instead of an ad-hoc emergency deploy. Highest value per line; ~a day.
  • Nothing else is strictly required to honestly claim a durability story, because PITR already is the durability.

Build soon after (belt-and-suspenders, not beta-blocking):

  • Daily D1-user-list-driven cron export to R2 + exportSnapshot() RPC + R2 bucket + lifecycle rule.
  • R2 restoreSnapshot() RPC (+ force-reseed) to pair with it.
  • This tier is what upgrades the legal promise from "30-day recovery" to "off-site backups."

Defer (fog / post-beta):

  • User-facing self-serve version history / restore.
  • Cross-region or cross-provider replication.

5. What the legal / privacy pages can honestly say

Claim Honest?
"Stored durably on Cloudflare with automatic 30-day point-in-time recovery." ✅ True today (PITR is real + automatic).
"Export your whole outline to OPML anytime." ✅ Already true (ADR 0036, user-facing).
"We keep off-site daily backups that survive infrastructure-level failure." ⚠️ True only after the R2 cron ships. Don't promise it before.
"We can't access your data" / E2E encryption. ❌ Never — the operator can read any DO; backup tooling makes this more true. Keep the map's privacy-honesty rule.

Precision note: until R2 export lands, PITR and the live data share one Cloudflare account — same blast radius. For beta, promise the 30-day recovery (true and strong) and reserve the "off-site" language for after the export ships.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment