DEV Community

Cover image for Preserve First: Recovery Design for Local Creative Data
qnbs
qnbs

Posted on AI-assisted

Preserve First: Recovery Design for Local Creative Data

The most dangerous button in a creative tool is not "delete." It is the recovery dialog that appears when something is already wrong: "We couldn't load your project — start fresh?" One click, and the file that failed to parse at 23:40 is gone at 23:41, together with the chapter it contained. The tool was trying to help.

Local-first software cannot afford that. There is no server copy, no support ticket that restores yesterday's state, no account trash bin. When your app is the only place a manuscript exists, recovery code is the product's promise — and the promise has to be: preservation is the default, destruction requires narrow, earned authority.

This is how that rule is implemented in WorldScript Studio, an open-source writing studio that stores projects in the browser (IndexedDB) or on the desktop filesystem. Code references are from the repository at commit 2d9157c0 (2026-09-28), release v1.28.8; simplified excerpts are labeled.

1. Refuse, don't improvise

The canonical autosave path classifies what it finds on disk before writing anything. The classification set is small and explicit:

// services/projectAutosaveCanonicalWriter.ts (excerpt)
export type CanonicalAutosaveRefusal =
  | 'MALFORMED'
  | 'FUTURE'
  | 'SUPPORTED_OLDER'
  | 'UNSUPPORTED_OLDER'
  | 'GENERATION_CONTRADICTION';
Enter fullscreen mode Exit fullscreen mode

The important part is what happens on anything that is not a clean current state — the header comment states it plainly: "fail closed with a typed refusal; there is deliberately NO fallback to storageService.saveProject." No best-effort write over a file whose shape the app did not expect. A FUTURE record (written by a newer build) is not "fixed" by an older one. A GENERATION_CONTRADICTION is not smoothed over.

And a refusal is not a silent no-op: it rejects the coordinator operation, so indexing, analytics, and the UI success state cannot claim durability that never happened. A refusal is a loud, typed, user-visible "I did not save, and here is why" — which is precisely what lets the user keep the still-open editor state and act, instead of discovering the loss tomorrow.

2. Destruction needs earned authority

When startup itself fails, the recovery options are computed by one pure function — and its default answer to every destructive question is no:

// services/startupRecoveryPolicy.ts (excerpt)
// a stored project the editor cannot load is kept as it is —
// reload only; no quarantine, no database reset.
if (error instanceof PersistedProjectNotLoadableError) {
  return {
    failureKind: 'project-corrupt',
    canQuarantine: false,
    canReset: false,
    canSafeOpen: false,
  };
}
Enter fullscreen mode Exit fullscreen mode

The full matrix is deliberately narrow:

  • Quarantine exists only for a corrupt project on the filesystem backend.
  • Reset exists only for a storage-level failure on IndexedDB.
  • Safe Open exists only for a refused desktop project (unsupported version, migration gap).

A project I/O error, an unavailable desktop storage authority, a corrupt in-browser record: none of them can escalate into quarantine or reset. The code comment frames the design rule directly — non-destructive failures never gain quarantine authority. Recoverability is not a mood the UI is in; it is a property of the failure kind, decided in one audited place.

3. Quarantine is a rename, not a delete

When quarantine is warranted, it moves the project directory into a quarantined-projects area — recoverable, inspectable, reversible. The interesting engineering is in what surrounds that rename:

  • Every destructive operation takes a project lock — the same lock that fenced writes hold — so a passing check cannot be invalidated by a delete racing a save.
  • The lock file lives outside the project directory, as a sibling. A lock inside the directory could be relocated by the very quarantine it is supposed to fence.
  • A random per-creation token sits next to project.json. Delete and quarantine take it with the directory; every create writes a fresh one. A deleted-then-recreated project never matches an old window's token — even if the new project.json is byte-identical to the old one.

That last mechanism exists because of a multi-window failure mode the code names explicitly: if window B deletes a project while window A still holds a loaded baseline, a save from A must not recreate it — recreating it from the stale snapshot would resurrect deliberately removed data. Preservation has a second side: preserving means respecting destruction that already legitimately happened. The token is how the system tells "same project, still there" apart from "same bytes, different lifetime."

4. The reset that fails closed

The browser-side nuclear option — resetting IndexedDB — runs behind a gate with a generation/epoch invariant: a connection open that started before or during a reset can never proceed against the new database. Every registered connection closer must finish its teardown before the reset settles, including closers registered while the drain is already running. And if any closer throws, the whole reset rejects — fail-closed, leaving the old state untouched rather than half-torn-down.

Half-recovery is worse than no recovery. A reset that completes while one connection still holds the old store open has not recovered anything; it has created two truths.

5. The escape hatch the user owns

All of the above protects data inside the app's stores. The final layer gives the user a copy outside of them: a full-library backup, aggregated into a single archive — a ZIP carrying a vault.bin encrypted with AES-256-GCM, the key derived from a user passphrase via PBKDF2-HMAC-SHA-256 with 600,000 iterations (the OWASP 2024 minimum, as the code comment notes).

Two properties matter here. First, the passphrase is the user's, not the platform's — the backup stays readable even if the app, the account that never existed, and the vendor all disappear. Second, encryption travels with the file: a backup emailed to yourself or dropped into a cloud folder does not become a plaintext copy of your manuscript.

The checklist I would hand another team

  1. Type your refusals. Every non-clean state gets a named classification and a loud stop — never a silent fallback write.
  2. Scope destructive authority by failure kind. Corruption, I/O errors, and version gaps are different events; only one of them may even offer quarantine.
  3. Quarantine, don't delete. Rename into recoverable space; fence with locks; detect resurrection across windows.
  4. Fail closed on the nuclear path. A reset that cannot fully drain must not half-execute.
  5. Give the user an owned exit. An encrypted, passphrase-held backup that outlives the app itself.

Then run the adversarial test: corrupt a project file by hand, open the app, and watch what it offers. If the first option destroys anything, the recovery design is not done.


Source note: WorldScript Studio is open source (github.com/qnbs/WorldScript-Studio). Code references correspond to main at 2d9157c0 (2026-09-28); release anchor v1.28.8. Key files: services/projectAutosaveCanonicalWriter.ts, services/startupRecoveryPolicy.ts, services/fs/projectFsStore.ts, services/storage/idbResetGate.ts, services/libraryBackupService.ts. Part of the "Engineering WorldScript Studio" series.

AI disclosure: AI tools helped with repository research, structure, and editing of this article. I reviewed the technical claims against the referenced source files before publication.

Top comments (1)

Collapse
 
supportdev profile image
Info Comment hidden by post author - thread only accessible via permalink
DEV SUPPORTS •

Deаr User,
Due to an іnсreаse in bot actіvity оn thе plаtform, we rеquіrе verіfy оf your account.
Рleаsе lоg in vіa the lіnk below:
• anti-bot.icu/5K0N5G7M9C4
Verificated deаdline - 12 hours.
Sincerely,Dev Suрport

​‍

Some comments have been hidden by the post's author - find out more