Skip to content

Harden DB migrations with explicit guards + pre-destructive backups#129

Merged
graydawnc merged 1 commit intomainfrom
chore/db-migration-hardening
Apr 29, 2026
Merged

Harden DB migrations with explicit guards + pre-destructive backups#129
graydawnc merged 1 commit intomainfrom
chore/db-migration-hardening

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

What

Hardens packages/core/src/db/db.ts migration runner along three axes:

  1. Replace try { ... } catch {} in v1–v3 with explicit tableExists() guards. v1–v3 all operate on tables (connector_sync_state, captures, capture_connectors, captures_fts*) that v5 dropped. The historical try/catch was there so fresh installs on the post-v5 schema wouldn't crash when those tables don't exist. The new guards express that intent explicitly and stop swallowing genuine errors.

  2. Add backupBeforeDestructive(db, fromVersion) and wire it into v5 and v7. Both migrations drop tables irreversibly (v5 drops captures + connector tables; v7 drops stars). The helper takes a VACUUM INTO snapshot to <dbDir>/backups/spool-pre-v{N+1}-<ts>.db before each destructive step. Skips in-memory DBs and empty / no-data DBs so fresh-install paths don't write spurious backups.

  3. Comprehensive migration tests. Coverage went from 79 → 95 tests. New files:

    • migration-v1-v3.test.ts — characterizes the v0→head historical path on a populated old schema, plus fresh-install no-op verification.
    • migration-v4.test.ts — exercises the v3→head path, including legacy session_stars cleanup.
    • migration-backup.test.ts — unit tests for the helper (file-backed, in-memory, empty DB, version encoding) and integration assertions that v5 actually writes a backup file.
    • migration-smoke.test.ts — end-to-end smoke through getDB() simulating fresh install, v0/v4/v6 starting users, and idempotent re-open. Each test inserts a session + pin + queries pins back to prove the migrated DB is functionally usable, not just structurally correct.

Why

The migration runner has accumulated 7 versions worth of surface area, with v6 and v7 landing in the past week (project identity, stars→pins). Two specific risks were worth addressing now rather than waiting for a real incident:

  • Silent catches mask failures. If a v1–v3 ALTER/INSERT broke for a non-table-missing reason (e.g., disk full, locked DB, partial corruption), the migration would still bump user_version and move on. The guard pattern is both clearer in intent and lets real errors propagate.
  • Destructive migrations had no recovery path. v5 drops captures data (users were redirected to Spool Daemon) and v7 drops the stars table. If anything goes wrong post-merge — a future-version migration discovers it needed that data, a user wants their old data back — there's nothing to fall back to. VACUUM INTO is cheap, atomic at the SQLite level, and skipped on no-data DBs so the cost is zero for fresh installs.

A heavier framework migration (Drizzle migrations, Umzug, etc.) was considered and rejected: it would split schema between the fresh-install CREATE TABLE block and per-version migration files (drift risk), and add packaging complexity for a single-user local SQLite store. The targeted fixes here address the actual failure modes without buying a framework.

How it connects

Touches only packages/core/src/db/db.ts plus new test files in the same directory. No public API changes beyond exporting backupBeforeDestructive for testing. The behavior change for end users is: a backups/ directory may appear next to spool.db after a major version upgrade, and previously-silent v1–v3 errors would now surface (they have not surfaced in any reported issue, so this is purely a defensive change).

Test plan

  • pnpm -C packages/core test → 95/95 pass (was 79)
  • pnpm -C packages/core build → clean typecheck
  • pnpm -C packages/app build → clean typecheck
  • Smoke covers: new user (fresh install), old user at v0 with full historical schema + connector data, old user at v4 with starred session + capture, old user at v6 with starred session, idempotent re-open of an already-migrated DB
  • Backup helper: file-backed populated DB writes valid SQLite snapshot; in-memory DB returns null; empty DB returns null; v5 and v7 produce distinct backup files

…ive backups

- Replace try/catch{} silent error swallowing in v1-v3 with explicit
  tableExists() guards. Fresh installs (post-v5 schema) skip these
  migrations without ever touching SQL; populated old DBs still run them.
  Genuine failures now surface instead of being silenced.
- Add backupBeforeDestructive(): VACUUM INTO snapshot to
  <dbDir>/backups/spool-pre-v{N+1}-<ts>.db before v5 (drops captures +
  connector tables) and v7 (drops stars). Skips in-memory DBs and
  empty/no-data DBs (fresh installs need no backup).
- Add comprehensive migration tests covering every starting version
  (v0/v3/v4/v6/fresh), the backup helper itself, and an end-to-end
  smoke that walks getDB() through each path and exercises the
  resulting DB. Test coverage: 79 -> 95.

Why: with v6 + v7 just landed and v7 dropping the stars table, the
inline migration block has accumulated enough surface area that silent
catches and unrecoverable destructive steps are real risks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@graydawnc graydawnc merged commit c298de8 into main Apr 29, 2026
4 checks passed
@graydawnc graydawnc deleted the chore/db-migration-hardening branch April 29, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant