@nanobpm/nano-workforce 0.106.0 → 0.106.1

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.
@@ -16,6 +16,11 @@ jobs:
16
16
  steps:
17
17
  - name: Checkout
18
18
  uses: actions/checkout@v4
19
+ with:
20
+ # Full history + tags: the migration immutability gate (check:migrations) diffs against the
21
+ # merge-base with origin/main, and the upgrade smoke test materialises the previous release
22
+ # tag's migration set — both need history a shallow clone doesn't have (issue #357).
23
+ fetch-depth: 0
19
24
 
20
25
  - name: Setup Node.js
21
26
  uses: actions/setup-node@v4
package/AGENTS.md CHANGED
@@ -273,6 +273,36 @@ Migrations live in `db/migrations/*.sql` and are **auto-applied on boot** from
273
273
  prefix while `main` keeps advancing, so the branch-local "next" number collides
274
274
  on merge. Two files must never share a prefix; `npm run check:migrations`
275
275
  (a CI gate) enforces this and fails the build on any new duplicate.
276
+ - **A merged migration is IMMUTABLE — never rename, delete, or edit it.** The
277
+ runtime keys the `_urban_migrations` ledger by *filename*, so a renamed file is
278
+ a *new* migration to the runner: it re-runs its DDL against an already-migrated
279
+ DB and aborts boot (`duplicate column …`); a deleted file desyncs the ledger
280
+ from the schema; an edited file silently no-ops on every existing install (the
281
+ name is already recorded) while diverging fresh ones. To change a merged
282
+ migration's effect, add a NEW migration. `npm run check:migrations` also gates
283
+ this — it diffs `db/migrations/` against the merge-base with `origin/main` and
284
+ fails on any rename/delete/edit — and the upgrade smoke test
285
+ (`app/migration-upgrade-smoke.test.ts`) materialises the previous release's
286
+ migration set and upgrades it to the current set, catching non-idempotent DDL a
287
+ fresh-DB CI never exercises (issue #357). Both need history: CI checks out with
288
+ `fetch-depth: 0`.
289
+
290
+ ### Healing an install wedged by a renamed migration
291
+
292
+ If a live node fails to boot with `migration "NNN_…sql" failed and was rolled
293
+ back … duplicate column` because a migration was renamed *before* the immutability
294
+ gate existed (e.g. `043_user_tasks_subject_title.sql` → `046_…`, issue #357),
295
+ reconcile its ledger — this makes **no schema change**, only aliases the old
296
+ ledger row to the new filename, and is safe to re-run:
297
+
298
+ ```bash
299
+ npm run heal:migrations -- /path/to/the/app.sqlite # then restart the node
300
+ ```
301
+
302
+ The known renames live in `RENAMED_MIGRATIONS` (`app/migrationHeal.ts`), the
303
+ single source of truth the heal script and its tests share. This list only heals
304
+ the pre-gate past — the immutability gate above prevents any new entry from ever
305
+ being needed.
276
306
 
277
307
  ## Runtime & CI gates
278
308
 
@@ -286,7 +316,7 @@ npm run typecheck # tsc --noEmit (Node)
286
316
  npm run check # urban check (manifest validation)
287
317
  npm run layout:check # BPMN diagram freshness (no drift)
288
318
  npm run check:prompts # agent-prompt linkedResource resolution
289
- npm run check:migrations # migration prefixes (no collisions)
319
+ npm run check:migrations # migration prefixes + immutability (no rename/delete/edit of a merged migration)
290
320
  npm run check:contracts # contract registry (no synonyms / undeclared env keys)
291
321
  npm test # unit tests (node --test)
292
322
  ```
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.106.1](https://github.com/nanobpm/nano-workforce/compare/v0.106.0...v0.106.1) (2026-08-20)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **migrations:** gate migration immutability + add upgrade smoke test ([#357](https://github.com/nanobpm/nano-workforce/issues/357)) ([#359](https://github.com/nanobpm/nano-workforce/issues/359)) ([7f7c6a6](https://github.com/nanobpm/nano-workforce/commit/7f7c6a6c59dac33602441c729dca897b3b23fabc)), closes [#311](https://github.com/nanobpm/nano-workforce/issues/311) [#316](https://github.com/nanobpm/nano-workforce/issues/316) [#351](https://github.com/nanobpm/nano-workforce/issues/351) [#355](https://github.com/nanobpm/nano-workforce/issues/355)
7
+
1
8
  # [0.106.0](https://github.com/nanobpm/nano-workforce/compare/v0.105.0...v0.106.0) (2026-08-20)
2
9
 
3
10
 
@@ -0,0 +1,55 @@
1
+ // Upgrade smoke test (issue #357) — CI only ever migrates a FRESH database, so it never exercises the
2
+ // one state where the forward-only contract actually breaks: a *pre-existing* install upgrading to
3
+ // the current set. This materialises the migration set at the previous release tag, applies the
4
+ // current set on top the way the runtime does (ledger keyed by filename, skip-applied / apply-new),
5
+ // and asserts the upgrade completes cleanly.
6
+ //
7
+ // It covers the whole failure class on a real release boundary — a renamed/deleted migration
8
+ // re-running, non-idempotent DDL, or an ordering assumption — rather than any single instance. When
9
+ // the tag is unavailable (a shallow clone without tags) it skips with a diagnostic; CI checks out
10
+ // full history + tags (`fetch-depth: 0`), so the real upgrade path is always exercised there.
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import test from "node:test";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import {
15
+ applyMigrationSet,
16
+ previousReleaseTag,
17
+ readMigrationSetFromDisk,
18
+ readMigrationSetFromGit,
19
+ } from "#test-migrations";
20
+
21
+ test("upgrading a DB from the previous release's migrations to the current set applies cleanly", (t) => {
22
+ const tag = previousReleaseTag();
23
+ if (tag === null) {
24
+ t.skip("no previous release tag reachable (shallow clone without tags) — CI runs this with fetch-depth: 0");
25
+ return;
26
+ }
27
+ const baseline = readMigrationSetFromGit(tag);
28
+ if (baseline === null) {
29
+ t.skip(`migration set at ${tag} unavailable — CI runs this with fetch-depth: 0`);
30
+ return;
31
+ }
32
+
33
+ const db = new DatabaseSync(":memory:");
34
+ // Stand up the DB exactly as the previous release left it...
35
+ applyMigrationSet(db, baseline);
36
+ const ledgerBefore = new Set(
37
+ (db.prepare("SELECT name FROM _urban_migrations").all() as { name: string }[]).map((r) => r.name),
38
+ );
39
+
40
+ // ...then upgrade in place to the current set. A rename/delete/non-idempotent migration throws here.
41
+ const current = readMigrationSetFromDisk();
42
+ applyMigrationSet(db, current);
43
+
44
+ // Every current migration is now recorded, and nothing the baseline applied went missing.
45
+ const ledgerAfter = new Set(
46
+ (db.prepare("SELECT name FROM _urban_migrations").all() as { name: string }[]).map((r) => r.name),
47
+ );
48
+ for (const file of current) {
49
+ assert(ledgerAfter.has(file.name), `current migration ${file.name} is recorded as applied after upgrade`);
50
+ }
51
+ for (const name of ledgerBefore) {
52
+ assert(ledgerAfter.has(name), `baseline migration ${name} remains in the ledger after upgrade`);
53
+ }
54
+ assertEquals(ledgerAfter.size, current.length, "the upgraded ledger holds exactly the current set");
55
+ });
@@ -0,0 +1,75 @@
1
+ // Regression guard for issue #357 — a migration renamed after it merged (`043_user_tasks_subject_
2
+ // title.sql` -> `046_…` in #316) re-runs against an already-migrated DB and aborts boot with
3
+ // "duplicate column name: subject_title", because the ledger is keyed by filename.
4
+ //
5
+ // This reproduces that exact break on the CURRENT migration set (RED), then proves
6
+ // `healMigrationLedger` reconciles the ledger so the upgrade completes cleanly (GREEN) — the recovery
7
+ // path for installs that migrated under the old name before the rename landed.
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import test from "node:test";
10
+ import { healMigrationLedger, RENAMED_MIGRATIONS } from "../app/migrationHeal.ts";
11
+ import { assert, assertEquals, assertThrows } from "#test-assert";
12
+ import { applyMigrationSet, readMigrationSetFromDisk } from "#test-migrations";
13
+
14
+ const NEW_NAME = "046_user_tasks_subject_title.sql";
15
+ const OLD_NAME = "043_user_tasks_subject_title.sql";
16
+
17
+ // A DB migrated fully with the CURRENT set, then rewound to model an install that applied the
18
+ // renamed migration under its OLD filename: the schema change is present, but the ledger records the
19
+ // old name — exactly the state a boot between #311 and #316 left behind.
20
+ function dbAppliedUnderOldName(): DatabaseSync {
21
+ const db = new DatabaseSync(":memory:");
22
+ applyMigrationSet(db, readMigrationSetFromDisk());
23
+ db.prepare("UPDATE _urban_migrations SET name=? WHERE name=?").run(OLD_NAME, NEW_NAME);
24
+ return db;
25
+ }
26
+
27
+ const applied = (db: DatabaseSync, name: string): boolean =>
28
+ (db.prepare("SELECT 1 AS one FROM _urban_migrations WHERE name=?").get(name) as
29
+ | { one: number }
30
+ | undefined) !== undefined;
31
+
32
+ test("RENAMED_MIGRATIONS pins the 043->046 user_tasks_subject_title rename", () => {
33
+ assertEquals(RENAMED_MIGRATIONS.get(NEW_NAME), OLD_NAME);
34
+ });
35
+
36
+ test("#357 repro: re-applying the current set to a DB migrated under the old name aborts on duplicate column", () => {
37
+ const db = dbAppliedUnderOldName();
38
+ // The renamed migration is unapplied under its NEW name, so the runtime re-runs its
39
+ // `ALTER TABLE user_tasks ADD COLUMN subject_title` against a table that already has the column.
40
+ const err = assertThrows(() => applyMigrationSet(db, readMigrationSetFromDisk()));
41
+ assert(
42
+ /duplicate column name: subject_title/.test(err.message),
43
+ `expected a duplicate-column abort, got: ${err.message}`,
44
+ );
45
+ });
46
+
47
+ test("heal reconciles the ledger so the upgrade completes cleanly", () => {
48
+ const db = dbAppliedUnderOldName();
49
+
50
+ const healed = healMigrationLedger(db);
51
+ assertEquals(healed, [NEW_NAME], "the new filename was aliased into the ledger");
52
+ assert(applied(db, NEW_NAME), "046 is now recorded as applied");
53
+ assert(applied(db, OLD_NAME), "the historical 043 ledger row is left intact");
54
+
55
+ // With the ledger reconciled, the renamed migration is skipped and the boot-time upgrade is clean.
56
+ const newly = applyMigrationSet(db, readMigrationSetFromDisk());
57
+ assertEquals(newly, [], "no migration re-runs after the heal");
58
+ });
59
+
60
+ test("heal is idempotent and a no-op on a healthy DB", () => {
61
+ // A DB that applied the migration under its CURRENT name needs no healing.
62
+ const clean = new DatabaseSync(":memory:");
63
+ applyMigrationSet(clean, readMigrationSetFromDisk());
64
+ assertEquals(healMigrationLedger(clean), [], "clean install: nothing to heal");
65
+
66
+ // Healing twice changes nothing the second time.
67
+ const broken = dbAppliedUnderOldName();
68
+ assertEquals(healMigrationLedger(broken), [NEW_NAME]);
69
+ assertEquals(healMigrationLedger(broken), [], "second heal is a no-op");
70
+ });
71
+
72
+ test("heal is a no-op on a fresh DB with no migration ledger", () => {
73
+ const fresh = new DatabaseSync(":memory:");
74
+ assertEquals(healMigrationLedger(fresh), [], "no ledger table => nothing to heal");
75
+ });
@@ -0,0 +1,74 @@
1
+ // migrationHeal — recover installs broken by a migration that was RENAMED after it had already
2
+ // merged and applied (issue #357).
3
+ //
4
+ // The runtime (`@nanobpm/urban` applyMigrations) keys the `_urban_migrations` ledger by FILENAME, so
5
+ // a renamed migration is a *different* migration to the runner: it re-runs its DDL against a schema
6
+ // that already has the change, and a bare `ALTER TABLE ... ADD COLUMN` then aborts boot with
7
+ // "duplicate column". The direct hazard is now blocked forward by the immutability gate
8
+ // (`scripts/check-migrations.ts`), but installs that migrated under the OLD name *before* the rename
9
+ // landed are already stuck and need their ledger reconciled.
10
+ //
11
+ // The fix is a ledger-alias, NOT a schema change and NOT an edit to the renamed migration (editing a
12
+ // merged migration is itself forbidden by the immutability gate, and would silently no-op on every
13
+ // already-migrated DB anyway): if the old filename is recorded as applied, the new filename names the
14
+ // exact same forward-only change, so it is safe — and correct — to record the new filename as applied
15
+ // too. `healMigrationLedger` does that, guarded so it only ever fires on the broken state.
16
+ //
17
+ // `RENAMED_MIGRATIONS` is the single source of truth for known historical renames. It exists ONLY to
18
+ // heal the pre-gate past — the immutability gate prevents any new entry from ever being needed.
19
+ import type { DatabaseSync } from "node:sqlite";
20
+
21
+ const MIGRATIONS_TABLE = "_urban_migrations";
22
+
23
+ // new filename -> old filename, for merged migrations that were renamed before the immutability gate
24
+ // existed. Each entry names the SAME forward-only change under two prefixes, so aliasing the ledger
25
+ // from the old name to the new is lossless.
26
+ //
27
+ // - `046_user_tasks_subject_title.sql` was merged as `043_user_tasks_subject_title.sql` (#311) and
28
+ // renumbered to 046 (#316) to break a prefix collision with `043_pr_epic_phase.sql`. Any DB that
29
+ // booted between those two PRs applied it as 043 and now re-runs it as 046 → "duplicate column
30
+ // name: subject_title" (issue #357).
31
+ export const RENAMED_MIGRATIONS: ReadonlyMap<string, string> = new Map([
32
+ ["046_user_tasks_subject_title.sql", "043_user_tasks_subject_title.sql"],
33
+ ]);
34
+
35
+ /** Does the `_urban_migrations` ledger table exist? A fresh/never-migrated DB has nothing to heal. */
36
+ function ledgerExists(db: DatabaseSync): boolean {
37
+ return (
38
+ db
39
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
40
+ .get(MIGRATIONS_TABLE) !== undefined
41
+ );
42
+ }
43
+
44
+ function isApplied(db: DatabaseSync, name: string): boolean {
45
+ return db.prepare(`SELECT 1 AS one FROM ${MIGRATIONS_TABLE} WHERE name=?`).get(name) !== undefined;
46
+ }
47
+
48
+ /**
49
+ * Reconcile the migration ledger for known historical renames, in place. For every rename where the
50
+ * OLD filename is recorded as applied but the NEW one is not, record the new filename as applied
51
+ * (aliasing the ledger) so the runtime stops re-running the renamed migration.
52
+ *
53
+ * Idempotent and safe on every state:
54
+ * - old applied, new missing -> alias inserted (the broken install; the only case that acts)
55
+ * - both applied -> no-op (overlay-drift install carrying both files)
56
+ * - only new applied -> no-op (clean install that never saw the old name)
57
+ * - neither applied / no ledger-> no-op (fresh DB)
58
+ *
59
+ * @returns the new filenames that were aliased in (empty when nothing needed healing).
60
+ */
61
+ export function healMigrationLedger(db: DatabaseSync, now: () => Date = () => new Date()): string[] {
62
+ if (!ledgerExists(db)) return [];
63
+ const healed: string[] = [];
64
+ for (const [newName, oldName] of RENAMED_MIGRATIONS) {
65
+ if (isApplied(db, oldName) && !isApplied(db, newName)) {
66
+ db.prepare(`INSERT OR IGNORE INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`).run(
67
+ newName,
68
+ now().toISOString(),
69
+ );
70
+ healed.push(newName);
71
+ }
72
+ }
73
+ return healed;
74
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.106.0",
3
+ "version": "0.106.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
7
7
  "imports": {
8
- "#test-assert": "./test/assert.ts"
8
+ "#test-assert": "./test/assert.ts",
9
+ "#test-migrations": "./test/migrations.ts"
9
10
  },
10
11
  "engines": {
11
12
  "node": ">=22.6"
@@ -37,6 +38,7 @@
37
38
  "pretypecheck": "urban gen",
38
39
  "check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
39
40
  "check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
41
+ "heal:migrations": "node --experimental-strip-types scripts/heal-migration-ledger.ts",
40
42
  "check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
41
43
  "reconcile:contracts": "node --experimental-strip-types scripts/reconcile-contracts.ts",
42
44
  "gen": "urban gen",
@@ -0,0 +1,58 @@
1
+ // Red/green coverage for the migration immutability gate (scripts/check-migrations.ts, issue #357).
2
+ //
3
+ // The runtime keys the migration ledger by FILENAME, so once a migration merges it must never be
4
+ // renamed (re-runs against a migrated DB and aborts boot), deleted (desyncs ledger from schema), or
5
+ // edited (silently no-ops on migrated installs). The gate diffs db/migrations against the merge-base
6
+ // with origin/main; here we drive its diff classifier directly with representative
7
+ // `git diff --find-renames --name-status` output so each violation shape is pinned.
8
+ import test from "node:test";
9
+ import { immutabilityErrorsFromDiff } from "./check-migrations.ts";
10
+ import { assert, assertEquals } from "#test-assert";
11
+
12
+ test("a rename of a merged migration is a violation", () => {
13
+ const errors = immutabilityErrorsFromDiff(
14
+ "R100\tdb/migrations/043_user_tasks_subject_title.sql\tdb/migrations/046_user_tasks_subject_title.sql",
15
+ );
16
+ assertEquals(errors.length, 1);
17
+ assert(/RENAMED/.test(errors[0]));
18
+ assert(/043_user_tasks_subject_title.sql -> 046_user_tasks_subject_title.sql/.test(errors[0]));
19
+ });
20
+
21
+ test("a delete of a merged migration is a violation", () => {
22
+ const errors = immutabilityErrorsFromDiff("D\tdb/migrations/046_user_tasks_subject_title.sql");
23
+ assertEquals(errors.length, 1);
24
+ assert(/DELETED/.test(errors[0]));
25
+ });
26
+
27
+ test("an edit of a merged migration is a violation", () => {
28
+ const errors = immutabilityErrorsFromDiff("M\tdb/migrations/046_user_tasks_subject_title.sql");
29
+ assertEquals(errors.length, 1);
30
+ assert(/EDITED/.test(errors[0]));
31
+ });
32
+
33
+ test("adding a new migration is allowed", () => {
34
+ assertEquals(
35
+ immutabilityErrorsFromDiff("A\tdb/migrations/061_brand_new.sql"),
36
+ [],
37
+ "an addition is not a violation",
38
+ );
39
+ });
40
+
41
+ test("a clean diff (no migration changes) yields no violations", () => {
42
+ assertEquals(immutabilityErrorsFromDiff(""), []);
43
+ });
44
+
45
+ test("mixed changes report every violation but ignore the addition", () => {
46
+ const errors = immutabilityErrorsFromDiff(
47
+ [
48
+ "A\tdb/migrations/061_new.sql",
49
+ "M\tdb/migrations/010_old.sql",
50
+ "D\tdb/migrations/011_gone.sql",
51
+ "R096\tdb/migrations/012_a.sql\tdb/migrations/013_a.sql",
52
+ ].join("\n"),
53
+ );
54
+ assertEquals(errors.length, 3, "the three mutations are flagged, the addition is not");
55
+ assert(errors.some((e) => /EDITED/.test(e)));
56
+ assert(errors.some((e) => /DELETED/.test(e)));
57
+ assert(errors.some((e) => /RENAMED/.test(e)));
58
+ });
@@ -13,11 +13,23 @@
13
13
  // The rule: no two migration files may share a numeric prefix. The pre-existing historical
14
14
  // duplicates are forward-only and already applied, so they cannot be renamed — they are
15
15
  // grandfathered in GRANDFATHERED_DUPES. Any NEW duplicate prefix fails the build.
16
+ //
17
+ // A second, independent invariant lives here too (issue #357): once a migration has merged to
18
+ // `main` it is IMMUTABLE — never renamed, deleted, or edited. The runtime keys the migration ledger
19
+ // by FILENAME, so renaming a merged migration makes the runner re-apply it against an already-
20
+ // migrated DB and abort boot ("duplicate column"); editing one silently no-ops on every existing
21
+ // install (the ledger already has that name) while diverging fresh installs. `checkImmutability`
22
+ // diffs `db/migrations/` against the merge-base with `origin/main` and fails on any rename, delete,
23
+ // or content change to a file that existed there. It compares against the merge-base (the branch's
24
+ // fork point), NOT `origin/main`'s tip, so a branch that is merely behind main isn't wrongly flagged
25
+ // for migrations added to main after it forked.
26
+ import { execFileSync } from "node:child_process";
16
27
  import { readdirSync } from "node:fs";
17
28
  import { dirname, join } from "node:path";
18
29
  import { fileURLToPath } from "node:url";
19
30
 
20
- const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db", "migrations");
31
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
32
+ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
21
33
 
22
34
  // Historical collisions that predate this gate. Forward-only + already applied ⇒ cannot be
23
35
  // renumbered. New duplicates are NOT allowed here — fix them before merge.
@@ -32,10 +44,112 @@ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db",
32
44
  // cannot be renumbered (a rename re-runs `CREATE TABLE`/`ALTER TABLE DROP COLUMN` on migrated DBs and
33
45
  // fails). They create three disjoint schema objects, so their relative apply order is irrelevant.
34
46
  // Grandfather 049; any NEW duplicate prefix still fails the build.
35
- const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049"]);
47
+ //
48
+ // 052 is the same story across two PRs: #351 landed `052_worker_durable_resume` and #355 landed
49
+ // `052_plan_conformance`, each the branch-local "next" prefix, colliding silently only once both were
50
+ // on main. Both are already applied forward-only, and — reinforced now by the immutability check
51
+ // below — renumbering a merged migration is itself forbidden (the rename would re-run it and abort
52
+ // boot, issue #357). The two create disjoint tables (`worker_durable_resume`, `plan_conformance`), so
53
+ // apply order is irrelevant. Grandfather 052; any NEW duplicate prefix still fails the build.
54
+ const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007", "049", "052"]);
36
55
 
37
56
  const PREFIX = /^(\d{3})_[^/]*\.sql$/;
38
57
 
58
+ function git(args: string[]): string {
59
+ return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
60
+ }
61
+
62
+ /** The commit to treat as "already merged" — the branch's fork point from `origin/main` (fall back to
63
+ * local `main`). `null` when no baseline is resolvable (e.g. a shallow clone with no `main`), in
64
+ * which case the immutability check is skipped with a warning; CI checks out full history. Override
65
+ * with `MIGRATION_BASELINE_REF` (used by the gate's own tests). */
66
+ function resolveBaseline(): string | null {
67
+ const override = process.env.MIGRATION_BASELINE_REF;
68
+ const upstreams = override ? [override] : ["origin/main", "main"];
69
+ for (const ref of upstreams) {
70
+ try {
71
+ // An explicit override is used as-is; a branch name is resolved to its merge-base with HEAD so a
72
+ // branch that is merely behind main isn't blamed for migrations main gained after it forked.
73
+ if (override) {
74
+ git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
75
+ return ref;
76
+ }
77
+ return git(["merge-base", ref, "HEAD"]).trim();
78
+ } catch {
79
+ // try the next candidate
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** Classify a `git diff --find-renames --name-status <baseline> -- db/migrations` listing into
86
+ * immutability violations. Renames (`R`), deletes (`D`), and content edits (`M`) of a merged
87
+ * migration are violations; additions (`A`) are allowed. Exported for unit coverage. */
88
+ export function immutabilityErrorsFromDiff(statusOutput: string): string[] {
89
+ const errors: string[] = [];
90
+ const name = (p: string): string => p.slice(p.lastIndexOf("/") + 1);
91
+ for (const line of statusOutput.split("\n")) {
92
+ if (line.trim() === "") continue;
93
+ const [status, ...paths] = line.split("\t");
94
+ if (status.startsWith("R")) {
95
+ errors.push(
96
+ ` ${name(paths[0])} -> ${name(paths[1])}: a merged migration was RENAMED. The ledger keys ` +
97
+ `by filename, so the renamed file re-runs on every existing install and aborts boot. Keep ` +
98
+ `the original filename; add a NEW migration for further change.`,
99
+ );
100
+ } else if (status.startsWith("D")) {
101
+ errors.push(
102
+ ` ${name(paths[0])}: a merged migration was DELETED. Forward-only migrations are immutable — ` +
103
+ `removing one desyncs the ledger from the schema. Leave it in place.`,
104
+ );
105
+ } else if (status.startsWith("M")) {
106
+ errors.push(
107
+ ` ${name(paths[0])}: a merged migration was EDITED. The ledger already records it, so the ` +
108
+ `edit silently no-ops on every migrated install while diverging fresh ones. Add a NEW ` +
109
+ `migration instead of changing a merged one.`,
110
+ );
111
+ }
112
+ }
113
+ return errors;
114
+ }
115
+
116
+ /** Fail on any rename, delete, or content change to a migration already present at the baseline — a
117
+ * merged migration is immutable (issue #357). Additions are fine. Returns whether the immutability
118
+ * gate actually ran (false when it was skipped because no baseline/diff was available), so the
119
+ * caller's success message doesn't claim a guarantee the gate never checked. */
120
+ function checkImmutability(errors: string[]): boolean {
121
+ const baseline = resolveBaseline();
122
+ if (baseline === null) {
123
+ console.warn(
124
+ "check-migrations: WARN — no origin/main baseline resolvable; skipping the immutability check " +
125
+ "(CI runs it with full history via fetch-depth: 0).",
126
+ );
127
+ return false;
128
+ }
129
+
130
+ let statusOutput: string;
131
+ try {
132
+ // Diff the baseline tree against the WORKING TREE (staged + unstaged), detecting renames, scoped
133
+ // to db/migrations. `--find-renames` surfaces a rename as one `R` row instead of a delete+add.
134
+ statusOutput = git([
135
+ "diff",
136
+ "--find-renames",
137
+ "--name-status",
138
+ baseline,
139
+ "--",
140
+ "db/migrations",
141
+ ]);
142
+ } catch {
143
+ console.warn(
144
+ `check-migrations: WARN — could not diff migrations against ${baseline}; skipping the immutability check.`,
145
+ );
146
+ return false;
147
+ }
148
+
149
+ errors.push(...immutabilityErrorsFromDiff(statusOutput));
150
+ return true;
151
+ }
152
+
39
153
  function main(): void {
40
154
  const files = readdirSync(MIGRATIONS_DIR)
41
155
  .filter((f) => f.endsWith(".sql"))
@@ -68,12 +182,20 @@ function main(): void {
68
182
  }
69
183
  }
70
184
 
185
+ const immutabilityChecked = checkImmutability(errors);
186
+
71
187
  if (errors.length > 0) {
72
- console.error(`check-migrations: db/migrations has colliding prefixes:\n${errors.join("\n")}`);
188
+ console.error(`check-migrations: db/migrations failed its merge-safety checks:\n${errors.join("\n")}`);
73
189
  process.exit(1);
74
190
  }
75
191
 
76
- console.log(`check-migrations: OK (${files.length} migrations, no colliding prefixes).`);
192
+ const immutabilityClause = immutabilityChecked
193
+ ? "none renamed/deleted/edited"
194
+ : "immutability check skipped (no baseline)";
195
+ console.log(
196
+ `check-migrations: OK (${files.length} migrations, no colliding prefixes, ${immutabilityClause}).`,
197
+ );
77
198
  }
78
199
 
79
- main();
200
+ if (import.meta.main) main();
201
+
@@ -0,0 +1,49 @@
1
+ // heal-migration-ledger — one-shot recovery for an install wedged by issue #357 (a migration that
2
+ // was renamed after it merged, so the runtime re-runs it and boot aborts on "duplicate column").
3
+ //
4
+ // Usage:
5
+ // node --experimental-strip-types scripts/heal-migration-ledger.ts <path-to-sqlite.db>
6
+ //
7
+ // It reconciles the `_urban_migrations` ledger for every known historical rename
8
+ // (`RENAMED_MIGRATIONS`): where the OLD filename is recorded as applied but the NEW one is not, it
9
+ // records the new filename as applied. This is a pure ledger-alias — it makes NO schema change — and
10
+ // is safe to run repeatedly and on a healthy DB (it only acts on the broken state). After running it,
11
+ // restart the node; the renamed migration is skipped and boot completes.
12
+ import { existsSync } from "node:fs";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { healMigrationLedger } from "../app/migrationHeal.ts";
15
+
16
+ function main(): void {
17
+ const dbPath = process.argv[2];
18
+ if (!dbPath) {
19
+ console.error(
20
+ "usage: node --experimental-strip-types scripts/heal-migration-ledger.ts <path-to-sqlite.db>",
21
+ );
22
+ process.exit(2);
23
+ }
24
+
25
+ // `DatabaseSync` silently CREATES an empty DB when the path doesn't exist, so a typo'd path would
26
+ // "heal" a brand-new empty file and misleadingly report "nothing to heal". Fail loudly instead.
27
+ if (!existsSync(dbPath)) {
28
+ console.error(
29
+ `heal-migration-ledger: ${dbPath} — no such file. Pass the path to the existing install's SQLite DB.`,
30
+ );
31
+ process.exit(2);
32
+ }
33
+
34
+ const db = new DatabaseSync(dbPath);
35
+ try {
36
+ const healed = healMigrationLedger(db);
37
+ if (healed.length === 0) {
38
+ console.log(`heal-migration-ledger: ${dbPath} — nothing to heal (ledger already consistent).`);
39
+ } else {
40
+ console.log(
41
+ `heal-migration-ledger: ${dbPath} — aliased ${healed.length} migration(s) as applied: ${healed.join(", ")}. Restart the node to complete boot.`,
42
+ );
43
+ }
44
+ } finally {
45
+ db.close();
46
+ }
47
+ }
48
+
49
+ if (import.meta.main) main();
@@ -0,0 +1,121 @@
1
+ // Test helpers for exercising the forward-only SQLite migration set the way the runtime does.
2
+ //
3
+ // `applyMigrationSet` mirrors `@nanobpm/urban`'s `applyMigrations` (ledger keyed by FILENAME, each
4
+ // migration wrapped in its own transaction, skip-applied / apply-new) against a `node:sqlite`
5
+ // `DatabaseSync`, so upgrade tests reproduce the exact boot-time behaviour — including the
6
+ // "duplicate column" abort a renamed migration causes — without booting the whole app.
7
+ import { execFileSync } from "node:child_process";
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import type { DatabaseSync } from "node:sqlite";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
14
+ const MIGRATIONS_DIR = join(REPO_ROOT, "db", "migrations");
15
+ const MIGRATIONS_TABLE = "_urban_migrations";
16
+
17
+ export interface MigrationFile {
18
+ name: string;
19
+ sql: string;
20
+ }
21
+
22
+ function byName(files: MigrationFile[]): MigrationFile[] {
23
+ // Match the runtime's plain lexical `.sort()` on filename (apply order).
24
+ return [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
25
+ }
26
+
27
+ /**
28
+ * Apply `files` to `db` exactly as the runtime does: create the ledger, skip any filename already
29
+ * recorded, and for each remaining file run its SQL + record the filename atomically in one
30
+ * transaction. Throws the same rolled-back error the runtime raises when a migration's SQL fails.
31
+ * @returns the filenames newly applied.
32
+ */
33
+ export function applyMigrationSet(
34
+ db: DatabaseSync,
35
+ files: MigrationFile[],
36
+ now: () => Date = () => new Date(),
37
+ ): string[] {
38
+ db.exec(
39
+ `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`,
40
+ );
41
+ const applied = new Set(
42
+ (db.prepare(`SELECT name FROM ${MIGRATIONS_TABLE}`).all() as { name: string }[]).map(
43
+ (r) => r.name,
44
+ ),
45
+ );
46
+ const newlyApplied: string[] = [];
47
+ for (const file of byName(files)) {
48
+ if (applied.has(file.name)) continue;
49
+ db.exec("BEGIN");
50
+ try {
51
+ db.exec(file.sql);
52
+ db.prepare(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES (?, ?)`).run(
53
+ file.name,
54
+ now().toISOString(),
55
+ );
56
+ db.exec("COMMIT");
57
+ } catch (err) {
58
+ db.exec("ROLLBACK");
59
+ throw new Error(
60
+ `migration "${file.name}" failed and was rolled back (no partial schema change, not recorded as applied): ${err instanceof Error ? err.message : String(err)}`,
61
+ { cause: err },
62
+ );
63
+ }
64
+ newlyApplied.push(file.name);
65
+ }
66
+ return newlyApplied;
67
+ }
68
+
69
+ /** The current worktree's migration set, read from `db/migrations/`. */
70
+ export function readMigrationSetFromDisk(): MigrationFile[] {
71
+ return readdirSync(MIGRATIONS_DIR)
72
+ .filter((f) => f.endsWith(".sql"))
73
+ .map((name) => ({ name, sql: readFileSync(join(MIGRATIONS_DIR, name), "utf8") }));
74
+ }
75
+
76
+ function git(args: string[]): string {
77
+ return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
78
+ }
79
+
80
+ /**
81
+ * The migration set as it existed at a git ref (tag/branch/sha), read straight from the object
82
+ * store so it works from any worktree. Returns `null` if the ref (or git) is unavailable — e.g. a
83
+ * shallow clone without tags — so callers can skip rather than fail spuriously (CI fetches full
84
+ * history + tags, so it exercises the real path).
85
+ */
86
+ export function readMigrationSetFromGit(ref: string): MigrationFile[] | null {
87
+ let listing: string;
88
+ try {
89
+ listing = git(["ls-tree", "-r", "--name-only", ref, "--", "db/migrations"]);
90
+ } catch {
91
+ return null;
92
+ }
93
+ const paths = listing
94
+ .split("\n")
95
+ .map((l) => l.trim())
96
+ .filter((l) => l.endsWith(".sql"));
97
+ if (paths.length === 0) return null;
98
+ const files: MigrationFile[] = [];
99
+ for (const path of paths) {
100
+ const name = path.slice(path.lastIndexOf("/") + 1);
101
+ files.push({ name, sql: git(["show", `${ref}:${path}`]) });
102
+ }
103
+ return files;
104
+ }
105
+
106
+ /**
107
+ * The most recent release tag (`vN.N.N`) reachable from HEAD's parent — i.e. the release we would be
108
+ * upgrading a live install FROM. Falls back to the tag on HEAD itself, then `null` if none is
109
+ * reachable (shallow clone without tags).
110
+ */
111
+ export function previousReleaseTag(): string | null {
112
+ for (const from of ["HEAD^", "HEAD"]) {
113
+ try {
114
+ const ref = git(["describe", "--tags", "--abbrev=0", "--match", "v*", from]).trim();
115
+ if (ref) return ref;
116
+ } catch {
117
+ // try the next base
118
+ }
119
+ }
120
+ return null;
121
+ }