@cosmicdrift/kumiko-framework 0.47.0 → 0.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db/__tests__/managed-recreate.integration.test.ts +78 -0
- package/src/db/__tests__/migrate-generator.test.ts +57 -1
- package/src/db/__tests__/rebuild-marker.test.ts +36 -8
- package/src/db/migrate-generator.ts +29 -0
- package/src/db/rebuild-marker.ts +13 -9
- package/src/engine/__tests__/feature-manifest.test.ts +45 -0
- package/src/engine/feature-manifest.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// #356: a generated migration that adds a NOT NULL ride-along column to a
|
|
2
|
+
// MANAGED projection must APPLY on a populated table — where the old additive
|
|
3
|
+
// `ALTER TABLE ADD COLUMN ... NOT NULL` dies on existing rows. The generator
|
|
4
|
+
// emits DROP+CREATE for that case; the rebuild (covered by
|
|
5
|
+
// pending-rebuilds.integration.test.ts) refills from events afterwards.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { createTestDb, type TestDb } from "../../stack";
|
|
12
|
+
import type { ColumnMeta, EntityTableMeta } from "../entity-table-meta";
|
|
13
|
+
import { diffSnapshots, renderMigrationSql, snapshotFromMetas } from "../migrate-generator";
|
|
14
|
+
import { runMigrationsFromDir } from "../migrate-runner";
|
|
15
|
+
import { asRawClient } from "../query";
|
|
16
|
+
|
|
17
|
+
const ID_COL: ColumnMeta = { name: "id", pgType: "uuid", notNull: true, primaryKey: true };
|
|
18
|
+
const NAME_COL: ColumnMeta = { name: "name", pgType: "text", notNull: true };
|
|
19
|
+
// The studio#58/publicstatus#116 blocker shape: NOT NULL without a default.
|
|
20
|
+
const ENVELOPE_COL: ColumnMeta = { name: "envelope", pgType: "jsonb", notNull: true };
|
|
21
|
+
|
|
22
|
+
function managedMeta(tableName: string, columns: readonly ColumnMeta[]): EntityTableMeta {
|
|
23
|
+
return { tableName, source: "managed", indexes: [], columns };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let testDb: TestDb;
|
|
27
|
+
let dir: string;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
testDb = await createTestDb();
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), "managed-recreate-"));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterAll(async () => {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
await testDb.cleanup();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("managed projection recreate applies on a populated table (#356)", () => {
|
|
40
|
+
test("generated DROP+CREATE applies where an in-place ALTER NOT NULL dies", async () => {
|
|
41
|
+
const raw = asRawClient(testDb.db);
|
|
42
|
+
|
|
43
|
+
// A populated managed projection in its OLD shape (id, name).
|
|
44
|
+
await raw.unsafe(`DROP TABLE IF EXISTS "read_proof"`);
|
|
45
|
+
await raw.unsafe(`CREATE TABLE "read_proof" ("id" uuid PRIMARY KEY, "name" text NOT NULL)`);
|
|
46
|
+
await raw.unsafe(
|
|
47
|
+
`INSERT INTO "read_proof" (id, name) VALUES (gen_random_uuid(), 'a'), (gen_random_uuid(), 'b')`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Generate the migration: managed table gains `envelope NOT NULL` → DROP+CREATE.
|
|
51
|
+
// (An in-place ALTER ADD COLUMN ... NOT NULL would die on the two existing
|
|
52
|
+
// rows — that failure mode is exactly what #356 removes for projections.)
|
|
53
|
+
const prev = snapshotFromMetas([managedMeta("read_proof", [ID_COL, NAME_COL])]);
|
|
54
|
+
const next = snapshotFromMetas([managedMeta("read_proof", [ID_COL, NAME_COL, ENVELOPE_COL])]);
|
|
55
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
56
|
+
name: "add_envelope",
|
|
57
|
+
sequenceNumber: 1,
|
|
58
|
+
});
|
|
59
|
+
expect(sql).toContain('DROP TABLE IF EXISTS "read_proof";');
|
|
60
|
+
expect(sql).not.toContain("ADD COLUMN");
|
|
61
|
+
|
|
62
|
+
writeFileSync(join(dir, "0001_add_envelope.sql"), sql);
|
|
63
|
+
const result = await runMigrationsFromDir(testDb.db, dir);
|
|
64
|
+
expect(result.applied).toContain("0001_add_envelope");
|
|
65
|
+
|
|
66
|
+
// Table is back at the new shape (envelope present) and emptied — the DROP
|
|
67
|
+
// ran; the queued rebuild refills it from events (proven elsewhere).
|
|
68
|
+
// SQL-result boundary: shape is known from the SELECT projection.
|
|
69
|
+
const colRows = (await raw.unsafe(
|
|
70
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = 'read_proof'`,
|
|
71
|
+
)) as readonly { column_name: string }[];
|
|
72
|
+
expect(colRows.map((r) => r.column_name)).toContain("envelope");
|
|
73
|
+
const countRows = (await raw.unsafe(
|
|
74
|
+
`SELECT count(*)::int AS count FROM "read_proof"`,
|
|
75
|
+
)) as readonly { count: number }[];
|
|
76
|
+
expect(countRows[0]?.count).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -10,10 +10,11 @@ import {
|
|
|
10
10
|
function meta(
|
|
11
11
|
tableName: string,
|
|
12
12
|
extraColumn?: EntityTableMeta["columns"][number],
|
|
13
|
+
source: EntityTableMeta["source"] = "unmanaged",
|
|
13
14
|
): EntityTableMeta {
|
|
14
15
|
return {
|
|
15
16
|
tableName,
|
|
16
|
-
source
|
|
17
|
+
source,
|
|
17
18
|
indexes: [],
|
|
18
19
|
columns: [
|
|
19
20
|
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
@@ -69,3 +70,58 @@ describe("renderMigrationSql / generateMigration", () => {
|
|
|
69
70
|
expect(out.filename).toBe("0001_init.sql");
|
|
70
71
|
});
|
|
71
72
|
});
|
|
73
|
+
|
|
74
|
+
describe("renderMigrationSql — managed recreate vs unmanaged in-place", () => {
|
|
75
|
+
test("managed: NOT NULL column without default → DROP+CREATE, no in-place ADD", () => {
|
|
76
|
+
const prev = snapshotFromMetas([meta("read_secrets", undefined, "managed")]);
|
|
77
|
+
const next = snapshotFromMetas([
|
|
78
|
+
meta("read_secrets", { name: "envelope", pgType: "jsonb", notNull: true }, "managed"),
|
|
79
|
+
]);
|
|
80
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
81
|
+
name: "secrets",
|
|
82
|
+
sequenceNumber: 2,
|
|
83
|
+
});
|
|
84
|
+
expect(sql).toContain('DROP TABLE IF EXISTS "read_secrets";');
|
|
85
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "read_secrets"');
|
|
86
|
+
expect(sql).not.toContain("ADD COLUMN");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("managed: column rename (drop + add NOT NULL) → DROP+CREATE with new shape", () => {
|
|
90
|
+
const prev = snapshotFromMetas([
|
|
91
|
+
meta("read_a", { name: "old_name", pgType: "text", notNull: true }, "managed"),
|
|
92
|
+
]);
|
|
93
|
+
const next = snapshotFromMetas([
|
|
94
|
+
meta("read_a", { name: "new_name", pgType: "text", notNull: true }, "managed"),
|
|
95
|
+
]);
|
|
96
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
97
|
+
name: "rename",
|
|
98
|
+
sequenceNumber: 3,
|
|
99
|
+
});
|
|
100
|
+
expect(sql).toContain('DROP TABLE IF EXISTS "read_a";');
|
|
101
|
+
expect(sql).toContain('"new_name"');
|
|
102
|
+
expect(sql).not.toContain("DROP COLUMN");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("managed: additive nullable column → in-place ADD COLUMN, no recreate", () => {
|
|
106
|
+
const prev = snapshotFromMetas([meta("read_a", undefined, "managed")]);
|
|
107
|
+
const next = snapshotFromMetas([
|
|
108
|
+
meta("read_a", { name: "note", pgType: "text", notNull: false }, "managed"),
|
|
109
|
+
]);
|
|
110
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), { name: "note", sequenceNumber: 4 });
|
|
111
|
+
expect(sql).toContain('ALTER TABLE "read_a" ADD COLUMN "note"');
|
|
112
|
+
expect(sql).not.toContain("DROP TABLE");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("unmanaged: NOT NULL column without default → in-place ADD (real data, never recreated)", () => {
|
|
116
|
+
const prev = snapshotFromMetas([meta("app_data")]);
|
|
117
|
+
const next = snapshotFromMetas([
|
|
118
|
+
meta("app_data", { name: "envelope", pgType: "jsonb", notNull: true }),
|
|
119
|
+
]);
|
|
120
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
121
|
+
name: "appdata",
|
|
122
|
+
sequenceNumber: 5,
|
|
123
|
+
});
|
|
124
|
+
expect(sql).toContain('ALTER TABLE "app_data" ADD COLUMN "envelope"');
|
|
125
|
+
expect(sql).not.toContain("DROP TABLE");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -10,10 +10,11 @@ function meta(
|
|
|
10
10
|
tableName: string,
|
|
11
11
|
extraColumn?: EntityTableMeta["columns"][number],
|
|
12
12
|
indexes: EntityTableMeta["indexes"] = [],
|
|
13
|
+
source: EntityTableMeta["source"] = "managed",
|
|
13
14
|
): EntityTableMeta {
|
|
14
15
|
return {
|
|
15
16
|
tableName,
|
|
16
|
-
source
|
|
17
|
+
source,
|
|
17
18
|
indexes,
|
|
18
19
|
columns: [
|
|
19
20
|
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
@@ -27,10 +28,10 @@ function tmpDir(): string {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
describe("rebuildTablesFromDiff", () => {
|
|
30
|
-
test("
|
|
31
|
+
test("managed: new + changed tables included, dropped excluded, sorted + unique", () => {
|
|
31
32
|
const prev = snapshotFromMetas([meta("read_a"), meta("read_c")]);
|
|
32
33
|
const next = snapshotFromMetas([
|
|
33
|
-
meta("read_a", { name: "title", pgType: "text", notNull:
|
|
34
|
+
meta("read_a", { name: "title", pgType: "text", notNull: false }),
|
|
34
35
|
meta("read_b"),
|
|
35
36
|
]);
|
|
36
37
|
const diff = diffSnapshots(prev, next);
|
|
@@ -42,7 +43,7 @@ describe("rebuildTablesFromDiff", () => {
|
|
|
42
43
|
expect(rebuildTablesFromDiff(diffSnapshots(snap, snap))).toEqual([]);
|
|
43
44
|
});
|
|
44
45
|
|
|
45
|
-
test("index-only change → no rebuild (ALTER bringt Tabelle alleine in Soll)", () => {
|
|
46
|
+
test("managed non-unique index-only change → no rebuild (ALTER bringt Tabelle alleine in Soll)", () => {
|
|
46
47
|
const prev = snapshotFromMetas([meta("read_a")]);
|
|
47
48
|
const next = snapshotFromMetas([
|
|
48
49
|
meta("read_a", undefined, [{ name: "read_a_id_idx", columns: ["id"] }]),
|
|
@@ -50,21 +51,48 @@ describe("rebuildTablesFromDiff", () => {
|
|
|
50
51
|
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
test("
|
|
54
|
+
test("managed new nullable column → rebuild (Backfill aus Events nötig)", () => {
|
|
55
|
+
const prev = snapshotFromMetas([meta("read_a")]);
|
|
56
|
+
const next = snapshotFromMetas([
|
|
57
|
+
meta("read_a", { name: "title", pgType: "text", notNull: false }),
|
|
58
|
+
]);
|
|
59
|
+
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual(["read_a"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("managed dropped column → rebuild (Tabelle wird DROP+CREATE'd, neu füllen)", () => {
|
|
54
63
|
const prev = snapshotFromMetas([
|
|
55
64
|
meta("read_a", { name: "old", pgType: "text", notNull: false }),
|
|
56
65
|
]);
|
|
57
66
|
const next = snapshotFromMetas([meta("read_a")]);
|
|
58
|
-
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
|
|
67
|
+
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual(["read_a"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("managed SET NOT NULL → rebuild (recreate)", () => {
|
|
71
|
+
const prev = snapshotFromMetas([
|
|
72
|
+
meta("read_a", { name: "title", pgType: "text", notNull: false }),
|
|
73
|
+
]);
|
|
74
|
+
const next = snapshotFromMetas([
|
|
75
|
+
meta("read_a", { name: "title", pgType: "text", notNull: true }),
|
|
76
|
+
]);
|
|
77
|
+
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual(["read_a"]);
|
|
59
78
|
});
|
|
60
79
|
|
|
61
|
-
test("new
|
|
80
|
+
test("managed new UNIQUE index → rebuild (recreate, könnte an Dups scheitern)", () => {
|
|
62
81
|
const prev = snapshotFromMetas([meta("read_a")]);
|
|
63
82
|
const next = snapshotFromMetas([
|
|
64
|
-
meta("read_a", { name: "
|
|
83
|
+
meta("read_a", undefined, [{ name: "read_a_id_uq", columns: ["id"], unique: true }]),
|
|
65
84
|
]);
|
|
66
85
|
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual(["read_a"]);
|
|
67
86
|
});
|
|
87
|
+
|
|
88
|
+
test("unmanaged tables are never rebuilt (echte Daten, keine Projektion)", () => {
|
|
89
|
+
const prev = snapshotFromMetas([meta("u_a", undefined, [], "unmanaged")]);
|
|
90
|
+
const next = snapshotFromMetas([
|
|
91
|
+
meta("u_a", { name: "title", pgType: "text", notNull: true }, [], "unmanaged"),
|
|
92
|
+
meta("u_new", undefined, [], "unmanaged"),
|
|
93
|
+
]);
|
|
94
|
+
expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
|
|
95
|
+
});
|
|
68
96
|
});
|
|
69
97
|
|
|
70
98
|
describe("write/read marker", () => {
|
|
@@ -42,6 +42,10 @@ export type TableDiff = {
|
|
|
42
42
|
readonly changedColumns: readonly ColumnChange[];
|
|
43
43
|
readonly newIndexes: readonly IndexMeta[];
|
|
44
44
|
readonly droppedIndexes: readonly string[];
|
|
45
|
+
// Full target meta — carried so the renderer can emit DROP+CREATE for a
|
|
46
|
+
// managed projection whose change cannot apply in-place (see
|
|
47
|
+
// managedChangeRequiresRecreate). Source-discriminator reached via nextMeta.source.
|
|
48
|
+
readonly nextMeta: EntityTableMeta;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
export type SchemaDiff = {
|
|
@@ -161,6 +165,7 @@ function diffOneTable(prev: EntityTableMeta, next: EntityTableMeta): TableDiff |
|
|
|
161
165
|
changedColumns,
|
|
162
166
|
newIndexes,
|
|
163
167
|
droppedIndexes,
|
|
168
|
+
nextMeta: next,
|
|
164
169
|
};
|
|
165
170
|
}
|
|
166
171
|
|
|
@@ -192,6 +197,23 @@ export function diffSnapshots(prev: Snapshot | null, next: Snapshot): SchemaDiff
|
|
|
192
197
|
return { newTables, droppedTables, changedTables };
|
|
193
198
|
}
|
|
194
199
|
|
|
200
|
+
// A managed projection is a disposable derivative of the event stream. When a
|
|
201
|
+
// schema change cannot apply in-place against existing rows — NOT NULL without
|
|
202
|
+
// default, a UNIQUE index (may hit duplicates), SET NOT NULL, a type change, or
|
|
203
|
+
// a dropped column (incl. the drop-half of a rename) — the additive ALTER would
|
|
204
|
+
// die on the very rows the queued rebuild discards anyway. Such a change is
|
|
205
|
+
// rendered as DROP+CREATE and refilled from events instead. Purely additive,
|
|
206
|
+
// in-place-safe changes (nullable/defaulted ADD, non-unique index, DROP NOT
|
|
207
|
+
// NULL, default-only) stay as cheap ALTERs with no forced replay.
|
|
208
|
+
export function managedChangeRequiresRecreate(td: TableDiff): boolean {
|
|
209
|
+
if (td.droppedColumns.length > 0) return true;
|
|
210
|
+
if (td.newColumns.some((c) => c.notNull && c.defaultSql === undefined)) return true;
|
|
211
|
+
if (td.newIndexes.some((idx) => idx.unique === true)) return true;
|
|
212
|
+
return td.changedColumns.some(
|
|
213
|
+
(c) => c.nullabilityChanged?.to === true || c.typeChanged !== undefined,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
195
217
|
// --- SQL-Render ---------------------------------------------------------
|
|
196
218
|
|
|
197
219
|
function quoteIdent(name: string): string {
|
|
@@ -275,6 +297,13 @@ export function renderMigrationSql(
|
|
|
275
297
|
lines.push("-- === Changed tables ===");
|
|
276
298
|
for (const td of diff.changedTables) {
|
|
277
299
|
lines.push(`-- ${td.tableName}`);
|
|
300
|
+
if (td.nextMeta.source === "managed" && managedChangeRequiresRecreate(td)) {
|
|
301
|
+
lines.push("-- managed projection — recreated + rebuilt from events (see .rebuild.json)");
|
|
302
|
+
lines.push(`DROP TABLE IF EXISTS ${quoteIdent(td.tableName)};`);
|
|
303
|
+
lines.push(...renderTableDdl(td.nextMeta));
|
|
304
|
+
lines.push("");
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
278
307
|
for (const col of td.newColumns) {
|
|
279
308
|
lines.push(renderAddColumn(td.tableName, col));
|
|
280
309
|
}
|
package/src/db/rebuild-marker.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
|
|
22
|
-
import type
|
|
22
|
+
import { managedChangeRequiresRecreate, type SchemaDiff } from "./migrate-generator";
|
|
23
23
|
|
|
24
24
|
const MARKER_VERSION = 1 as const;
|
|
25
25
|
|
|
@@ -32,18 +32,22 @@ function markerPathFor(migrationsDir: string, migrationId: string): string {
|
|
|
32
32
|
return join(migrationsDir, `${migrationId}.rebuild.json`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
35
|
+
// Nur MANAGED Projektionen (Derivate des Event-Streams) brauchen/erlauben einen
|
|
36
|
+
// Rebuild — unmanaged Tabellen tragen echte Daten und werden nie aus Events
|
|
37
|
+
// rekonstruiert, dürfen also nie in den Marker. Eine managed Tabelle kommt rein,
|
|
38
|
+
// wenn sie eine neue Spalte bekommt (Backfill) oder DROP+CREATE'd wird
|
|
39
|
+
// (managedChangeRequiresRecreate → Tabelle geleert, muss neu gefüllt werden).
|
|
40
|
+
// Reine non-unique-Index-/Default-/DROP-NOT-NULL-Änderungen brauchen keinen
|
|
41
|
+
// Rebuild. Sortiert + dedupliziert für stabilen PR-Diff.
|
|
41
42
|
export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
|
|
42
43
|
const names = new Set<string>();
|
|
43
44
|
for (const t of diff.changedTables) {
|
|
44
|
-
if (t.
|
|
45
|
+
if (t.nextMeta.source !== "managed") continue;
|
|
46
|
+
if (t.newColumns.length > 0 || managedChangeRequiresRecreate(t)) names.add(t.tableName);
|
|
47
|
+
}
|
|
48
|
+
for (const t of diff.newTables) {
|
|
49
|
+
if (t.source === "managed") names.add(t.tableName);
|
|
45
50
|
}
|
|
46
|
-
for (const t of diff.newTables) names.add(t.tableName);
|
|
47
51
|
return [...names].sort();
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ConfigScopes } from "../constants";
|
|
3
|
+
import { buildManifestFromRegistry, createRegistry, defineFeature } from "../index";
|
|
4
|
+
|
|
5
|
+
const boolKey = {
|
|
6
|
+
type: "boolean",
|
|
7
|
+
scope: ConfigScopes.system,
|
|
8
|
+
access: { read: ["anonymous"], write: ["anonymous"] },
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
describe("buildManifestFromRegistry — deterministic codepoint sort (#330)", () => {
|
|
12
|
+
// `bZeta` vs `balpha`: localeCompare orders these case-insensitively
|
|
13
|
+
// (balpha < bZeta), but a codepoint sort puts uppercase 'Z' (U+005A) ahead of
|
|
14
|
+
// lowercase 'a' (U+0061) — the two comparators DISAGREE. The manifest is
|
|
15
|
+
// serialized to byte-exact JSON and must not depend on the runner's ICU
|
|
16
|
+
// locale (macOS-dev vs Linux-CI). This assertion fails the instant anyone
|
|
17
|
+
// reverts buildManifestFromRegistry to localeCompare. (Feature names skip the
|
|
18
|
+
// kebab normalization that config/secret short-names go through, so they are
|
|
19
|
+
// the one place a case-based disagreement survives into the sorted output.)
|
|
20
|
+
test("features are ordered by codepoint name, not locale", () => {
|
|
21
|
+
const registry = createRegistry([
|
|
22
|
+
defineFeature("bZeta", () => {}),
|
|
23
|
+
defineFeature("balpha", () => {}),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const manifest = buildManifestFromRegistry(registry, { source: "test" });
|
|
27
|
+
|
|
28
|
+
expect(manifest.features.map((f) => f.name)).toEqual(["bZeta", "balpha"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("config keys within a feature come out sorted by qualified name", () => {
|
|
32
|
+
const feature = defineFeature("demo", (r) => {
|
|
33
|
+
r.config({ keys: { "z-flag": boolKey, "a-flag": boolKey } });
|
|
34
|
+
});
|
|
35
|
+
const registry = createRegistry([feature]);
|
|
36
|
+
|
|
37
|
+
const manifest = buildManifestFromRegistry(registry, { source: "test" });
|
|
38
|
+
const demo = manifest.features.find((f) => f.name === "demo");
|
|
39
|
+
|
|
40
|
+
expect(demo?.configKeys.map((k) => k.qualifiedName)).toEqual([
|
|
41
|
+
"demo:config:a-flag",
|
|
42
|
+
"demo:config:z-flag",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -59,6 +59,13 @@ export type FeatureManifest = {
|
|
|
59
59
|
|
|
60
60
|
const CONFIG_SEGMENT = ":config:";
|
|
61
61
|
|
|
62
|
+
// Codepoint order, not localeCompare: the manifest is serialized to byte-exact
|
|
63
|
+
// JSON, and localeCompare's ordering depends on the runner's ICU locale → drift
|
|
64
|
+
// between macOS-dev and Linux-CI (#330).
|
|
65
|
+
function compareByCodepoint(a: string, b: string): number {
|
|
66
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
export type BuildManifestOptions = {
|
|
63
70
|
/** Herkunfts-Beschreibung fürs Manifest (landet 1:1 im JSON). */
|
|
64
71
|
readonly source: string;
|
|
@@ -111,8 +118,8 @@ export function buildManifestFromRegistry(
|
|
|
111
118
|
});
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
configKeys.sort((a, b) => a.qualifiedName
|
|
115
|
-
secrets.sort((a, b) => a.qualifiedName
|
|
121
|
+
configKeys.sort((a, b) => compareByCodepoint(a.qualifiedName, b.qualifiedName));
|
|
122
|
+
secrets.sort((a, b) => compareByCodepoint(a.qualifiedName, b.qualifiedName));
|
|
116
123
|
|
|
117
124
|
manifestFeatures.push({
|
|
118
125
|
name: feature.name,
|
|
@@ -133,7 +140,7 @@ export function buildManifestFromRegistry(
|
|
|
133
140
|
});
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
manifestFeatures.sort((a, b) => a.name
|
|
143
|
+
manifestFeatures.sort((a, b) => compareByCodepoint(a.name, b.name));
|
|
137
144
|
|
|
138
145
|
return {
|
|
139
146
|
source: options.source,
|