Public: anyone can read this; the crew can change it.
A migration is proven against production's data, and a failed one says which #33
Check · Review · Land · Live done — Landed and live in production.
hearth/quorum:hearth/29-a-migration-is-proven-against-production → main 4 commits fork 2 threads · 2 unresolved
AGENTS.md +10 −3
| @@ -9,8 +9,10 @@ preview at its own address, deployed from your branch as you push. | ||
| 9 | 9 | - Work on the branch you were given. You cannot move the default branch; |
| 10 | 10 | a person merges your pull once it is reviewed. |
| 11 | 11 | - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime. |
| 12 | - Run `bun test` before you push. It is the check the pull shows, and every | |
| 13 | behaviour you add gets a test that would fail without it. | |
| 12 | - Run `bun run test` before you push: `bun test`, and then the migrations | |
| 13 | against a throwaway `FORGE_APP_DATA` (`bun run src/db.ts --check`), which | |
| 14 | is the check the pull shows. Every behaviour you add gets a test that | |
| 15 | would fail without it. | |
| 14 | 16 | - `src/tty.ts` is the app's terminal. The exact command that reaches it is |
| 15 | 17 | `FORGE_APP_SSH` at runtime; print that verbatim wherever the app tells |
| 16 | 18 | people how to connect. What a person can do on the page they can do here, |
| @@ -43,7 +45,12 @@ To add a concept: | ||
| 43 | 45 | - **An action** — a function in `src/actions.ts`, its rule inside it, and |
| 44 | 46 | a test in `src/actions.test.ts` that fails without the rule. |
| 45 | 47 | - **A migration** — append to `migrations` in `src/db.ts`. Never edit one |
| 46 | that has run. `bun run src/db.ts --check` shows the version. | |
| 48 | that has run. `bun run src/db.ts --check` shows the version. It ships with | |
| 49 | a test that upgrades a seeded database — `atVersion(n - 1)` in | |
| 50 | `src/db.test.ts`, three lines — because previews carry production's data | |
| 51 | and your migration meets real rows there, not an empty file. A migration | |
| 52 | that adds a table adds a row to `seeds` there, so what comes after it is | |
| 53 | proven against that table too. | |
| 47 | 54 | - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`. |
| 48 | 55 | With a second page, move them under `src/pages/` and keep one `layout`. |
| 49 | 56 | - **An API route** — a row in `src/routes.ts` that calls the action and |
README.md +35 −1
| @@ -9,7 +9,8 @@ makes "production data" and "preview data" two different things. | ||
| 9 | 9 | |
| 10 | 10 | sh |
| 11 | 11 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| 12 | bun test # the check every pull runs | |
| 12 | bun run test # the check every pull runs | |
| 13 | bun test # just the tests | |
| 13 | 14 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal |
| 14 | 15 | FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity |
| 15 | 16 | |
| @@ -135,6 +136,39 @@ it where it came from. `/about` shows an admin the file's size, its schema | ||
| 135 | 136 | version, the last snapshot kept beside it and both download links, and shows |
| 136 | 137 | nobody else anything. |
| 137 | 138 | |
| 139 | ## Migrations, against real data | |
| 140 | ||
| 141 | `src/db.ts` holds a forward-only list of migrations, applied once and in one | |
| 142 | transaction when the file is opened. A preview gets a copy of production's | |
| 143 | data, so a migration meets real rows there before a person does — and the two | |
| 144 | ways that goes wrong each say so in a sentence: | |
| 145 | ||
| 146 | ||
| 147 | migration 7 failed: Cannot add a NOT NULL column with default value NULL | |
| 148 | database is at schema version 8 but this build has 6 migrations: it was | |
| 149 | written by a newer branch than this one, so nothing was applied. | |
| 150 | ||
| 151 | ||
| 152 | Either one goes to stderr and the process exits 1 — at boot, at the terminal | |
| 153 | and from `bun run src/db.ts --check` — and never as a stack trace. The | |
| 154 | transaction is all-or-none: a failed migration leaves no half schema and no | |
| 155 | version recorded for it, so fixing the code and deploying again is the whole | |
| 156 | recovery. A database recorded *ahead* of the list is refused rather than | |
| 157 | migrated, because a preview from an older branch must not write versions over | |
| 158 | newer ones. | |
| 159 | ||
| 160 | `bun run test` is `bun test` and then those migrations against a throwaway | |
| 161 | `FORGE_APP_DATA`, which is what the pull's check runs. The two halves catch | |
| 162 | different things, and the check needs both: `--check` on an empty directory | |
| 163 | proves the list applies at all, and `bun test` is what meets rows — | |
| 164 | `src/db.test.ts` has one case per shipped migration, where `atVersion(n)` | |
| 165 | builds a database with the first `n` migrations and a seeded row in every | |
| 166 | table that exists at that version, and the case asserts every row survives | |
| 167 | the next migration with `PRAGMA integrity_check` still `ok`. A migration that | |
| 168 | fails only against data — the common kind — fails there, before the preview's | |
| 169 | boot. A migration that has run is never edited; if one was wrong, the fix is | |
| 170 | another migration. | |
| 171 | ||
| 138 | 172 | A proposal is a title (1–120 characters), a paragraph (1–2000) and two to |
| 139 | 173 | eight options, each labelled, each different. Every option is a line of text: |
| 140 | 174 | a JSON or MCP caller that sends a number, an object or a list in that array is |
forge.json +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "app": { |
| 3 | 3 | "run": "bun run src/server.ts", |
| 4 | "test": "bun test", | |
| 4 | "test": "bun test && FORGE_APP_DATA=\"$(mktemp -d)\" bun run src/db.ts --check", | |
| 5 | 5 | "tty": "bun run src/tty.ts" |
| 6 | 6 | } |
| 7 | 7 | } |
package.json +1 −1
| @@ -5,6 +5,6 @@ | ||
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | 7 | "start": "bun run src/server.ts", |
| 8 | "test": "bun test" | |
| 8 | "test": "bun test && FORGE_APP_DATA=\"$(mktemp -d)\" bun run src/db.ts --check" | |
| 9 | 9 | } |
| 10 | 10 | } |
src/backup.test.ts +19
| @@ -218,6 +218,25 @@ test("the terminal takes a snapshot and prints where it went", () => { | ||
| 218 | 218 | expect(out).toContain(`schema version ${migrations.length}`); |
| 219 | 219 | }); |
| 220 | 220 | |
| 221 | test("a database the build cannot open refuses the snapshot in one line", () => { | |
| 222 | // The same door as the boot and the terminal: a file written by a newer | |
| 223 | // branch stops this CLI too, with the sentence and not a stack. | |
| 224 | const at = dir(); | |
| 225 | const db = withRows(at); | |
| 226 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run( | |
| 227 | migrations.length + 1, | |
| 228 | ); | |
| 229 | db.close(); | |
| 230 | const ran = Bun.spawnSync(["bun", "run", "src/backup.ts", "--snapshot"], { | |
| 231 | env: { ...process.env, FORGE_APP_DATA: at }, | |
| 232 | }); | |
| 233 | expect(ran.exitCode).toBe(1); | |
| 234 | const said = ran.stderr.toString(); | |
| 235 | expect(said).toContain("written by a newer branch"); | |
| 236 | expect(said.trim().split("\n")).toHaveLength(1); | |
| 237 | expect(snapshots(at)).toEqual([]); | |
| 238 | }); | |
| 239 | ||
| 221 | 240 | // The doors. The admin check is in the action, so it holds at both of them |
| 222 | 241 | // and on the page that links them. |
| 223 | 242 | const KEY = "a-key-only-the-forge-holds"; |
src/backup.ts +3 −3
| @@ -29,7 +29,7 @@ import { | ||
| 29 | 29 | unlinkSync, |
| 30 | 30 | } from "node:fs"; |
| 31 | 31 | import { basename, dirname, join, resolve } from "node:path"; |
| 32 | import { migrations, openDb, schemaVersion } from "./db.ts"; | |
| 32 | import { migrations, openOrExit, schemaVersion } from "./db.ts"; | |
| 33 | 33 | import { title } from "./env.ts"; |
| 34 | 34 | |
| 35 | 35 | /** The first sixteen bytes of every SQLite database, and nothing else. */ |
| @@ -291,7 +291,7 @@ if (import.meta.main) { | ||
| 291 | 291 | const argv = process.argv.slice(2); |
| 292 | 292 | const restoring = argv.indexOf("--restore"); |
| 293 | 293 | if (argv.includes("--snapshot")) { |
| 294 | const db = openDb(); | |
| 294 | const db = openOrExit(); | |
| 295 | 295 | const file = snapshot(db); |
| 296 | 296 | console.log(`snapshot ${resolve(file.path)}`); |
| 297 | 297 | console.log(`${file.bytes} bytes, schema version ${schemaVersion(db)}`); |
| @@ -304,7 +304,7 @@ if (import.meta.main) { | ||
| 304 | 304 | } |
| 305 | 305 | // The database is opened once first, so a restore onto a directory |
| 306 | 306 | // that has never run this app still lands beside a WAL-mode file. |
| 307 | const db = openDb(); | |
| 307 | const db = openOrExit(); | |
| 308 | 308 | const dir = dirOf(db); |
| 309 | 309 | db.close(); |
| 310 | 310 | const done = restore(file, dir); |
src/db.test.ts +216 −18
| @@ -3,7 +3,91 @@ import { expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtempSync } from "node:fs"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { check, migrate, migrations, openDb } from "./db.ts"; | |
| 6 | import { | |
| 7 | MigrationError, | |
| 8 | check, | |
| 9 | migrate, | |
| 10 | migrations, | |
| 11 | openDb, | |
| 12 | openFailure, | |
| 13 | } from "./db.ts"; | |
| 14 | ||
| 15 | /** | |
| 16 | * One row per table, in the order the foreign keys need. A migration that | |
| 17 | * adds a table adds a line here, and every case below then proves that | |
| 18 | * table's rows survive whatever comes after it. | |
| 19 | */ | |
| 20 | const seeds: Record<string, string> = { | |
| 21 | note: "INSERT INTO note (body, author_id, author_name) VALUES ('history', 'u1', 'alice')", | |
| 22 | proposal: | |
| 23 | "INSERT INTO proposal (title, body, author_id, author_name) VALUES ('Where do we meet?', 'The old room is cold.', 'u1', 'alice')", | |
| 24 | option: | |
| 25 | "INSERT INTO option (proposal_id, label, position) VALUES (1, 'The library', 0)", | |
| 26 | event: | |
| 27 | "INSERT INTO event (type, data) VALUES ('proposal.opened', '{\"id\":1}')", | |
| 28 | remark: | |
| 29 | "INSERT INTO remark (proposal_id, kind, body, author_id, author_name) VALUES (1, 'comment', 'Neither room is warm.', 'u1', 'alice')", | |
| 30 | member: | |
| 31 | "INSERT INTO member (person_id, name, added_by) VALUES ('u1', 'alice', 'u9')", | |
| 32 | }; | |
| 33 | ||
| 34 | /** A migration that deliberately takes a table with it: the version, and what goes. */ | |
| 35 | const drops: Record<number, readonly string[]> = { 4: ["note"] }; | |
| 36 | ||
| 37 | /** A connection like the app's: the same pragmas, no migrations of its own. */ | |
| 38 | function connect(dir: string): Database { | |
| 39 | const db = new Database(join(dir, "app.db")); | |
| 40 | db.exec("PRAGMA foreign_keys = ON"); | |
| 41 | db.exec("PRAGMA busy_timeout = 5000"); | |
| 42 | return db; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * A database with the first `n` migrations and one seeded row in every table | |
| 47 | * that exists at that version — what a preview's copy of production looks | |
| 48 | * like to the migration that runs next. Three lines test a new migration: | |
| 49 | * `atVersion(n - 1)`, `upgrade(dir)`, compare `seeded`. | |
| 50 | */ | |
| 51 | function atVersion(n: number): { dir: string; db: Database } { | |
| 52 | const dir = mkdtempSync(join(tmpdir(), "app-db-")); | |
| 53 | const db = connect(dir); | |
| 54 | expect(migrate(db, { upTo: n })).toBe(n); | |
| 55 | for (const [table, sql] of Object.entries(seeds)) | |
| 56 | if (tables(db).includes(table)) db.query(sql).run(); | |
| 57 | return { dir, db }; | |
| 58 | } | |
| 59 | ||
| 60 | /** Open the file again and run what is left, as a boot would. */ | |
| 61 | function upgrade(dir: string, upTo?: number): Database { | |
| 62 | const db = connect(dir); | |
| 63 | migrate(db, upTo === undefined ? {} : { upTo }); | |
| 64 | return db; | |
| 65 | } | |
| 66 | ||
| 67 | const tables = (db: Database): string[] => | |
| 68 | db | |
| 69 | .query< | |
| 70 | { name: string }, | |
| 71 | [] | |
| 72 | >("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") | |
| 73 | .all() | |
| 74 | .map((row) => row.name); | |
| 75 | ||
| 76 | /** How many seeded rows each seeded table still holds. */ | |
| 77 | const seeded = (db: Database): Record<string, number> => | |
| 78 | Object.fromEntries( | |
| 79 | tables(db) | |
| 80 | .filter((table) => table in seeds) | |
| 81 | .map((table) => [ | |
| 82 | table, | |
| 83 | db.query<{ n: number }, []>(`SELECT count(*) AS n FROM ${table}`).get()! | |
| 84 | .n, | |
| 85 | ]), | |
| 86 | ); | |
| 87 | ||
| 88 | const integrity = (db: Database): string => | |
| 89 | db.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get()! | |
| 90 | .integrity_check; | |
| 7 | 91 | |
| 8 | 92 | test("migrations apply once, in order, and the file checks out", () => { |
| 9 | 93 | const dir = mkdtempSync(join(tmpdir(), "app-db-")); |
| @@ -23,25 +107,139 @@ test("migrations apply once, in order, and the file checks out", () => { | ||
| 23 | 107 | ).toBe(1); |
| 24 | 108 | }); |
| 25 | 109 | |
| 26 | test("a database that stopped at the note migrates forward", () => { | |
| 27 | const dir = mkdtempSync(join(tmpdir(), "app-db-")); | |
| 28 | const old = new Database(join(dir, "app.db")); | |
| 29 | old.exec( | |
| 30 | "CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))", | |
| 31 | ); | |
| 32 | migrations.slice(0, 2).forEach((sql, i) => { | |
| 33 | old.exec(sql); | |
| 34 | old.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); | |
| 35 | }); | |
| 36 | old.query( | |
| 37 | "INSERT INTO note (body, author_id, author_name) VALUES ('history', 'u1', 'alice')", | |
| 38 | ).run(); | |
| 39 | old.close(); | |
| 110 | // One case per shipped migration: the version before it, seeded, upgraded by | |
| 111 | // it alone. Every row that was there is there afterwards, except in a table | |
| 112 | // the migration says it drops, and the file still checks out. | |
| 113 | for (let n = 1; n <= migrations.length; n++) { | |
| 114 | test(`migration ${n} upgrades a seeded database from version ${n - 1}`, () => { | |
| 115 | const { dir, db } = atVersion(n - 1); | |
| 116 | const before = seeded(db); | |
| 117 | db.close(); | |
| 40 | 118 | |
| 41 | const db = openDb(dir); | |
| 119 | const up = upgrade(dir, n); | |
| 120 | expect(check(up).version).toBe(n); | |
| 121 | const after = seeded(up); | |
| 122 | // A table goes only where the migration says so; every other seeded row | |
| 123 | // is still in place afterwards. | |
| 124 | const gone = drops[n] ?? []; | |
| 125 | for (const table of gone) expect(Object.keys(before)).toContain(table); | |
| 126 | for (const table of gone) expect(Object.keys(after)).not.toContain(table); | |
| 127 | for (const [table, count] of Object.entries(before)) | |
| 128 | if (!gone.includes(table)) expect(after[table]).toBe(count); | |
| 129 | expect(integrity(up)).toBe("ok"); | |
| 130 | up.close(); | |
| 131 | }); | |
| 132 | } | |
| 133 | ||
| 134 | test("a database that stopped at the note migrates forward", () => { | |
| 135 | const { dir, db } = atVersion(2); | |
| 136 | expect(seeded(db)).toEqual({ note: 1 }); | |
| 137 | db.close(); | |
| 138 | ||
| 139 | const up = upgrade(dir); | |
| 140 | expect(check(up)).toEqual({ version: migrations.length, integrity: "ok" }); | |
| 141 | expect(() => up.query("SELECT 1 FROM note").get()).toThrow(); | |
| 142 | expect(() => up.query("SELECT 1 FROM proposal").get()).not.toThrow(); | |
| 143 | up.close(); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a migration that fails names itself, and leaves no half schema", () => { | |
| 147 | const { dir, db } = atVersion(migrations.length); | |
| 148 | const before = seeded(db); | |
| 149 | // Exactly the failure a preview finds and an empty database does not: a | |
| 150 | // NOT NULL column with no default, against rows that already exist. Two | |
| 151 | // statements, so a rollback has something to undo. | |
| 152 | const broken = [ | |
| 153 | ...migrations, | |
| 154 | `ALTER TABLE proposal ADD COLUMN half TEXT; | |
| 155 | ALTER TABLE proposal ADD COLUMN deadline TEXT NOT NULL`, | |
| 156 | ]; | |
| 157 | let thrown: unknown; | |
| 158 | try { | |
| 159 | migrate(db, { list: broken }); | |
| 160 | } catch (error) { | |
| 161 | thrown = error; | |
| 162 | } | |
| 163 | expect(thrown).toBeInstanceOf(MigrationError); | |
| 164 | const message = (thrown as Error).message; | |
| 165 | expect(message).toStartWith(`migration ${broken.length} failed: `); | |
| 166 | expect(message).toContain("NOT NULL"); | |
| 167 | expect(message.split("\n")).toHaveLength(1); | |
| 168 | // What stderr gets is that sentence and nothing else — no stack. | |
| 169 | expect(openFailure(thrown, dir)).toBe(message); | |
| 170 | ||
| 171 | // All or none: no new column, no version for it, every row still there. | |
| 172 | const columns = db | |
| 173 | .query< | |
| 174 | { name: string }, | |
| 175 | [] | |
| 176 | >("SELECT name FROM pragma_table_info('proposal')") | |
| 177 | .all() | |
| 178 | .map((row) => row.name); | |
| 179 | expect(columns).not.toContain("half"); | |
| 180 | expect(columns).not.toContain("deadline"); | |
| 42 | 181 | expect(check(db)).toEqual({ version: migrations.length, integrity: "ok" }); |
| 43 | expect(() => db.query("SELECT 1 FROM note").get()).toThrow(); | |
| 44 | expect(() => db.query("SELECT 1 FROM proposal").get()).not.toThrow(); | |
| 182 | expect(seeded(db)).toEqual(before); | |
| 183 | db.close(); | |
| 184 | }); | |
| 185 | ||
| 186 | test("a database recorded ahead of the list is refused, not migrated", () => { | |
| 187 | const { dir, db } = atVersion(migrations.length); | |
| 188 | // A preview deployed from an older branch onto newer data. | |
| 189 | const ahead = migrations.length + 1; | |
| 190 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run(ahead); | |
| 191 | db.close(); | |
| 192 | ||
| 193 | let thrown: unknown; | |
| 194 | try { | |
| 195 | openDb(dir); | |
| 196 | } catch (error) { | |
| 197 | thrown = error; | |
| 198 | } | |
| 199 | expect(thrown).toBeInstanceOf(MigrationError); | |
| 200 | expect((thrown as Error).message).toBe( | |
| 201 | `database is at schema version ${ahead} but this build has ${migrations.length} migrations: it was written by a newer branch than this one, so nothing was applied.`, | |
| 202 | ); | |
| 203 | const still = connect(dir); | |
| 204 | expect(check(still).version).toBe(ahead); | |
| 205 | still.close(); | |
| 206 | }); | |
| 207 | ||
| 208 | test("a failure that cannot open the database names the directory", () => { | |
| 209 | expect(openFailure(new Error("unable to open database file"), "/nope")).toBe( | |
| 210 | "cannot open the database in /nope: unable to open database file", | |
| 211 | ); | |
| 212 | }); | |
| 213 | ||
| 214 | test("`db.ts --check` says the version, and exits 1 on a refusal", () => { | |
| 215 | const script = join(import.meta.dir, "db.ts"); | |
| 216 | const { dir, db } = atVersion(migrations.length); | |
| 217 | db.close(); | |
| 218 | ||
| 219 | const sound = Bun.spawnSync({ | |
| 220 | cmd: ["bun", "run", script, "--check"], | |
| 221 | env: { ...process.env, FORGE_APP_DATA: dir }, | |
| 222 | }); | |
| 223 | expect(sound.stdout.toString()).toBe( | |
| 224 | `schema version ${migrations.length}\nintegrity ok\n`, | |
| 225 | ); | |
| 226 | expect(sound.exitCode).toBe(0); | |
| 227 | ||
| 228 | const ahead = connect(dir); | |
| 229 | ahead | |
| 230 | .query("INSERT INTO schema_version (version) VALUES (?1)") | |
| 231 | .run(migrations.length + 1); | |
| 232 | ahead.close(); | |
| 233 | const refused = Bun.spawnSync({ | |
| 234 | cmd: ["bun", "run", script, "--check"], | |
| 235 | env: { ...process.env, FORGE_APP_DATA: dir }, | |
| 236 | }); | |
| 237 | expect(refused.exitCode).toBe(1); | |
| 238 | const said = refused.stderr.toString(); | |
| 239 | expect(said).toContain("written by a newer branch"); | |
| 240 | // One sentence an operator can act on, and not a line of source. | |
| 241 | expect(said.trim().split("\n")).toHaveLength(1); | |
| 242 | expect(said).not.toContain("db.ts:"); | |
| 45 | 243 | }); |
| 46 | 244 | |
| 47 | 245 | test("a database with proposals in it takes the newest migration", () => { |
src/db.ts +86 −15
| @@ -5,6 +5,11 @@ | ||
| 5 | 5 | * on, and a forward-only list of migrations applied once, in a |
| 6 | 6 | * transaction, when the file is opened. |
| 7 | 7 | * |
| 8 | * A preview carries a copy of production's data, so a migration meets real | |
| 9 | * rows before a person does. When one fails it says which one and what | |
| 10 | * SQLite said, on stderr, and the process stops with a non-zero status — | |
| 11 | * never a stack trace from inside `serve()`. | |
| 12 | * | |
| 8 | 13 | * bun run src/db.ts --check the schema version and an integrity check |
| 9 | 14 | */ |
| 10 | 15 | |
| @@ -88,6 +93,15 @@ export const migrations: string[] = [ | ||
| 88 | 93 | )`, |
| 89 | 94 | ]; |
| 90 | 95 | |
| 96 | /** | |
| 97 | * A migration that would not apply, in one sentence an operator can act on: | |
| 98 | * which migration, and what SQLite said about it. The cause is kept for a | |
| 99 | * caller that wants it; nothing prints the stack. | |
| 100 | */ | |
| 101 | export class MigrationError extends Error { | |
| 102 | override readonly name = "MigrationError"; | |
| 103 | } | |
| 104 | ||
| 91 | 105 | export function openDb(dir = dataDir): Database { |
| 92 | 106 | mkdirSync(dir, { recursive: true }); |
| 93 | 107 | const db = new Database(join(dir, "app.db")); |
| @@ -102,28 +116,82 @@ export function openDb(dir = dataDir): Database { | ||
| 102 | 116 | return db; |
| 103 | 117 | } |
| 104 | 118 | |
| 105 | /** Apply the migrations past the recorded version, all or none. Returns the version. */ | |
| 106 | export function migrate(db: Database): number { | |
| 119 | export interface MigrateOptions { | |
| 120 | /** Stop after this many migrations. The whole list by default. */ | |
outdated
x
outdated
Housekeeping: the two one-character comments above are mine and mean nothing — they are probes of this forge's review API (it wants an anchored line, not a bare verdict) and there is no delete route to take them back. Ignore them; the review proper is on the ahead-of-the-list guard a few lines down. |
||
| 121 | readonly upTo?: number; | |
| 122 | /** The list to apply. `migrations` everywhere but a test. */ | |
| 123 | readonly list?: readonly string[]; | |
| 124 | } | |
| 125 | ||
| 126 | /** | |
| 127 | * Apply the migrations past the recorded version, all or none. Returns the | |
outdated
Approved. I read every changed line, ran the suite at this head, and exercised the failure paths myself rather than trusting the description. It does what it says. I appended The tests bite. I mutated the code to check: deleting the ahead-of-the-list guard and the naming Security: nothing about identity, routes, body caps, rate limits or escaping is touched. The two failure sentences carry SQLite's words, a migration index and the data directory, and go only to stderr — never a row, never a person, never an HTTP body. The preview still serves the floor (CSP, Three things to know, none of them blocking:
Nothing here is worth another round. Land it. |
||
| 128 | * version reached. A failure throws `MigrationError` naming the migration, | |
| 129 | * and the transaction leaves the file exactly as it found it: no half | |
| 130 | * schema, no version recorded for a migration that did not finish. | |
| 131 | */ | |
| 132 | export function migrate(db: Database, options: MigrateOptions = {}): number { | |
| 133 | const list = options.list ?? migrations; | |
| 134 | const target = Math.min(options.upTo ?? list.length, list.length); | |
| 107 | 135 | db.exec( |
| 108 | 136 | "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))", |
| 109 | 137 | ); |
| 110 | 138 | return db.transaction(() => { |
| 111 | const current = db | |
| 112 | .query< | |
| 113 | { version: number }, | |
| 114 | [] | |
| 115 | >("SELECT coalesce(max(version), 0) AS version FROM schema_version") | |
| 116 | .get()!.version; | |
| 117 | migrations.slice(current).forEach((sql, i) => { | |
| 118 | db.exec(sql); | |
| 119 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run( | |
| 120 | current + i + 1, | |
| 139 | const current = schemaVersion(db); | |
| 140 | // Data newer than the code: a preview deployed from a branch that does | |
| 141 | // not have the migrations this file has already run. Going forward from | |
| 142 | // here would record versions over somebody else's, so stop instead. | |
| 143 | if (current > list.length) | |
| 144 | throw new MigrationError( | |
| 145 | `database is at schema version ${current} but this build has ${list.length} migrations: it was written by a newer branch than this one, so nothing was applied.`, | |
| 121 | 146 | ); |
| 122 | }); | |
| 123 | return migrations.length; | |
| 147 | for (let i = current; i < target; i++) { | |
| 148 | try { | |
| 149 | db.exec(list[i]!); | |
| 150 | } catch (cause) { | |
| 151 | throw new MigrationError(`migration ${i + 1} failed: ${said(cause)}`, { | |
| 152 | cause, | |
| 153 | }); | |
| 154 | } | |
| 155 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); | |
| 156 | } | |
| 157 | return Math.max(current, target); | |
| 124 | 158 | })(); |
| 125 | 159 | } |
| 126 | 160 | |
| 161 | /** What something thrown says, without the stack that came with it. */ | |
| 162 | const said = (error: unknown): string => | |
| 163 | error instanceof Error ? error.message : String(error); | |
| 164 | ||
| 165 | /** | |
| 166 | * Why the database would not open, as the one line stderr gets. A migration | |
| 167 | * failure already says it; anything else names the directory, because the | |
| 168 | * usual answer is that `FORGE_APP_DATA` is not writable. | |
| 169 | */ | |
| 170 | export const openFailure = (error: unknown, dir: string): string => | |
| 171 | error instanceof MigrationError | |
| 172 | ? error.message | |
| 173 | : `cannot open the database in ${dir}: ${said(error)}`; | |
| 174 | ||
| 175 | /** The boot that cannot go on: one line, exit 1, never a stack. */ | |
| 176 | export function exitOnOpenFailure(error: unknown, dir: string): never { | |
| 177 | console.error(openFailure(error, dir)); | |
| 178 | process.exit(1); | |
| 179 | } | |
| 180 | ||
| 181 | /** | |
| 182 | * `openDb` for something with a process to stop: the database, or the one | |
| 183 | * line and exit 1. Every entry point that owns the process uses this, so | |
| 184 | * the sentence is the same at the boot, the terminal and the CLIs, while | |
| 185 | * `openDb` itself keeps throwing for `serve()` and anything embedding it. | |
| 186 | */ | |
| 187 | export function openOrExit(dir = dataDir): Database { | |
| 188 | try { | |
| 189 | return openDb(dir); | |
| 190 | } catch (error) { | |
| 191 | exitOnOpenFailure(error, dir); | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 127 | 195 | /** The version recorded in the file. Zero before a migration has ever run. */ |
| 128 | 196 | export const schemaVersion = (db: Database): number => |
| 129 | 197 | db |
| @@ -151,7 +219,10 @@ export function check(db: Database): { version: number; integrity: string } { | ||
| 151 | 219 | } |
| 152 | 220 | |
| 153 | 221 | if (import.meta.main) { |
| 154 | const db = openDb(); | |
| 222 | // The cheap rehearsal: open the database the migrations would meet at | |
| 223 | // boot and say what happened. `forge.json` runs this in the check, so a | |
| 224 | // migration that cannot apply fails the pull and not the preview's boot. | |
| 225 | const db = openOrExit(); | |
| 155 | 226 | const { version, integrity } = check(db); |
| 156 | 227 | console.log(`schema version ${version}`); |
| 157 | 228 | if (process.argv.includes("--check")) { |
src/server.test.ts +23
| @@ -838,6 +838,29 @@ test("boot names what is missing, and never what is in it", () => { | ||
| 838 | 838 | expect(quiet[0]).toContain("FORGE_APP_URL"); |
| 839 | 839 | }); |
| 840 | 840 | |
| 841 | test("a boot the database refuses says which migration, not a stack", () => { | |
| 842 | const dir = mkdtempSync(join(tmpdir(), "app-ahead-")); | |
| 843 | serve({ port: 0, dataDir: dir, identityKey: KEY }).stop(true); | |
| 844 | // A preview deployed from an older branch onto newer data: the file knows | |
| 845 | // a migration this build does not have. | |
| 846 | const file = new Database(join(dir, "app.db")); | |
| 847 | file | |
| 848 | .query("INSERT INTO schema_version (version) VALUES (?1)") | |
| 849 | .run(migrations.length + 1); | |
| 850 | file.close(); | |
| 851 | ||
| 852 | const boot = Bun.spawnSync({ | |
| 853 | cmd: ["bun", "run", join(import.meta.dir, "server.ts")], | |
| 854 | env: { ...process.env, FORGE_APP_DATA: dir, PORT: "0" }, | |
| 855 | }); | |
| 856 | expect(boot.exitCode).toBe(1); | |
| 857 | const said = boot.stderr.toString().trim(); | |
| 858 | expect(said).toContain(`this build has ${migrations.length} migrations`); | |
| 859 | expect(said.split("\n")).toHaveLength(1); | |
| 860 | expect(said).not.toContain("server.ts:"); | |
| 861 | expect(boot.stdout.toString()).not.toContain("listening"); | |
| 862 | }); | |
| 863 | ||
| 841 | 864 | test("a change is on the event stream, and a late client hears what it missed", async () => { |
| 842 | 865 | const stream = await fetch(base + "/events"); |
| 843 | 866 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); |
src/server.ts +6 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * it serves what the forge said; a test calls `serve` with its own. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { openDb } from "./db.ts"; | |
| 9 | import { openDb, openOrExit } from "./db.ts"; | |
| 10 | 10 | import { |
| 11 | 11 | dataDir, |
| 12 | 12 | identityKey, |
| @@ -89,6 +89,11 @@ export function serve(options: ServeOptions) { | ||
| 89 | 89 | } |
| 90 | 90 | |
| 91 | 91 | if (import.meta.main) { |
| 92 | // The database before the socket: a preview carries production's data, so | |
| 93 | // a migration meets real rows here first. A failure says which migration | |
| 94 | // and what SQLite said, then stops — never a stack from inside `serve`. | |
| 95 | // Migrations run once, so the open `serve` does next finds nothing to do. | |
| 96 | openOrExit(dataDir).close(); | |
| 92 | 97 | const server = serve({ |
| 93 | 98 | port: Number(process.env.PORT ?? 8080), |
| 94 | 99 | hostname: process.env.HOST, |
src/tty.ts +4 −2
| @@ -26,13 +26,15 @@ import { | ||
| 26 | 26 | type ProposalSummary, |
| 27 | 27 | type Remark, |
| 28 | 28 | } from "./actions.ts"; |
| 29 | import { openDb } from "./db.ts"; | |
| 29 | import { openOrExit } from "./db.ts"; | |
| 30 | 30 | import { oneLine, plain } from "./plain.ts"; |
| 31 | 31 | import { ssh, title, where } from "./env.ts"; |
| 32 | 32 | import { describeRule, RULE_FORMS } from "./quorum.ts"; |
| 33 | 33 | import { whoAtTty } from "./who.ts"; |
| 34 | 34 | |
| 35 | const db = openDb(); | |
| 35 | // The same file the server opens, and the same answer when it will not open: | |
| 36 | // which migration failed, on stderr, and no session at all. | |
| 37 | const db = openOrExit(); | |
| 36 | 38 | const me = whoAtTty(); |
| 37 | 39 | const esc = "\x1b["; |
| 38 | 40 | const dim = (s: string): string => `${esc}2m${s}${esc}0m`; |
x