@cosmicdrift/kumiko-framework 0.46.0 → 0.48.0
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.
|
|
3
|
+
"version": "0.48.0",
|
|
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>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.45.0",
|
|
185
185
|
"@types/uuid": "^11.0.0",
|
|
186
186
|
"bun-types": "^1.3.13",
|
|
187
187
|
"pino-pretty": "^13.1.3"
|
|
@@ -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
|
|