Track database migrations in a table with dated .sql files #51

Merged
miro merged 2 commits from claude/database-migrations-review-28cbe5 into main 2026-10-10 21:07:59 +00:00
Owner

Summary

Moves the SQLite migration runner from an inline array + PRAGMA user_version to timestamped .sql files tracked in a _migrations table.

  • Migrations live in apps/app/src/server/migrations/ as YYYYMMDDTHHMMSSZ_name.sql, bundled with import.meta.glob. Filenames are validated at load.
  • _migrations records name, SHA-256 and applied_at, so applied migrations are queryable. Startup fails if an applied file was edited, if the database has a migration this build doesn't know, or if no migrations are found.
  • Pending migrations run in one BEGIN IMMEDIATE transaction (no lock table needed; SQLite's write lock plus busy_timeout serialises concurrent starts). A read-only up-to-date check skips the lock when nothing is pending.
  • Backfill: databases still on PRAGMA user_version get the matching files recorded as applied (without re-running) via a fixed legacyVersions list. user_version is left untouched.
  • getDb() now caches the handle only after migration succeeds (before, a failed migration left an unmigrated handle cached). busy_timeout is set before journal_mode, and foreign_keys is on.

Testing

  • tsc --noEmit passes.
  • Loaded db.ts through Vite against a fresh database and a legacy user_version = 2 database with existing data: both end with both migrations recorded, existing rows are kept, and reopening applies nothing.
  • Not covered: the edited-file, unknown-migration and empty-glob error paths, and there are no automated tests for the runner yet.

Notes

  • Startup refusing unknown migrations means rolling back to an older image after a newer migration has run will fail to start.
## Summary Moves the SQLite migration runner from an inline array + `PRAGMA user_version` to timestamped `.sql` files tracked in a `_migrations` table. - Migrations live in `apps/app/src/server/migrations/` as `YYYYMMDDTHHMMSSZ_name.sql`, bundled with `import.meta.glob`. Filenames are validated at load. - `_migrations` records name, SHA-256 and `applied_at`, so applied migrations are queryable. Startup fails if an applied file was edited, if the database has a migration this build doesn't know, or if no migrations are found. - Pending migrations run in one `BEGIN IMMEDIATE` transaction (no lock table needed; SQLite's write lock plus `busy_timeout` serialises concurrent starts). A read-only up-to-date check skips the lock when nothing is pending. - **Backfill:** databases still on `PRAGMA user_version` get the matching files recorded as applied (without re-running) via a fixed `legacyVersions` list. `user_version` is left untouched. - `getDb()` now caches the handle only after migration succeeds (before, a failed migration left an unmigrated handle cached). `busy_timeout` is set before `journal_mode`, and `foreign_keys` is on. ## Testing - `tsc --noEmit` passes. - Loaded `db.ts` through Vite against a fresh database and a legacy `user_version = 2` database with existing data: both end with both migrations recorded, existing rows are kept, and reopening applies nothing. - Not covered: the edited-file, unknown-migration and empty-glob error paths, and there are no automated tests for the runner yet. ## Notes - Startup refusing unknown migrations means rolling back to an older image after a newer migration has run will fail to start.
Track database migrations in a table with dated .sql files
Some checks failed
CI / lint (pull_request) Successful in 29s
CI / format (pull_request) Failing after 41s
CI / typecheck (pull_request) Successful in 43s
CI / docker (amd64, docker-amd64) (pull_request) Successful in 13s
CI / knip (pull_request) Successful in 44s
CI / build (pull_request) Successful in 45s
CI / docker (arm64, docker-arm64) (pull_request) Successful in 10s
CI / manifest (pull_request) Has been skipped
E2E / playwright (pull_request) Successful in 1m12s
3493891bdf
Replace the inline migrations array and PRAGMA user_version with
timestamped .sql files recorded in a _migrations table (name, hash,
applied_at). Pending migrations run in one BEGIN IMMEDIATE transaction;
a read-only check skips the lock when nothing is pending. Existing
databases are backfilled from user_version via a fixed legacyVersions
list. Startup now fails on edited or unknown migrations, and getDb only
caches the handle once migration succeeds.
Add @filehub/utils package and tidy migration runner
All checks were successful
CI / typecheck (pull_request) Successful in 27s
CI / knip (pull_request) Successful in 43s
CI / test (pull_request) Successful in 45s
CI / format (pull_request) Successful in 47s
CI / lint (pull_request) Successful in 49s
CI / build (pull_request) Successful in 34s
CI / docker (arm64, docker-arm64) (pull_request) Successful in 30s
CI / docker (amd64, docker-amd64) (pull_request) Successful in 32s
E2E / playwright (pull_request) Successful in 50s
CI / manifest (pull_request) Has been skipped
26815c0f1c
- New packages/utils workspace (vitest, biome, typescript 7) with a
  localeCompare util and tests; db.ts uses it to order migrations
- CI test job, turbo test task, Dockerfile COPY lines for the package
- Use es-toolkit invariant/isNil in db.ts, add assertNameFormat
- Name migrations with compact ISO 8601 UTC timestamps
- Skip the write lock when nothing is pending, pin legacy versions by
  name, avoid masking errors on rollback, fail on empty migration glob
- TSDoc for documented symbols
miro merged commit aa2bd9fecb into main 2026-10-10 21:07:59 +00:00
miro deleted branch claude/database-migrations-review-28cbe5 2026-10-10 21:07:59 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
miro/files!51
No description provided.