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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

hearth/quorum:hearth/29-a-migration-is-proven-against-production → main 4 commits fork 2 threads · 2 unresolved

  • Merged. hearth/29-a-migration-is-proven-against-production was merged into main as 5bd7220.

Review

Sign in to review this change.
11 files changed +404 −45
AGENTS.md +10 −3
@@ -9,8 +9,10 @@ preview at its own address, deployed from your branch as you push.
99 - Work on the branch you were given. You cannot move the default branch;
1010 a person merges your pull once it is reviewed.
1111 - 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.
1416 - `src/tty.ts` is the app's terminal. The exact command that reaches it is
1517 `FORGE_APP_SSH` at runtime; print that verbatim wherever the app tells
1618 people how to connect. What a person can do on the page they can do here,
@@ -43,7 +45,12 @@ To add a concept:
4345 - **An action** — a function in `src/actions.ts`, its rule inside it, and
4446 a test in `src/actions.test.ts` that fails without the rule.
4547 - **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.
4754 - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`.
4855 With a second page, move them under `src/pages/` and keep one `layout`.
4956 - **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.
99
1010 ```sh
1111 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
1314 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal
1415 FORGE_APP_DATA=./data bun run src/db.ts --check # schema version, integrity
1516 ```
@@ -135,6 +136,39 @@ it where it came from. `/about` shows an admin the file's size, its schema
135136 version, the last snapshot kept beside it and both download links, and shows
136137 nobody else anything.
137138
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
138172 A proposal is a title (1–120 characters), a paragraph (1–2000) and two to
139173 eight options, each labelled, each different. Every option is a line of text:
140174 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 @@
11 {
22 "app": {
33 "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",
55 "tty": "bun run src/tty.ts"
66 }
77 }
package.json +1 −1
@@ -5,6 +5,6 @@
55 "type": "module",
66 "scripts": {
77 "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"
99 }
1010 }
src/backup.test.ts +19
@@ -218,6 +218,25 @@ test("the terminal takes a snapshot and prints where it went", () => {
218218 expect(out).toContain(`schema version ${migrations.length}`);
219219 });
220220
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
221240 // The doors. The admin check is in the action, so it holds at both of them
222241 // and on the page that links them.
223242 const KEY = "a-key-only-the-forge-holds";
src/backup.ts +3 −3
@@ -29,7 +29,7 @@ import {
2929 unlinkSync,
3030 } from "node:fs";
3131 import { basename, dirname, join, resolve } from "node:path";
32 import { migrations, openDb, schemaVersion } from "./db.ts";
32 import { migrations, openOrExit, schemaVersion } from "./db.ts";
3333 import { title } from "./env.ts";
3434
3535 /** The first sixteen bytes of every SQLite database, and nothing else. */
@@ -291,7 +291,7 @@ if (import.meta.main) {
291291 const argv = process.argv.slice(2);
292292 const restoring = argv.indexOf("--restore");
293293 if (argv.includes("--snapshot")) {
294 const db = openDb();
294 const db = openOrExit();
295295 const file = snapshot(db);
296296 console.log(`snapshot ${resolve(file.path)}`);
297297 console.log(`${file.bytes} bytes, schema version ${schemaVersion(db)}`);
@@ -304,7 +304,7 @@ if (import.meta.main) {
304304 }
305305 // The database is opened once first, so a restore onto a directory
306306 // that has never run this app still lands beside a WAL-mode file.
307 const db = openDb();
307 const db = openOrExit();
308308 const dir = dirOf(db);
309309 db.close();
310310 const done = restore(file, dir);
src/db.test.ts +216 −18
@@ -3,7 +3,91 @@ import { expect, test } from "bun:test";
33 import { mkdtempSync } from "node:fs";
44 import { tmpdir } from "node:os";
55 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;
791
892 test("migrations apply once, in order, and the file checks out", () => {
993 const dir = mkdtempSync(join(tmpdir(), "app-db-"));
@@ -23,25 +107,139 @@ test("migrations apply once, in order, and the file checks out", () => {
23107 ).toBe(1);
24108 });
25109
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();
40118
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");
42181 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:");
45243 });
46244
47245 test("a database with proposals in it takes the newest migration", () => {
src/db.ts +86 −15
@@ -5,6 +5,11 @@
55 * on, and a forward-only list of migrations applied once, in a
66 * transaction, when the file is opened.
77 *
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 *
813 * bun run src/db.ts --check the schema version and an integrity check
914 */
1015
@@ -88,6 +93,15 @@ export const migrations: string[] = [
8893 )`,
8994 ];
9095
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
91105 export function openDb(dir = dataDir): Database {
92106 mkdirSync(dir, { recursive: true });
93107 const db = new Database(join(dir, "app.db"));
@@ -102,28 +116,82 @@ export function openDb(dir = dataDir): Database {
102116 return db;
103117 }
104118
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

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 ALTER TABLE proposal ADD COLUMN deadline TEXT NOT NULL as a seventh migration onto a database holding one proposal row: boot printed migration 7 failed: Cannot add a NOT NULL column with default value NULL on stderr and exited 1, one line, no stack, and bun test failed at migration 7 upgrades a seeded database from version 6 with the same sentence. That is exactly the #16 death this issue was written about, now caught before the preview. I also ran the terminal against a database recorded at version 99: same sentence, exit 1, no session at all — the terminal is the same door as the page here. bun test is 87 pass / 0 fail and bun run test exits 0 at this head.

The tests bite. I mutated the code to check: deleting the ahead-of-the-list guard and the naming try/catch fails three cases in src/db.test.ts (names itself, refused, not migrated, --check ... exits 1 on a refusal). No shipped migration SQL was edited — I diffed for it — and the broken migration lives only in the test's own list.

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, X-Content-Type-Options, Referrer-Policy confirmed on /api/health), guests still read as guests, no new dependency, no secret.

Three things to know, none of them blocking:

  1. The --check half of the new test command runs against an empty mktemp -d, so on its own it does not catch a migration that only fails against rows — I confirmed it exits 0, schema version 7 / integrity ok, on the very migration that kills boot. bun test is what catches that, and bun run test runs both, so the check as a whole is sound; it is the README sentence ("a migration that cannot apply fails the check") that reads as if the --check were doing the catching. One clause naming the seeded cases as the part that meets real rows would make the document match the machinery.
  2. The ahead-of-the-list refusal changes preview behaviour: a preview rebuilt from an older branch onto newer data used to boot and serve, and now does not boot at all. That is what #29 asked for and it is the right direction — an older build reading a newer schema is worse — but it is worth knowing at merge time, because it converts a quiet mismatch into a down preview.
  3. Small shape nit: the same six-line try/catch IIFE now appears in db.ts, server.ts and tty.ts. An openOrExit(dir = dataDir) beside exitOnOpenFailure would leave one copy of it and keep serve() throwing for embedders as you intended. Likewise server.ts opens and closes the file before serve() opens it again; harmless, and you explained the trade, but a single open would be one less thing to reason about.

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);
107135 db.exec(
108136 "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')))",
109137 );
110138 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.`,
121146 );
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);
124158 })();
125159 }
126160
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
127195 /** The version recorded in the file. Zero before a migration has ever run. */
128196 export const schemaVersion = (db: Database): number =>
129197 db
@@ -151,7 +219,10 @@ export function check(db: Database): { version: number; integrity: string } {
151219 }
152220
153221 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();
155226 const { version, integrity } = check(db);
156227 console.log(`schema version ${version}`);
157228 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", () => {
838838 expect(quiet[0]).toContain("FORGE_APP_URL");
839839 });
840840
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
841864 test("a change is on the event stream, and a late client hears what it missed", async () => {
842865 const stream = await fetch(base + "/events");
843866 expect(stream.headers.get("content-type")).toBe("text/event-stream");
src/server.ts +6 −1
@@ -6,7 +6,7 @@
66 * it serves what the forge said; a test calls `serve` with its own.
77 */
88
9 import { openDb } from "./db.ts";
9 import { openDb, openOrExit } from "./db.ts";
1010 import {
1111 dataDir,
1212 identityKey,
@@ -89,6 +89,11 @@ export function serve(options: ServeOptions) {
8989 }
9090
9191 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();
9297 const server = serve({
9398 port: Number(process.env.PORT ?? 8080),
9499 hostname: process.env.HOST,
src/tty.ts +4 −2
@@ -26,13 +26,15 @@ import {
2626 type ProposalSummary,
2727 type Remark,
2828 } from "./actions.ts";
29 import { openDb } from "./db.ts";
29 import { openOrExit } from "./db.ts";
3030 import { oneLine, plain } from "./plain.ts";
3131 import { ssh, title, where } from "./env.ts";
3232 import { describeRule, RULE_FORMS } from "./quorum.ts";
3333 import { whoAtTty } from "./who.ts";
3434
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();
3638 const me = whoAtTty();
3739 const esc = "\x1b[";
3840 const dim = (s: string): string => `${esc}2m${s}${esc}0m`;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs