@cosmicdrift/kumiko-framework 0.45.0 → 0.46.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 +1 -1
- package/src/__tests__/schema-cli.integration.test.ts +28 -0
- package/src/db/__tests__/collect-table-metas.test.ts +60 -2
- package/src/db/collect-table-metas.ts +22 -2
- package/src/db/entity-table-meta.ts +41 -0
- package/src/db/schema-inspection.ts +41 -18
- package/src/engine/define-feature.ts +8 -1
- package/src/engine/registry.ts +44 -3
- package/src/engine/types/feature.ts +12 -1
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +42 -0
- package/src/migrations/__tests__/kumiko-drift.report.test.ts +13 -0
- package/src/migrations/kumiko-drift.ts +50 -11
- package/src/schema-cli.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.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>",
|
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
|
|
6
|
+
import { createDbConnection, tableExists } from "../db";
|
|
6
7
|
import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
|
|
7
8
|
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
8
9
|
|
|
@@ -186,6 +187,33 @@ describe("runSchemaCli — DB-backed paths", () => {
|
|
|
186
187
|
rmSync(appCwd, { recursive: true, force: true });
|
|
187
188
|
});
|
|
188
189
|
|
|
190
|
+
test("apply creates the framework-infra tables on a greenfield DB", async () => {
|
|
191
|
+
// Regression-pin: a brand-new app (no legacy-drizzle cutover) only had its
|
|
192
|
+
// entity-read tables after `apply`, so runProdApp's first event-store access
|
|
193
|
+
// hit "relation kumiko_events does not exist". `apply` now ensures the
|
|
194
|
+
// framework-infra tables (idempotent) so a greenfield deploy boots.
|
|
195
|
+
const appCwd = freshAppCwd();
|
|
196
|
+
writeSchemaFile(appCwd, "tbl_infra");
|
|
197
|
+
await runSchemaCli(["generate", "infra_test"], appCwd, captureOut().out);
|
|
198
|
+
await runSchemaCli(["apply"], appCwd, captureOut().out);
|
|
199
|
+
|
|
200
|
+
const { db, close } = createDbConnection(dbUrl);
|
|
201
|
+
try {
|
|
202
|
+
for (const table of [
|
|
203
|
+
"public.kumiko_events",
|
|
204
|
+
"public.kumiko_snapshots",
|
|
205
|
+
"public.kumiko_archived_streams",
|
|
206
|
+
"public.kumiko_event_consumers",
|
|
207
|
+
"public.kumiko_projections",
|
|
208
|
+
]) {
|
|
209
|
+
expect(await tableExists(db, table)).toBe(true);
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
await close();
|
|
213
|
+
}
|
|
214
|
+
rmSync(appCwd, { recursive: true, force: true });
|
|
215
|
+
});
|
|
216
|
+
|
|
189
217
|
test("status with pending migrations exits 1 (regression-pin: CI-gating signal)", async () => {
|
|
190
218
|
const appCwd = freshAppCwd();
|
|
191
219
|
writeSchemaFile(appCwd, "tbl_pending");
|
|
@@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { defineFeature } from "../../engine/define-feature";
|
|
3
3
|
import { createEntity, createTextField } from "../../engine/factories";
|
|
4
4
|
import { collectTableMetas } from "../collect-table-metas";
|
|
5
|
-
import { integer, type SchemaTable, table, text, uuid } from "../dialect";
|
|
5
|
+
import { integer, jsonb, type SchemaTable, table, text, uniqueIndex, uuid } from "../dialect";
|
|
6
6
|
import { defineUnmanagedTable } from "../entity-table-meta";
|
|
7
|
-
import {
|
|
7
|
+
import { asEntityTableMeta } from "../query";
|
|
8
|
+
import { buildBaseColumns, buildEntityTable } from "../table-builder";
|
|
8
9
|
|
|
9
10
|
function exampleEntity() {
|
|
10
11
|
return createEntity({
|
|
@@ -124,3 +125,60 @@ describe("collectTableMetas (#255)", () => {
|
|
|
124
125
|
expect(() => collectTableMetas([feature])).toThrow(/no EntityTableMeta/);
|
|
125
126
|
});
|
|
126
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("collectTableMetas — r.entity backing table (#347)", () => {
|
|
130
|
+
const richWidgetTable = table(
|
|
131
|
+
"read_widgets",
|
|
132
|
+
{
|
|
133
|
+
...buildBaseColumns(false, "uuid"),
|
|
134
|
+
name: text("name").notNull(),
|
|
135
|
+
tag: jsonb("tag").notNull(), // ride-along: no entity field declares this
|
|
136
|
+
},
|
|
137
|
+
(t) => [uniqueIndex("read_widgets_name_unique").on(t.name)],
|
|
138
|
+
) as unknown as SchemaTable;
|
|
139
|
+
|
|
140
|
+
function widgetEntity() {
|
|
141
|
+
return createEntity({
|
|
142
|
+
table: "read_widgets",
|
|
143
|
+
fields: { name: createTextField({ required: true }) },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
test("ride-along column + index from the backing table reach the generated meta", () => {
|
|
148
|
+
const feature = defineFeature("test", (r) => {
|
|
149
|
+
r.entity("widget", widgetEntity(), { table: richWidgetTable });
|
|
150
|
+
});
|
|
151
|
+
const meta = collectTableMetas([feature]).find((m) => m.tableName === "read_widgets");
|
|
152
|
+
expect(meta?.columns.map((c) => c.name)).toContain("tag");
|
|
153
|
+
expect(meta?.indexes.map((i) => i.name)).toContain("read_widgets_name_unique");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("generate draws from the backing table itself — one source (the #255 invariant)", () => {
|
|
157
|
+
const feature = defineFeature("test", (r) => {
|
|
158
|
+
r.entity("widget", widgetEntity(), { table: richWidgetTable });
|
|
159
|
+
});
|
|
160
|
+
const meta = collectTableMetas([feature]).find((m) => m.tableName === "read_widgets");
|
|
161
|
+
// Identical to the table the test-stack push + executor use → they cannot drift.
|
|
162
|
+
expect(meta).toEqual(asEntityTableMeta(richWidgetTable));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("throws when the backing table is missing a field's column (superset violated)", () => {
|
|
166
|
+
const thin = createEntity({
|
|
167
|
+
table: "read_widgets",
|
|
168
|
+
fields: { name: createTextField({ required: true }) },
|
|
169
|
+
});
|
|
170
|
+
const rich = createEntity({
|
|
171
|
+
table: "read_widgets",
|
|
172
|
+
fields: {
|
|
173
|
+
name: createTextField({ required: true }),
|
|
174
|
+
extra: createTextField({ required: true }),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
// Backing table built from the THIN entity lacks the `extra` column.
|
|
178
|
+
const backing = buildEntityTable("widget", thin) as unknown as SchemaTable;
|
|
179
|
+
const feature = defineFeature("test", (r) => {
|
|
180
|
+
r.entity("widget", rich, { table: backing });
|
|
181
|
+
});
|
|
182
|
+
expect(() => collectTableMetas([feature])).toThrow(/missing column "extra"/);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
// crashte (#255).
|
|
8
8
|
|
|
9
9
|
import type { FeatureDefinition } from "../engine/types";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
assertBackingTableSuperset,
|
|
12
|
+
buildEntityTableMeta,
|
|
13
|
+
type EntityTableMeta,
|
|
14
|
+
} from "./entity-table-meta";
|
|
11
15
|
import { enumerateFeatureTableSources } from "./feature-table-sources";
|
|
12
16
|
import { asEntityTableMeta } from "./query";
|
|
13
17
|
|
|
@@ -35,7 +39,23 @@ export function collectTableMetas(
|
|
|
35
39
|
// Verhalten (gleiche Reihenfolge, gleiche buildEntityTableMeta-Optionen).
|
|
36
40
|
for (const feature of features) {
|
|
37
41
|
for (const [name, ent] of Object.entries(feature.entities ?? {})) {
|
|
38
|
-
const
|
|
42
|
+
const fieldMeta = buildEntityTableMeta(name, ent, { relations: feature.relations[name] });
|
|
43
|
+
// Backing table wins: it's the physical DDL truth for ride-along columns/
|
|
44
|
+
// indexes the field-DSL can't express (secrets' envelope). Validated as a
|
|
45
|
+
// superset of the field-derived meta so a field/table disagreement throws.
|
|
46
|
+
const backing = feature.entityTables?.[name];
|
|
47
|
+
let meta = fieldMeta;
|
|
48
|
+
if (backing !== undefined) {
|
|
49
|
+
const tableMeta = asEntityTableMeta(backing);
|
|
50
|
+
if (!tableMeta) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`collectTableMetas: entity "${name}" (${feature.name}) declares a backing ` +
|
|
53
|
+
"table that carries no EntityTableMeta — build it via table() / buildEntityTable.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
assertBackingTableSuperset(name, fieldMeta, tableMeta);
|
|
57
|
+
meta = tableMeta;
|
|
58
|
+
}
|
|
39
59
|
metas.push(meta);
|
|
40
60
|
byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
|
|
41
61
|
}
|
|
@@ -406,6 +406,47 @@ function sqlExpressionText(where: unknown): string | undefined {
|
|
|
406
406
|
return undefined;
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
// Validates that a backing Drizzle table (declared via `r.entity(name, def,
|
|
410
|
+
// { table })`) is a SUPERSET of the field-derived meta: every column the
|
|
411
|
+
// entity fields produce must exist on the table with the same pgType +
|
|
412
|
+
// notNull. Ride-along columns/indexes the table adds on top (envelope,
|
|
413
|
+
// uniqueIndex, …) are exactly the point — they pass. A field with no matching
|
|
414
|
+
// physical column, or a type/nullability mismatch, is real authoring drift
|
|
415
|
+
// (the table and the entity disagree on a shared column) → throw. Catches the
|
|
416
|
+
// inverse of the bug this whole mechanism fixes.
|
|
417
|
+
export function assertBackingTableSuperset(
|
|
418
|
+
entityName: string,
|
|
419
|
+
fieldMeta: EntityTableMeta,
|
|
420
|
+
tableMeta: EntityTableMeta,
|
|
421
|
+
): void {
|
|
422
|
+
const tableCols = columnsByNameMeta(tableMeta);
|
|
423
|
+
for (const fieldCol of fieldMeta.columns) {
|
|
424
|
+
const tableCol = tableCols.get(fieldCol.name);
|
|
425
|
+
if (!tableCol) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`r.entity("${entityName}", …, { table }): the backing table ` +
|
|
428
|
+
`"${tableMeta.tableName}" is missing column "${fieldCol.name}" that the ` +
|
|
429
|
+
"entity field declares. The table must be a superset of the entity's " +
|
|
430
|
+
"fields — add the column to the table or remove the field.",
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (tableCol.pgType !== fieldCol.pgType || tableCol.notNull !== fieldCol.notNull) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`r.entity("${entityName}", …, { table }): column "${fieldCol.name}" ` +
|
|
436
|
+
`disagrees between entity field (${fieldCol.pgType}, ` +
|
|
437
|
+
`notNull=${fieldCol.notNull}) and backing table "${tableMeta.tableName}" ` +
|
|
438
|
+
`(${tableCol.pgType}, notNull=${tableCol.notNull}). Align them.`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
445
|
+
const m = new Map<string, ColumnMeta>();
|
|
446
|
+
for (const c of meta.columns) m.set(c.name, c);
|
|
447
|
+
return m;
|
|
448
|
+
}
|
|
449
|
+
|
|
409
450
|
export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
|
|
410
451
|
return {
|
|
411
452
|
tableName: input.tableName,
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { DbConnection, DbTx } from "./connection";
|
|
2
2
|
|
|
3
|
+
type UnsafeFn = (s: string, p?: readonly unknown[]) => Promise<readonly Record<string, unknown>[]>;
|
|
4
|
+
|
|
5
|
+
// The raw postgres `.unsafe(sql, params)` escape hatch lives at different
|
|
6
|
+
// depths depending on whether `db` is a DbConnection, a Drizzle session, or
|
|
7
|
+
// the bare client. Resolve it once for the low-level inspection queries below.
|
|
8
|
+
function resolveUnsafeClient(db: DbConnection | DbTx): UnsafeFn {
|
|
9
|
+
const dbAny = db as unknown as {
|
|
10
|
+
$client?: { unsafe?: UnsafeFn };
|
|
11
|
+
session?: { client?: { unsafe?: UnsafeFn } };
|
|
12
|
+
unsafe?: UnsafeFn;
|
|
13
|
+
};
|
|
14
|
+
const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
|
|
15
|
+
return (client as { unsafe: UnsafeFn }).unsafe;
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
// True when `<fullyQualifiedName>` refers to an existing relation in the
|
|
4
19
|
// current database. Thin wrapper over `to_regclass`, which returns NULL
|
|
5
20
|
// when the name doesn't resolve — the only postgres query that cheaply
|
|
@@ -17,18 +32,7 @@ export async function tableExists(
|
|
|
17
32
|
db: DbConnection | DbTx,
|
|
18
33
|
fullyQualifiedName: string,
|
|
19
34
|
): Promise<boolean> {
|
|
20
|
-
const
|
|
21
|
-
$client?: {
|
|
22
|
-
unsafe: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
|
|
23
|
-
};
|
|
24
|
-
session?: {
|
|
25
|
-
client?: {
|
|
26
|
-
unsafe: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
|
|
27
|
-
};
|
|
28
|
-
};
|
|
29
|
-
unsafe?: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
|
|
30
|
-
};
|
|
31
|
-
const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
|
|
35
|
+
const unsafe = resolveUnsafeClient(db);
|
|
32
36
|
// quote_ident-Round-trip auf SQL-Seite: ohne Quotes folded postgres
|
|
33
37
|
// unquoted identifier case-insensitiv (myWidget → mywidget), während die
|
|
34
38
|
// generierte DDL den Namen via quoteIdent("myWidget") → "myWidget" case-
|
|
@@ -42,10 +46,29 @@ export async function tableExists(
|
|
|
42
46
|
[fullyQualifiedName.slice(0, dotIdx), fullyQualifiedName.slice(dotIdx + 1)],
|
|
43
47
|
]
|
|
44
48
|
: [`SELECT to_regclass(quote_ident($1)) IS NOT NULL AS exists`, [fullyQualifiedName]];
|
|
45
|
-
const rows = await (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const rows = await unsafe(sql, params);
|
|
50
|
+
return rows[0]?.["exists"] === true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Live column names of a `public` table, snake_case as stored. Empty set for a
|
|
54
|
+
// non-existent table (callers gate on tableExists first). Used by the schema-
|
|
55
|
+
// drift Layer-3 column-diff to catch a migrated-but-incomplete table (a
|
|
56
|
+
// snapshot column the physical table is missing) at boot instead of as a
|
|
57
|
+
// runtime-500 on the first write.
|
|
58
|
+
export async function columnNamesOf(
|
|
59
|
+
db: DbConnection | DbTx,
|
|
60
|
+
tableName: string,
|
|
61
|
+
): Promise<ReadonlySet<string>> {
|
|
62
|
+
const unsafe = resolveUnsafeClient(db);
|
|
63
|
+
const rows = await unsafe(
|
|
64
|
+
"SELECT column_name FROM information_schema.columns " +
|
|
65
|
+
"WHERE table_schema = 'public' AND table_name = $1",
|
|
66
|
+
[tableName],
|
|
67
|
+
);
|
|
68
|
+
const names = new Set<string>();
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
const name = row["column_name"];
|
|
71
|
+
if (typeof name === "string") names.add(name);
|
|
72
|
+
}
|
|
73
|
+
return names;
|
|
51
74
|
}
|
|
@@ -103,6 +103,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
103
103
|
// Tier-2 step kinds declared via r.requires.step("webhook.send"). Q9.
|
|
104
104
|
const requiredSteps = new Set<string>();
|
|
105
105
|
const entities: Record<string, EntityDefinition> = {};
|
|
106
|
+
const entityTables: Record<string, unknown> = {};
|
|
106
107
|
const relations: Record<string, Record<string, RelationDefinition>> = {};
|
|
107
108
|
const writeHandlers: Record<string, WriteHandlerDef> = {};
|
|
108
109
|
const queryHandlers: Record<string, QueryHandlerDef> = {};
|
|
@@ -223,8 +224,13 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
223
224
|
toggleableDefault = options.default;
|
|
224
225
|
},
|
|
225
226
|
|
|
226
|
-
entity(
|
|
227
|
+
entity(
|
|
228
|
+
entityName: string,
|
|
229
|
+
definition: EntityDefinition,
|
|
230
|
+
options?: { readonly table?: unknown },
|
|
231
|
+
): EntityRef {
|
|
227
232
|
entities[entityName] = definition;
|
|
233
|
+
if (options?.table !== undefined) entityTables[entityName] = options.table;
|
|
228
234
|
return { name: entityName, table: definition.table ?? toTableName(entityName) };
|
|
229
235
|
},
|
|
230
236
|
|
|
@@ -923,6 +929,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
923
929
|
requiredSteps,
|
|
924
930
|
...(toggleableDefault !== undefined && { toggleableDefault }),
|
|
925
931
|
entities,
|
|
932
|
+
entityTables,
|
|
926
933
|
relations,
|
|
927
934
|
writeHandlers,
|
|
928
935
|
queryHandlers,
|
package/src/engine/registry.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { asEntityTableMeta } from "../bun-db/query";
|
|
1
2
|
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
assertBackingTableSuperset,
|
|
5
|
+
buildEntityTableMeta,
|
|
6
|
+
resolveTableName,
|
|
7
|
+
} from "../db/entity-table-meta";
|
|
3
8
|
import { buildEntityTable } from "../db/table-builder";
|
|
4
9
|
import { buildMetricName, validateMetricName } from "../observability";
|
|
5
10
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
@@ -67,9 +72,18 @@ function buildImplicitProjection(
|
|
|
67
72
|
entityName: string,
|
|
68
73
|
entity: EntityDefinition,
|
|
69
74
|
qualify: typeof qualifyEntityName,
|
|
75
|
+
backingTable?: unknown,
|
|
70
76
|
): ProjectionDefinition {
|
|
71
77
|
const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
|
|
72
|
-
|
|
78
|
+
// Backing table (r.entity(name, def, { table })) is the one physical table
|
|
79
|
+
// object shared by executor-writes, rebuild-replay, test-push and
|
|
80
|
+
// collectTableMetas — restoring the #255 invariant (test-push == generate).
|
|
81
|
+
// Validated as a superset of the field-derived columns so a field/table
|
|
82
|
+
// disagreement fails at boot, not as a silent thin-vs-rich row.
|
|
83
|
+
const drizzleTable =
|
|
84
|
+
backingTable !== undefined
|
|
85
|
+
? resolveBackingTable(entityName, entity, backingTable)
|
|
86
|
+
: buildEntityTable(entityName, entity);
|
|
73
87
|
// applyEntityEvent gibt ApplyResult zurück; SingleStreamApplyFn erwartet
|
|
74
88
|
// Promise<void>. Im rebuild-Pfad ist die Row irrelevant — wir discarden.
|
|
75
89
|
const handler = async (
|
|
@@ -99,6 +113,27 @@ function buildImplicitProjection(
|
|
|
99
113
|
};
|
|
100
114
|
}
|
|
101
115
|
|
|
116
|
+
// Validates a r.entity backing table is a superset of the entity's field-
|
|
117
|
+
// derived columns, then hands it back as the projection table. The cast is a
|
|
118
|
+
// system-boundary reconstitution: the table is stored as `unknown` on
|
|
119
|
+
// FeatureDefinition only to keep drizzle out of the plain-data shape, and
|
|
120
|
+
// asEntityTableMeta confirms the kumiko-table shape at runtime.
|
|
121
|
+
function resolveBackingTable(
|
|
122
|
+
entityName: string,
|
|
123
|
+
entity: EntityDefinition,
|
|
124
|
+
backingTable: unknown,
|
|
125
|
+
): ProjectionDefinition["table"] {
|
|
126
|
+
const tableMeta = asEntityTableMeta(backingTable);
|
|
127
|
+
if (!tableMeta) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`r.entity("${entityName}", …, { table }): the backing table carries no ` +
|
|
130
|
+
"EntityTableMeta — build it via table() / buildEntityTable.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
assertBackingTableSuperset(entityName, buildEntityTableMeta(entityName, entity), tableMeta);
|
|
134
|
+
return backingTable as ProjectionDefinition["table"];
|
|
135
|
+
}
|
|
136
|
+
|
|
102
137
|
// This is where the magic happens. By "magic" I mean: precomputed maps.
|
|
103
138
|
// I build everything once at boot (hooks, relations, searchable fields, ...)
|
|
104
139
|
// so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
|
|
@@ -886,7 +921,13 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
886
921
|
// mit Entity-Name registriert.
|
|
887
922
|
for (const feature of features) {
|
|
888
923
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
889
|
-
const def = buildImplicitProjection(
|
|
924
|
+
const def = buildImplicitProjection(
|
|
925
|
+
feature.name,
|
|
926
|
+
entityName,
|
|
927
|
+
entity,
|
|
928
|
+
qualify,
|
|
929
|
+
feature.entityTables?.[entityName],
|
|
930
|
+
);
|
|
890
931
|
if (projectionMap.has(def.name)) {
|
|
891
932
|
throw new Error(
|
|
892
933
|
`Implicit projection "${def.name}" kollidiert mit einer explizit registrierten r.projection. ` +
|
|
@@ -202,6 +202,13 @@ export type FeatureDefinition = {
|
|
|
202
202
|
// (test fixtures, partial boots — see registry.test.ts "slot robustness")
|
|
203
203
|
// omit them and the registry guards against that. Type follows runtime.
|
|
204
204
|
readonly entities?: Readonly<Record<string, EntityDefinition>>;
|
|
205
|
+
// Optional backing Drizzle table per entity, declared via the third arg of
|
|
206
|
+
// `r.entity(name, def, { table })`. Source of truth for the physical DDL
|
|
207
|
+
// when the table carries columns/indexes the field-DSL can't express
|
|
208
|
+
// (e.g. secrets' envelope jsonb without default). `collectTableMetas` and
|
|
209
|
+
// the registry's implicit-projection use this object instead of the
|
|
210
|
+
// field-derived table, so generate + test-push + executor share ONE table.
|
|
211
|
+
readonly entityTables?: Readonly<Record<string, unknown>>;
|
|
205
212
|
readonly relations: Readonly<Record<string, EntityRelations>>;
|
|
206
213
|
readonly writeHandlers: Readonly<Record<string, WriteHandlerDef>>;
|
|
207
214
|
readonly queryHandlers: Readonly<Record<string, QueryHandlerDef>>;
|
|
@@ -359,7 +366,11 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
359
366
|
// bug — and one nothing catches at boot, so don't.
|
|
360
367
|
toggleable(options: { default: boolean }): void;
|
|
361
368
|
|
|
362
|
-
entity(
|
|
369
|
+
entity(
|
|
370
|
+
name: string,
|
|
371
|
+
definition: EntityDefinition,
|
|
372
|
+
options?: { readonly table?: unknown },
|
|
373
|
+
): EntityRef;
|
|
363
374
|
|
|
364
375
|
writeHandler<TName extends string, TSchema extends ZodType>(
|
|
365
376
|
def: WriteHandlerDefinition<TName, TSchema>,
|
|
@@ -103,6 +103,48 @@ describe("kumiko-drift boot-gate", () => {
|
|
|
103
103
|
expect(report.missingTables).toEqual(["kdrift_widget"]);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("snapshot column missing in DB → missingColumns (Layer 3, boot-fail not 500)", async () => {
|
|
107
|
+
// The #347 class: table was migrated from a thin snapshot, the snapshot is
|
|
108
|
+
// now richer (ride-along columns the generator started emitting). The table
|
|
109
|
+
// EXISTS (Layer 2 ok) but lacks columns the code will write → without
|
|
110
|
+
// Layer 3 this surfaces as a runtime-500 on the first write. Layer 3 makes
|
|
111
|
+
// it a boot-time SchemaDriftError with a regen-hint instead.
|
|
112
|
+
await asRawClient(testDb.db).unsafe(
|
|
113
|
+
`CREATE TABLE "kdrift_widget" ("id" uuid PRIMARY KEY, "key" text)`,
|
|
114
|
+
);
|
|
115
|
+
writeMigration("0001_init.sql", `SELECT 1;`); // recorded, table pre-exists thin
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(dir, ".snapshot.json"),
|
|
118
|
+
JSON.stringify({
|
|
119
|
+
version: 1,
|
|
120
|
+
tables: [
|
|
121
|
+
{
|
|
122
|
+
tableName: "kdrift_widget",
|
|
123
|
+
columns: [
|
|
124
|
+
{ name: "id", pgType: "uuid", notNull: true },
|
|
125
|
+
{ name: "key", pgType: "text", notNull: true },
|
|
126
|
+
{ name: "envelope", pgType: "jsonb", notNull: true },
|
|
127
|
+
{ name: "metadata", pgType: "jsonb", notNull: true },
|
|
128
|
+
],
|
|
129
|
+
indexes: [],
|
|
130
|
+
source: "managed",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
await runMigrationsFromDir(testDb.db, dir);
|
|
136
|
+
|
|
137
|
+
const report = await detectKumikoDrift(testDb.db, dir);
|
|
138
|
+
expect(report.ok).toBe(false);
|
|
139
|
+
expect(report.missingTables).toEqual([]);
|
|
140
|
+
expect(report.missingColumns).toEqual([
|
|
141
|
+
{ table: "kdrift_widget", columns: ["envelope", "metadata"] },
|
|
142
|
+
]);
|
|
143
|
+
await expect(assertKumikoSchemaCurrent(testDb.db, dir)).rejects.toBeInstanceOf(
|
|
144
|
+
SchemaDriftError,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
106
148
|
test("missing migrations dir → ok (no migrations to validate)", async () => {
|
|
107
149
|
// Regression für review #155 finding 1: existing-App-Upgrade ohne
|
|
108
150
|
// ./kumiko/migrations darf nicht roh ENOENT werfen (würde sonst plain
|
|
@@ -12,6 +12,7 @@ const empty: KumikoDriftReport = {
|
|
|
12
12
|
pending: [],
|
|
13
13
|
checksumMismatches: [],
|
|
14
14
|
missingTables: [],
|
|
15
|
+
missingColumns: [],
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
describe("formatKumikoDriftReport", () => {
|
|
@@ -76,6 +77,18 @@ describe("formatKumikoDriftReport", () => {
|
|
|
76
77
|
expect(out).not.toContain("Restore from backup");
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
test("missing columns → lists table.columns + regen remediation", () => {
|
|
81
|
+
const report: KumikoDriftReport = {
|
|
82
|
+
...empty,
|
|
83
|
+
ok: false,
|
|
84
|
+
missingColumns: [{ table: "read_tenant_secrets", columns: ["envelope", "metadata"] }],
|
|
85
|
+
};
|
|
86
|
+
const out = formatKumikoDriftReport(report);
|
|
87
|
+
expect(out).toContain("2 missing column(s)");
|
|
88
|
+
expect(out).toContain("read_tenant_secrets: envelope, metadata");
|
|
89
|
+
expect(out).toContain("'kumiko schema generate'");
|
|
90
|
+
});
|
|
91
|
+
|
|
79
92
|
test("pending + mismatch combined → both remediation lines", () => {
|
|
80
93
|
const report: KumikoDriftReport = {
|
|
81
94
|
...empty,
|
|
@@ -6,20 +6,21 @@
|
|
|
6
6
|
// 1. Migrations applied: every `kumiko/migrations/*.sql` has a row in
|
|
7
7
|
// `_kumiko_migrations`. Applied-but-edited (checksum mismatch) is drift.
|
|
8
8
|
// 2. Tables exist: every table in `kumiko/migrations/.snapshot.json` exists.
|
|
9
|
+
// 3. Columns exist: every column the snapshot declares for an existing table
|
|
10
|
+
// is present in the live schema. A migrated-but-incomplete table (snapshot
|
|
11
|
+
// richer than the DB — e.g. a ride-along column the generator once missed)
|
|
12
|
+
// would otherwise surface only as a runtime-500 on the first write.
|
|
9
13
|
//
|
|
10
14
|
// Contract (unchanged from the legacy gate): boot VALIDATES only, never
|
|
11
15
|
// applies. Apply is the deploy-step `kumiko schema apply` (runMigrationsFromDir).
|
|
12
|
-
//
|
|
13
|
-
// Layer 3 (column-diff against the snapshot's ColumnMeta — catches manual
|
|
14
|
-
// ALTERs / stale defs) is a documented follow-up; see
|
|
15
|
-
// docs/plans/migration-system-consolidation.md.
|
|
16
16
|
|
|
17
17
|
import { existsSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import type { DbConnection } from "../db/connection";
|
|
20
|
+
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
20
21
|
import { loadSnapshotJson } from "../db/migrate-generator";
|
|
21
22
|
import { fetchAppliedMigrations, loadMigrationsFromDir } from "../db/migrate-runner";
|
|
22
|
-
import { tableExists } from "../db/schema-inspection";
|
|
23
|
+
import { columnNamesOf, tableExists } from "../db/schema-inspection";
|
|
23
24
|
|
|
24
25
|
const SNAPSHOT_FILENAME = ".snapshot.json";
|
|
25
26
|
|
|
@@ -29,11 +30,17 @@ export type ChecksumMismatch = {
|
|
|
29
30
|
readonly actual: string; // checksum of the file on disk now
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
export type MissingColumns = {
|
|
34
|
+
readonly table: string;
|
|
35
|
+
readonly columns: readonly string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
32
38
|
export type KumikoDriftReport = {
|
|
33
39
|
readonly ok: boolean;
|
|
34
40
|
readonly pending: readonly string[];
|
|
35
41
|
readonly checksumMismatches: readonly ChecksumMismatch[];
|
|
36
42
|
readonly missingTables: readonly string[];
|
|
43
|
+
readonly missingColumns: readonly MissingColumns[];
|
|
37
44
|
};
|
|
38
45
|
|
|
39
46
|
export class SchemaDriftError extends Error {
|
|
@@ -56,7 +63,7 @@ export async function detectKumikoDrift(
|
|
|
56
63
|
// würde loadMigrationsFromDir → readdirSync synchron ENOENT werfen
|
|
57
64
|
// (plain Error, kein SchemaDriftError) und der Boot crasht roh.
|
|
58
65
|
if (!existsSync(migrationsDir)) {
|
|
59
|
-
return { ok: true, pending: [], checksumMismatches: [], missingTables: [] };
|
|
66
|
+
return { ok: true, pending: [], checksumMismatches: [], missingTables: [], missingColumns: [] };
|
|
60
67
|
}
|
|
61
68
|
const local = loadMigrationsFromDir(migrationsDir);
|
|
62
69
|
// Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table fehlt.
|
|
@@ -82,20 +89,38 @@ export async function detectKumikoDrift(
|
|
|
82
89
|
// layer still gates.
|
|
83
90
|
const snapshot = loadSnapshotJson(join(migrationsDir, SNAPSHOT_FILENAME));
|
|
84
91
|
const missingTables: string[] = [];
|
|
92
|
+
const missingColumns: MissingColumns[] = [];
|
|
85
93
|
if (snapshot) {
|
|
86
|
-
const
|
|
87
|
-
snapshot.tables.map((t) =>
|
|
88
|
-
|
|
94
|
+
const existence = await Promise.all(
|
|
95
|
+
snapshot.tables.map((t) => tableExists(db, t.tableName).then((exists) => ({ t, exists }))),
|
|
96
|
+
);
|
|
97
|
+
const present: EntityTableMeta[] = [];
|
|
98
|
+
for (const { t, exists } of existence) {
|
|
99
|
+
if (exists) present.push(t);
|
|
100
|
+
else missingTables.push(t.tableName);
|
|
101
|
+
}
|
|
102
|
+
// Layer 3 — column-diff for tables that DO exist.
|
|
103
|
+
const columnChecks = await Promise.all(
|
|
104
|
+
present.map((t) =>
|
|
105
|
+
columnNamesOf(db, t.tableName).then((live) => ({
|
|
106
|
+
table: t.tableName,
|
|
107
|
+
columns: t.columns.map((c) => c.name).filter((n) => !live.has(n)),
|
|
108
|
+
})),
|
|
89
109
|
),
|
|
90
110
|
);
|
|
91
|
-
for (const c of
|
|
111
|
+
for (const c of columnChecks) if (c.columns.length > 0) missingColumns.push(c);
|
|
92
112
|
}
|
|
93
113
|
|
|
94
114
|
return {
|
|
95
|
-
ok:
|
|
115
|
+
ok:
|
|
116
|
+
pending.length === 0 &&
|
|
117
|
+
checksumMismatches.length === 0 &&
|
|
118
|
+
missingTables.length === 0 &&
|
|
119
|
+
missingColumns.length === 0,
|
|
96
120
|
pending,
|
|
97
121
|
checksumMismatches,
|
|
98
122
|
missingTables,
|
|
123
|
+
missingColumns,
|
|
99
124
|
};
|
|
100
125
|
}
|
|
101
126
|
|
|
@@ -116,6 +141,13 @@ export function formatKumikoDriftReport(report: KumikoDriftReport): string {
|
|
|
116
141
|
lines.push(` ${report.missingTables.length} missing table(s):`);
|
|
117
142
|
for (const t of report.missingTables) lines.push(` - ${t}`);
|
|
118
143
|
}
|
|
144
|
+
if (report.missingColumns.length > 0) {
|
|
145
|
+
const count = report.missingColumns.reduce((n, m) => n + m.columns.length, 0);
|
|
146
|
+
lines.push(` ${count} missing column(s):`);
|
|
147
|
+
for (const m of report.missingColumns) {
|
|
148
|
+
lines.push(` - ${m.table}: ${m.columns.join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
119
151
|
// Per-Cause Remediation — `kumiko schema apply` löst NUR pending. Checksum-
|
|
120
152
|
// mismatch ist eine Sackgasse für apply (MigrationChecksumMismatchError) und
|
|
121
153
|
// baseline (ON CONFLICT DO NOTHING → landet in alreadyTracked). Missing
|
|
@@ -134,6 +166,13 @@ export function formatKumikoDriftReport(report: KumikoDriftReport): string {
|
|
|
134
166
|
lines.push("Missing table(s) without pending migration(s) — table was dropped after apply.");
|
|
135
167
|
lines.push("Restore from backup, or generate a new migration that re-creates the table.");
|
|
136
168
|
}
|
|
169
|
+
if (report.missingColumns.length > 0) {
|
|
170
|
+
lines.push("Missing column(s) — the live table predates a richer snapshot (e.g. a");
|
|
171
|
+
lines.push("ride-along column the generator now emits). Run 'kumiko schema generate' to");
|
|
172
|
+
lines.push(
|
|
173
|
+
"produce an ALTER-TABLE migration for the new column(s), then 'kumiko schema apply'.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
137
176
|
return lines.join("\n");
|
|
138
177
|
}
|
|
139
178
|
|
package/src/schema-cli.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
writeRebuildMarker,
|
|
25
25
|
writeSnapshotJson,
|
|
26
26
|
} from "./db";
|
|
27
|
+
import { createEventsTable } from "./event-store";
|
|
28
|
+
import { createEventConsumerStateTable, createProjectionStateTable } from "./pipeline";
|
|
27
29
|
|
|
28
30
|
export type SchemaCliOut = {
|
|
29
31
|
readonly log: (line: string) => void;
|
|
@@ -143,6 +145,14 @@ export async function runSchemaCli(
|
|
|
143
145
|
const { db, close } = createDbConnection(dbUrl);
|
|
144
146
|
try {
|
|
145
147
|
const result = await runMigrationsFromDir(db, migrationsDir);
|
|
148
|
+
// Framework-Infra-Tabellen (event-store + pipeline-state) — die erfasst
|
|
149
|
+
// `generate` nicht (nur Entity-read-Tabellen). Bestehende DBs haben sie
|
|
150
|
+
// aus dem legacy-drizzle-Fundament; eine Greenfield-DB (erste App ohne
|
|
151
|
+
// Cutover) hätte sonst kein kumiko_events → runProdApp-Boot scheitert.
|
|
152
|
+
// Alle drei sind idempotent (tableExists-Gate), also no-op für Bestands-DBs.
|
|
153
|
+
await createEventsTable(db);
|
|
154
|
+
await createEventConsumerStateTable(db);
|
|
155
|
+
await createProjectionStateTable(db);
|
|
146
156
|
out.log("");
|
|
147
157
|
if (result.applied.length === 0) {
|
|
148
158
|
out.log(` ✓ All ${result.skipped.length} migrations already applied.`);
|