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.
- 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).
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.execrows and KVput()values. Restore is whole-DB, not per-table. For us that means all ofnodes/kv/meta/changelogfor 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 callingctx.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.
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 ahasStoredDataflag per object. But we don't need it. We already hold the authoritative id set: DO name =user.id, and theusertable lives in D1. A cron Worker canSELECT 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()andgetKv(collection). Add oneexportSnapshot(): { 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), keyedbackups/<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.
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 doesgetBookmarkForTime→onNextSessionRestoreBookmark→ctx.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 RPCrestoreSnapshot(data)that truncates + reseeds. Needs a force variant of the existingseed()(which guards onisSeeded). Pairs with §2; only useful once exports exist.
Build for beta (small, high-value):
- Operator "restore one user" via PITR. Admin-gated route + DO
restoreToTimeRPC. 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.
| 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." | |
| "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.