@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.
- package/.github/workflows/ci.yml +5 -0
- package/AGENTS.md +31 -1
- package/CHANGELOG.md +14 -0
- package/app/mergesPerDay.test.ts +94 -10
- package/app/mergesPerDay.ts +84 -18
- package/app/migration-upgrade-smoke.test.ts +55 -0
- package/app/migrationHeal.test.ts +75 -0
- package/app/migrationHeal.ts +74 -0
- package/package.json +4 -2
- package/scripts/check-migrations.test.ts +58 -0
- package/scripts/check-migrations.ts +127 -5
- package/scripts/heal-migration-ledger.ts +49 -0
- package/test/migrations.ts +121 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
|
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
|
|
package/app/mergesPerDay.test.ts
CHANGED
|
@@ -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");
|
package/app/mergesPerDay.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|