@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/CHANGELOG.md +69 -0
- package/migrations/001_initial.sql +111 -0
- package/package.json +27 -0
- package/src/cache-db.ts +17 -0
- package/src/cache.ts +67 -0
- package/src/config.ts +129 -0
- package/src/data-db.ts +21 -0
- package/src/db-handles.ts +70 -0
- package/src/hitl.ts +257 -0
- package/src/index.ts +34 -0
- package/src/instance.ts +64 -0
- package/src/kinds/control.ts +26 -0
- package/src/kinds/custom.ts +19 -0
- package/src/kinds/data.ts +30 -0
- package/src/kinds/db.ts +92 -0
- package/src/kinds/hitl.ts +56 -0
- package/src/kinds/http.ts +134 -0
- package/src/kinds/index.ts +66 -0
- package/src/kinds/runs.ts +130 -0
- package/src/kinds/transform.ts +123 -0
- package/src/kinds/types.ts +16 -0
- package/src/lock.ts +64 -0
- package/src/migrate.ts +204 -0
- package/src/pipeline.ts +601 -0
- package/src/resources.ts +350 -0
- package/src/runs.ts +53 -0
- package/src/runtime-db.ts +48 -0
- package/src/server.ts +537 -0
- package/src/startRunner.ts +87 -0
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
|
+
}
|