@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.47.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: "unmanaged",
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: "unmanaged",
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("includes new + changed tables, excludes dropped, sorted + unique", () => {
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: true }),
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("dropped-column-only change no rebuild", () => {
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-column change → rebuild (Backfill aus Events nötig)", () => {
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: "title", pgType: "text", notNull: false }),
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
  }
@@ -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 { SchemaDiff } from "./migrate-generator";
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
- // Tabellen die nach dieser Migration einen Projection-Rebuild brauchen können:
36
- // neu angelegte + Tabellen mit neuer Spalte. Index-/Nullability-/Default-/
37
- // Drop-only-Änderungen brauchen KEINEN Rebuild das generierte ALTER-SQL
38
- // bringt die Tabelle alleine in den Soll-Zustand. Über-Rebuild ist safe-but-
39
- // wasteful (Tabellen-Lock + Full-Replay auf grossen Streams), deshalb hier
40
- // konservativ einschränken. Sortiert + dedupliziert für stabilen PR-Diff.
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.newColumns.length > 0) names.add(t.tableName);
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.localeCompare(b.qualifiedName));
115
- secrets.sort((a, b) => a.qualifiedName.localeCompare(b.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.localeCompare(b.name));
143
+ manifestFeatures.sort((a, b) => compareByCodepoint(a.name, b.name));
137
144
 
138
145
  return {
139
146
  source: options.source,