@toist/aja 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/migrate.ts ADDED
@@ -0,0 +1,204 @@
1
+ // 2121
2
+ // Linear SQL-file migration runner for runtime.db.
3
+ //
4
+ // Migrations live in <migrationsDir> (default: sibling 'migrations/' of the
5
+ // runner code). Files named NNN_description.sql are applied in lexicographic
6
+ // order, each in its own SQL transaction.
7
+ //
8
+ // Tracking: the _migrations table records every applied filename + sha256
9
+ // checksum of its contents. Re-applying is a no-op. Editing an applied file
10
+ // is a loud error — checksum mismatch refuses to start, forcing the author to
11
+ // add a new migration instead.
12
+ //
13
+ // Bootstrap: _migrations is created (CREATE TABLE IF NOT EXISTS) before
14
+ // reading it, so the very first run on a fresh DB works without special-casing.
15
+ //
16
+ // Auto-migrate vs explicit: by default the runner applies pending migrations
17
+ // at startup. RUNTIME_AUTO_MIGRATE=false flips this to fail-loud — startup
18
+ // aborts if anything is pending.
19
+
20
+ import { Database } from "bun:sqlite"
21
+ import { readdirSync, readFileSync, existsSync, copyFileSync, statSync, unlinkSync } from "node:fs"
22
+ import { join, dirname, basename } from "node:path"
23
+ import { fileURLToPath } from "node:url"
24
+ import { createHash } from "node:crypto"
25
+
26
+ const __dir = dirname(fileURLToPath(import.meta.url))
27
+ export const DEFAULT_MIGRATIONS_DIR = join(__dir, "..", "migrations")
28
+ const BACKUP_RETAIN = 5
29
+
30
+ export interface MigrateOptions {
31
+ migrationsDir?: string
32
+ }
33
+
34
+ export interface MigrationResult {
35
+ applied: string[]
36
+ alreadyApplied: string[]
37
+ backup?: string
38
+ }
39
+
40
+ function checksum(contents: string): string {
41
+ return createHash("sha256").update(contents).digest("hex")
42
+ }
43
+
44
+ interface MigrationFile {
45
+ filename: string
46
+ contents: string
47
+ checksum: string
48
+ }
49
+
50
+ function readMigrationFiles(migrationsDir: string): MigrationFile[] {
51
+ if (!existsSync(migrationsDir)) {
52
+ throw new Error(`[migrate] migrations directory not found: ${migrationsDir}`)
53
+ }
54
+ return readdirSync(migrationsDir)
55
+ .filter((f) => f.endsWith(".sql"))
56
+ .sort()
57
+ .map((filename) => {
58
+ const contents = readFileSync(join(migrationsDir, filename), "utf8")
59
+ return { filename, contents, checksum: checksum(contents) }
60
+ })
61
+ }
62
+
63
+ function ensureMigrationsTable(db: Database): void {
64
+ db.exec(`
65
+ CREATE TABLE IF NOT EXISTS _migrations (
66
+ filename TEXT PRIMARY KEY,
67
+ checksum TEXT NOT NULL,
68
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
69
+ );
70
+ `)
71
+ }
72
+
73
+ function readApplied(db: Database): Map<string, string> {
74
+ const rows = db.prepare("SELECT filename, checksum FROM _migrations").all() as
75
+ { filename: string; checksum: string }[]
76
+ return new Map(rows.map((r) => [r.filename, r.checksum]))
77
+ }
78
+
79
+ function verifyChecksum(file: MigrationFile, knownChecksum: string): void {
80
+ if (file.checksum !== knownChecksum) {
81
+ throw new Error(
82
+ `[migrate] checksum mismatch for already-applied "${file.filename}". ` +
83
+ `Applied migrations are immutable — add a new migration instead of editing this one.`,
84
+ )
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Snapshot the runtime DB before any pending migration is applied. Keeps the
90
+ * last BACKUP_RETAIN snapshots, prunes older ones. Returns the backup path,
91
+ * or null if there is nothing to back up (first-ever run).
92
+ *
93
+ * Filename: <db>.bak-<ISO-timestamp> with colons replaced (Windows-safe).
94
+ * Backups live next to the DB. They are not an audit log — durable history
95
+ * lives in the runs table itself.
96
+ */
97
+ function backupAndPrune(dbPath: string): string | null {
98
+ if (!existsSync(dbPath)) return null
99
+
100
+ const dir = dirname(dbPath)
101
+ const base = basename(dbPath)
102
+ const ts = new Date().toISOString().replace(/[:.]/g, "-")
103
+ const backupPath = join(dir, `${base}.bak-${ts}`)
104
+
105
+ copyFileSync(dbPath, backupPath)
106
+ console.log(`[migrate] backup ${backupPath}`)
107
+
108
+ const prefix = `${base}.bak-`
109
+ const all = readdirSync(dir)
110
+ .filter((f) => f.startsWith(prefix))
111
+ .map((f) => ({ name: f, mtime: statSync(join(dir, f)).mtimeMs }))
112
+ .sort((a, b) => b.mtime - a.mtime)
113
+
114
+ for (const stale of all.slice(BACKUP_RETAIN)) {
115
+ unlinkSync(join(dir, stale.name))
116
+ console.log(`[migrate] pruned old backup ${stale.name}`)
117
+ }
118
+
119
+ return backupPath
120
+ }
121
+
122
+ /**
123
+ * Returns the list of migrations that exist on disk but are not yet applied
124
+ * to the given database. Verifies checksums of already-applied migrations as
125
+ * a side-effect (throws on mismatch). Does not mutate the database.
126
+ */
127
+ export function pendingMigrations(dbPath: string, opts: MigrateOptions = {}): string[] {
128
+ const files = readMigrationFiles(opts.migrationsDir ?? DEFAULT_MIGRATIONS_DIR)
129
+ const db = new Database(dbPath, { create: true })
130
+ try {
131
+ ensureMigrationsTable(db)
132
+ const applied = readApplied(db)
133
+ const pending: string[] = []
134
+ for (const file of files) {
135
+ const known = applied.get(file.filename)
136
+ if (known === undefined) pending.push(file.filename)
137
+ else verifyChecksum(file, known)
138
+ }
139
+ return pending
140
+ } finally {
141
+ db.close()
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Applies pending migrations in lexicographic order. Idempotent: returns
147
+ * with empty `applied` if nothing is pending. Each migration runs in its own
148
+ * SQL transaction; a failure rolls back that migration and aborts the run.
149
+ *
150
+ * Before applying anything, snapshots the DB to <db>.bak-<timestamp> and
151
+ * prunes old backups beyond BACKUP_RETAIN. No backup is taken when nothing
152
+ * is pending (no-op runs leave the filesystem alone).
153
+ */
154
+ export function runMigrations(dbPath: string, opts: MigrateOptions = {}): MigrationResult {
155
+ const files = readMigrationFiles(opts.migrationsDir ?? DEFAULT_MIGRATIONS_DIR)
156
+ const result: MigrationResult = { applied: [], alreadyApplied: [] }
157
+
158
+ // Phase 1: open, verify checksums of already-applied, collect pending. Close.
159
+ const checkDb = new Database(dbPath, { create: true })
160
+ const pending: MigrationFile[] = []
161
+ try {
162
+ ensureMigrationsTable(checkDb)
163
+ const applied = readApplied(checkDb)
164
+ for (const file of files) {
165
+ const known = applied.get(file.filename)
166
+ if (known === undefined) {
167
+ pending.push(file)
168
+ } else {
169
+ verifyChecksum(file, known)
170
+ result.alreadyApplied.push(file.filename)
171
+ }
172
+ }
173
+ } finally {
174
+ checkDb.close()
175
+ }
176
+
177
+ if (pending.length === 0) return result
178
+
179
+ // Phase 2: backup with no DB connection open, then apply pending sequentially.
180
+ result.backup = backupAndPrune(dbPath) ?? undefined
181
+
182
+ const db = new Database(dbPath, { create: true })
183
+ try {
184
+ for (const file of pending) {
185
+ const apply = db.transaction(() => {
186
+ db.exec(file.contents)
187
+ db.prepare(
188
+ "INSERT INTO _migrations (filename, checksum) VALUES (?, ?)",
189
+ ).run(file.filename, file.checksum)
190
+ })
191
+ try {
192
+ apply()
193
+ result.applied.push(file.filename)
194
+ console.log(`[migrate] applied ${file.filename}`)
195
+ } catch (err) {
196
+ const msg = err instanceof Error ? err.message : String(err)
197
+ throw new Error(`[migrate] failed applying "${file.filename}": ${msg}`)
198
+ }
199
+ }
200
+ return result
201
+ } finally {
202
+ db.close()
203
+ }
204
+ }