@nanobpm/nano-workforce 0.106.0 → 0.106.2

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,17 @@
1
+ ## [0.106.2](https://github.com/nanobpm/nano-workforce/compare/v0.106.1...v0.106.2) (2026-08-20)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **velocity:** bucket burn-up by local timezone, not UTC ([#361](https://github.com/nanobpm/nano-workforce/issues/361)) ([#362](https://github.com/nanobpm/nano-workforce/issues/362)) ([84d842a](https://github.com/nanobpm/nano-workforce/commit/84d842ad443fba8a0c399bdd5f4fca466da4a21c))
7
+
8
+ ## [0.106.1](https://github.com/nanobpm/nano-workforce/compare/v0.106.0...v0.106.1) (2026-08-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+
1
15
  # [0.106.0](https://github.com/nanobpm/nano-workforce/compare/v0.105.0...v0.106.0) (2026-08-20)
2
16
 
3
17
 
@@ -12,6 +12,13 @@ import { assert, assertEquals } from "#test-assert";
12
12
  import type { DataLayer } from "@nanobpm/urban";
13
13
  import { deriveMergesPerDay, type MergeAuditRow, pollMergesPerDay } from "./mergesPerDay.ts";
14
14
 
15
+ // The bucketing is now LOCAL-calendar-day (issue #361: use the viewer's timezone, not UTC). The
16
+ // derivation buckets in an explicit IANA `timeZone` argument (via `Intl.DateTimeFormat`), so these
17
+ // tests pass the zone directly rather than mutating the process-global `process.env.TZ` — which
18
+ // `node --test` runs concurrently across files, so an in-process `TZ` flip could leak into and
19
+ // reorder unrelated date-handling tests. The UTC-based assertions pass `"UTC"`; the
20
+ // timezone-specific ones pass the zone they exercise.
21
+
15
22
  // A tiny in-memory record gateway (all/find/insert/update/delete), mirroring the fake-app style used
16
23
  // across the app tests (see app/delivery.test.ts), enough to exercise the `pollMergesPerDay`
17
24
  // projection.
@@ -62,7 +69,7 @@ test("counts DISTINCT merged PRs per calendar day", () => {
62
69
  merged("o/r#1", "2026-01-01T09:00:00Z"),
63
70
  merged("o/r#2", "2026-01-01T18:30:00Z"),
64
71
  merged("o/r#3", "2026-01-02T10:00:00Z"),
65
- ]);
72
+ ], "UTC");
66
73
  assertEquals(days.map((d) => [d.day, d.merged]), [
67
74
  ["2026-01-01", 2],
68
75
  ["2026-01-02", 1],
@@ -74,7 +81,7 @@ test("dedupes duplicate merged rows for the same PR on the same day (COUNT DISTI
74
81
  merged("o/r#1", "2026-01-01T09:00:00Z"),
75
82
  merged("o/r#1", "2026-01-01T09:00:05Z"), // retry / already-merged short-circuit
76
83
  merged("o/r#1", "2026-01-01T23:59:00Z"),
77
- ]);
84
+ ], "UTC");
78
85
  assertEquals(days.length, 1);
79
86
  assertEquals(days[0].merged, 1);
80
87
  });
@@ -84,7 +91,7 @@ test("the same PR merged on two different days counts once per day", () => {
84
91
  const days = deriveMergesPerDay([
85
92
  merged("o/r#1", "2026-01-01T09:00:00Z"),
86
93
  merged("o/r#1", "2026-01-02T09:00:00Z"),
87
- ]);
94
+ ], "UTC");
88
95
  assertEquals(days.map((d) => [d.day, d.merged]), [
89
96
  ["2026-01-01", 1],
90
97
  ["2026-01-02", 1],
@@ -96,7 +103,7 @@ test("ignores queued and blocked attempts", () => {
96
103
  merged("o/r#1", "2026-01-01T09:00:00Z"),
97
104
  { pr_key: "o/r#2", outcome: "queued", at: "2026-01-01T09:10:00Z" },
98
105
  { pr_key: "o/r#3", outcome: "blocked", at: "2026-01-01T09:20:00Z" },
99
- ]);
106
+ ], "UTC");
100
107
  assertEquals(days.length, 1);
101
108
  assertEquals(days[0].merged, 1);
102
109
  });
@@ -107,7 +114,7 @@ test("orders days ascending and carries a running burn-up cumulative", () => {
107
114
  merged("o/r#1", "2026-01-01T10:00:00Z"),
108
115
  merged("o/r#2", "2026-01-01T11:00:00Z"),
109
116
  merged("o/r#4", "2026-01-02T10:00:00Z"),
110
- ]);
117
+ ], "UTC");
111
118
  assertEquals(days.map((d) => d.day), ["2026-01-01", "2026-01-02", "2026-01-03"]);
112
119
  assertEquals(days.map((d) => d.merged), [2, 1, 1]);
113
120
  assertEquals(days.map((d) => d.cumulative), [2, 3, 4]);
@@ -122,7 +129,7 @@ test("bar scales against the busiest day: full for the max, non-empty for a lone
122
129
  merged("o/r#4", "2026-01-01T04:00:00Z"),
123
130
  // day B: 1 merge → short but visible bar
124
131
  merged("o/r#5", "2026-01-02T01:00:00Z"),
125
- ]);
132
+ ], "UTC");
126
133
  const [a, b] = days;
127
134
  assert(a.bar.length > b.bar.length, "the busier day must draw a longer bar");
128
135
  assert(b.bar.length >= 1, "a day with any merge must draw at least one glyph");
@@ -133,6 +140,83 @@ test("empty audit yields no days", () => {
133
140
  assertEquals(deriveMergesPerDay([]), []);
134
141
  });
135
142
 
143
+ test("buckets by the viewer's LOCAL calendar day, not UTC (issue #361)", () => {
144
+ // 02:00Z on Jan 1 is still Dec 31 in a west-of-UTC zone (America/New_York, UTC-5).
145
+ {
146
+ const days = deriveMergesPerDay([merged("o/r#1", "2026-01-01T02:00:00Z")], "America/New_York");
147
+ assertEquals(
148
+ days.map((d) => d.day),
149
+ ["2025-12-31"],
150
+ );
151
+ }
152
+ // 23:00Z on Jan 1 is already Jan 2 in an east-of-UTC zone (Pacific/Kiritimati, UTC+14).
153
+ {
154
+ const days = deriveMergesPerDay([merged("o/r#1", "2026-01-01T23:00:00Z")], "Pacific/Kiritimati");
155
+ assertEquals(
156
+ days.map((d) => d.day),
157
+ ["2026-01-02"],
158
+ );
159
+ }
160
+ });
161
+
162
+ test("two merges either side of local midnight land on the same local day (issue #361)", () => {
163
+ // In UTC these are two different UTC days; in America/New_York (UTC-5) both are Jan 1 evening,
164
+ // so a local-time bucketing counts them together on 2026-01-01.
165
+ {
166
+ const days = deriveMergesPerDay(
167
+ [
168
+ merged("o/r#1", "2026-01-01T18:00:00Z"), // 13:00 local, Jan 1
169
+ merged("o/r#2", "2026-01-02T04:00:00Z"), // 23:00 local, Jan 1
170
+ ],
171
+ "America/New_York",
172
+ );
173
+ assertEquals(
174
+ days.map((d) => [d.day, d.merged]),
175
+ [["2026-01-01", 2]],
176
+ );
177
+ }
178
+ });
179
+
180
+ test("non-ISO / malformed `at` still groups deterministically without throwing", () => {
181
+ const days = deriveMergesPerDay([
182
+ { pr_key: "o/r#1", outcome: "merged", at: "not-a-timestamp" },
183
+ { pr_key: "o/r#2", outcome: "merged", at: "not-a-timestamp" },
184
+ ], "UTC");
185
+ assertEquals(days.length, 1);
186
+ assertEquals(days[0].day, "not-a-timestamp");
187
+ assertEquals(days[0].merged, 2);
188
+ });
189
+
190
+ test("ambiguous partially-formed `at` (date-only / offset-less) buckets on the trimmed string, not a runtime-dependent day", () => {
191
+ // `new Date("2026-01-01")` parses as UTC midnight while `new Date("2026-01-01T12:00:00")` parses in
192
+ // the host's local zone — bucketing either would be runtime/timezone-dependent, the exact drift this
193
+ // read model exists to avoid. Neither carries an explicit `Z`/offset, so both must fall back to the
194
+ // trimmed string and group deterministically regardless of the viewer's `timeZone`.
195
+ const rows: MergeAuditRow[] = [
196
+ { pr_key: "o/r#1", outcome: "merged", at: "2026-01-01" },
197
+ { pr_key: "o/r#2", outcome: "merged", at: "2026-01-01T12:00:00" },
198
+ ];
199
+ for (const zone of ["UTC", "America/New_York", "Pacific/Kiritimati"]) {
200
+ const days = deriveMergesPerDay(rows, zone);
201
+ assertEquals(days.map((d) => [d.day, d.merged]), [
202
+ ["2026-01-01", 1],
203
+ ["2026-01-01T12:00:00", 1],
204
+ ]);
205
+ }
206
+ });
207
+
208
+ test("an invalid IANA timeZone falls back to the host zone instead of throwing (issue #361)", () => {
209
+ // A bogus zone would make `Intl.DateTimeFormat` throw a `RangeError`; bucketing must stay
210
+ // deterministic and not wedge `deriveMergesPerDay`/`pollMergesPerDay`.
211
+ const days = deriveMergesPerDay(
212
+ [merged("o/r#1", "2026-01-01T12:00:00Z")],
213
+ "Not/AZone",
214
+ );
215
+ assertEquals(days.length, 1);
216
+ assertEquals(days[0].merged, 1);
217
+ assert(/^\d{4}-\d{2}-\d{2}$/.test(days[0].day));
218
+ });
219
+
136
220
  test("pollMergesPerDay projects the aggregate onto merges_per_day", async () => {
137
221
  const { data, stores } = memData();
138
222
  stores.merges = [
@@ -141,7 +225,7 @@ test("pollMergesPerDay projects the aggregate onto merges_per_day", async () =>
141
225
  { id: 3, pr_key: "o/r#2", outcome: "merged", at: "2026-01-02T09:00:00Z" },
142
226
  { id: 4, pr_key: "o/r#3", outcome: "queued", at: "2026-01-02T09:10:00Z" }, // ignored
143
227
  ];
144
- await pollMergesPerDay(data);
228
+ await pollMergesPerDay(data, "UTC");
145
229
  const rows = (stores.merges_per_day ?? []).slice().sort((x, y) => x.day.localeCompare(y.day));
146
230
  assertEquals(rows.map((r) => [r.day, r.merged, r.cumulative]), [
147
231
  ["2026-01-01", 1, 1],
@@ -153,10 +237,10 @@ test("pollMergesPerDay projects the aggregate onto merges_per_day", async () =>
153
237
  test("pollMergesPerDay is idempotent — a steady-state re-run writes nothing", async () => {
154
238
  const { data, stores, writes } = memData();
155
239
  stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
156
- await pollMergesPerDay(data);
240
+ await pollMergesPerDay(data, "UTC");
157
241
  const afterFirst = writes();
158
242
  assert(afterFirst > 0, "the first pass must project at least one row");
159
- await pollMergesPerDay(data);
243
+ await pollMergesPerDay(data, "UTC");
160
244
  assertEquals(writes(), afterFirst, "a steady-state re-run must not write");
161
245
  });
162
246
 
@@ -166,7 +250,7 @@ test("pollMergesPerDay prunes a day that no longer derives from the audit", asyn
166
250
  { day: "2025-12-31", merged: 3, cumulative: 3, bar: "███", updated_at: "old" },
167
251
  ];
168
252
  stores.merges = [{ id: 1, pr_key: "o/r#1", outcome: "merged", at: "2026-01-01T09:00:00Z" }];
169
- await pollMergesPerDay(data);
253
+ await pollMergesPerDay(data, "UTC");
170
254
  const days = (stores.merges_per_day ?? []).map((r: any) => r.day);
171
255
  assert(!days.includes("2025-12-31"), "a stale day must be pruned");
172
256
  assert(days.includes("2026-01-01"), "the derived day must be present");
@@ -7,10 +7,12 @@
7
7
  // set is not exhaustive), `at` = ISO timestamp. Only `outcome = 'merged'` rows feed this aggregate,
8
8
  // so merged-per-day is
9
9
  // fully DERIVABLE from that audit trail with NO new write-path bookkeeping (AGENTS.md: "Derivation
10
- // over duplication"). The canonical aggregate is the one in the issue:
10
+ // over duplication"). The `at` audit value is UTC, but the day is bucketed in the viewer's LOCAL
11
+ // timezone (issue #361) so an operator sees merges on the calendar day they happened locally, not
12
+ // shifted across a UTC midnight. The canonical aggregate is the one in the issue, in local time:
11
13
  //
12
- // SELECT date(at) AS day, COUNT(DISTINCT pr_key) AS merged
13
- // FROM merges WHERE outcome = 'merged' GROUP BY date(at);
14
+ // SELECT date(at, 'localtime') AS day, COUNT(DISTINCT pr_key) AS merged
15
+ // FROM merges WHERE outcome = 'merged' GROUP BY date(at, 'localtime');
14
16
  //
15
17
  // Two halves, mirroring the `deriveDelivery`/`pollDelivery` and `deriveLineage`/`pollLineage`
16
18
  // convention:
@@ -37,7 +39,7 @@ export interface MergeAuditRow {
37
39
 
38
40
  /** One projected calendar day of merge throughput. */
39
41
  export interface MergeDay {
40
- /** Calendar day, ISO `YYYY-MM-DD` (SQLite `date(at)`). */
42
+ /** Local calendar day, ISO `YYYY-MM-DD` (SQLite `date(at, 'localtime')` — issue #361). */
41
43
  day: string;
42
44
  /** Distinct PRs merged that day (`COUNT(DISTINCT pr_key)`). */
43
45
  merged: number;
@@ -51,12 +53,72 @@ export interface MergeDay {
51
53
  const BAR_WIDTH = 30;
52
54
  const BAR_FULL = "█";
53
55
 
54
- /** The calendar day of an ISO timestamp — the JS twin of SQLite `date(at)`. A well-formed `merges.at`
55
- * is an ISO string, so the first 10 chars are `YYYY-MM-DD`; fall back to the whole trimmed value for
56
- * any non-ISO shape so a malformed row still groups deterministically rather than throwing. */
57
- function dayOf(at: string): string {
56
+ /** The **local** calendar day of an ISO timestamp — the viewer's-timezone twin of SQLite
57
+ * `date(at, 'localtime')` (issue #361). The `merges.at` audit value is a UTC ISO string, but the
58
+ * Velocity page is read by an operator in their own timezone, so bucketing on the UTC date split a
59
+ * single local day across two rows (a late-evening merge west of UTC, or an early-morning one east
60
+ * of it, landed on the wrong day). We derive the day in the target `timeZone` via
61
+ * `Intl.DateTimeFormat` — an explicit, side-effect-free zone rather than one mutated through the
62
+ * process-global `process.env.TZ`. When `timeZone` is omitted the formatter uses the host's
63
+ * resolved zone (so a remote deployment can still pin the operator's zone via `TZ`, and a
64
+ * co-located console — the default `npm start` on `localhost` — is already the browser's zone). An
65
+ * invalid/unknown IANA `timeZone` falls back to the host-resolved zone (rather than throwing a
66
+ * `RangeError` that would wedge `deriveMergesPerDay`/`pollMergesPerDay`), so bucketing stays
67
+ * deterministic. Any value that is not an UNAMBIGUOUS ISO instant — one carrying an explicit
68
+ * timezone designator (`Z` or a `±HH:MM`/`±HHMM` offset) — falls back to the whole trimmed string so
69
+ * it still groups deterministically. This deliberately excludes partially-formed values a bare
70
+ * `new Date(s)` would still parse but *ambiguously*: a date-only `"2026-01-01"` is read as UTC
71
+ * midnight while an offset-less `"2026-01-01T12:00:00"` is read in the host's local zone — so
72
+ * bucketing them would be runtime/timezone-dependent, the very drift this read model exists to avoid.
73
+ * Production `merges.at` values are always `new Date().toISOString()` (UTC, `Z`-suffixed), so only a
74
+ * malformed audit row ever takes the fallback. */
75
+ const dayFormatters = new Map<string, Intl.DateTimeFormat>();
76
+
77
+ /** An unambiguous ISO-8601 instant: a full `YYYY-MM-DDTHH:MM[:SS[.sss]]` carrying an explicit zone
78
+ * designator (`Z`, or a `±HH:MM`/`±HHMM` offset). Only these parse to a timezone-independent instant;
79
+ * anything else (date-only, offset-less datetime, free text) buckets ambiguously, so `dayOf` treats
80
+ * it as a non-instant and groups on the trimmed string instead. */
81
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
82
+
83
+ /** A cached `en-CA` day formatter for `timeZone`, falling back to the host-resolved zone when the
84
+ * zone is invalid/unknown (an invalid IANA string makes `Intl.DateTimeFormat` throw a `RangeError`).
85
+ * Keyed so an invalid zone is only probed once. */
86
+ function dayFormatter(timeZone?: string): Intl.DateTimeFormat {
87
+ const key = timeZone ?? "";
88
+ const cached = dayFormatters.get(key);
89
+ if (cached) return cached;
90
+ const opts: Intl.DateTimeFormatOptions = {
91
+ year: "numeric",
92
+ month: "2-digit",
93
+ day: "2-digit",
94
+ };
95
+ let fmt: Intl.DateTimeFormat;
96
+ try {
97
+ fmt = new Intl.DateTimeFormat("en-CA", { ...opts, timeZone });
98
+ } catch {
99
+ fmt = new Intl.DateTimeFormat("en-CA", opts);
100
+ }
101
+ dayFormatters.set(key, fmt);
102
+ return fmt;
103
+ }
104
+
105
+ function dayOf(at: string, timeZone?: string): string {
58
106
  const s = String(at).trim();
59
- return /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : s;
107
+ // Only bucket unambiguous ISO instants (explicit `Z`/offset). A partially-formed value a bare
108
+ // `new Date(s)` would still parse — a date-only or offset-less datetime — buckets differently per
109
+ // runtime/timezone, so group it deterministically on the trimmed string instead.
110
+ if (!ISO_INSTANT.test(s)) return s;
111
+ const d = new Date(s);
112
+ if (Number.isNaN(d.getTime())) return s;
113
+ const parts = dayFormatter(timeZone).formatToParts(d);
114
+ const field = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
115
+ const year = field("year");
116
+ const month = field("month");
117
+ const day = field("day");
118
+ // Guard against a formatter that somehow omits a field — never emit a `"--"`-shaped key; fall back
119
+ // to the trimmed string so the row still groups deterministically.
120
+ if (!year || !month || !day) return s;
121
+ return `${year}-${month}-${day}`;
60
122
  }
61
123
 
62
124
  /** Render a proportional bar: `merged` glyphs scaled against the busiest day's `max`, min one glyph
@@ -69,17 +131,20 @@ function barFor(merged: number, max: number): string {
69
131
 
70
132
  /** PURE aggregate: merge audit rows → one ordered `MergeDay` per calendar day (ascending).
71
133
  *
72
- * Only `outcome === "merged"` rows count; `queued`/`blocked` attempts are ignored. Within a day a
73
- * `pr_key` is counted once (`COUNT(DISTINCT pr_key)`), so duplicate `merged` audit rows — an
74
- * `already-merged` short-circuit or a retry do not double-count. `cumulative` is the running total
75
- * across days (burn-up); `bar` is scaled against the busiest day so the chart is comparable. */
76
- export function deriveMergesPerDay(rows: readonly MergeAuditRow[]): MergeDay[] {
134
+ * Days are bucketed in `timeZone` (an IANA zone, e.g. `America/New_York`); omit it to use the host's
135
+ * resolved zone the production default, matching SQLite `date(at, 'localtime')` for the operator's
136
+ * console (issue #361). Only `outcome === "merged"` rows count; `queued`/`blocked` attempts are
137
+ * ignored. Within a day a `pr_key` is counted once (`COUNT(DISTINCT pr_key)`), so duplicate `merged`
138
+ * audit rows an `already-merged` short-circuit or a retry — do not double-count. `cumulative` is
139
+ * the running total across days (burn-up); `bar` is scaled against the busiest day so the chart is
140
+ * comparable. */
141
+ export function deriveMergesPerDay(rows: readonly MergeAuditRow[], timeZone?: string): MergeDay[] {
77
142
  // day -> set of distinct merged pr_keys that day.
78
143
  const prKeysByDay = new Map<string, Set<string>>();
79
144
  for (const r of rows) {
80
145
  if (r.outcome !== "merged") continue;
81
146
  if (r.pr_key == null || r.at == null) continue;
82
- const day = dayOf(r.at);
147
+ const day = dayOf(r.at, timeZone);
83
148
  let set = prKeysByDay.get(day);
84
149
  if (!set) {
85
150
  set = new Set<string>();
@@ -114,13 +179,14 @@ const mergesAudit = (data: DataLayer) => data.table<MergeAuditRow>("merges", "id
114
179
  * it onto the `merges_per_day` read table the Velocity page reads. Additive/derived only — never
115
180
  * touches `merges`. Upserts a day only when its projection actually changes (so a steady-state pass is
116
181
  * a no-op) and prunes any stale day row that no longer derives (defensive — days are append-only in
117
- * practice, but a purge/rewrite of the audit must not leave a phantom). */
118
- export async function pollMergesPerDay(data: DataLayer): Promise<void> {
182
+ * practice, but a purge/rewrite of the audit must not leave a phantom). Buckets in `timeZone` (an
183
+ * IANA zone) when given; the production caller omits it to use the host's resolved zone. */
184
+ export async function pollMergesPerDay(data: DataLayer, timeZone?: string): Promise<void> {
119
185
  try {
120
186
  // Only `outcome === "merged"` rows contribute to the aggregate, so filter at the read rather than
121
187
  // scanning queued/blocked rows as the audit grows (deriveMergesPerDay ignores non-merged rows too).
122
188
  const audit = await mergesAudit(data).find({ outcome: "merged" });
123
- const want = deriveMergesPerDay(audit);
189
+ const want = deriveMergesPerDay(audit, timeZone);
124
190
  const wantByDay = new Map(want.map((d) => [d.day, d]));
125
191
 
126
192
  const existing = await mergesPerDay(data).all();
@@ -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.2",
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
+ }