@cosmicdrift/kumiko-framework 0.45.1 → 0.47.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.45.1",
3
+ "version": "0.47.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.40.1",
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"
@@ -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 { buildEntityTable } from "../table-builder";
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 { buildEntityTableMeta, type EntityTableMeta } from "./entity-table-meta";
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 meta = buildEntityTableMeta(name, ent, { relations: feature.relations[name] });
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 dbAny = db as unknown as {
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
- client as {
47
- unsafe: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
48
- }
49
- ).unsafe(sql, params);
50
- return rows[0]?.exists ?? false;
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(entityName: string, definition: EntityDefinition): EntityRef {
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,
@@ -1,5 +1,10 @@
1
+ import { asEntityTableMeta } from "../bun-db/query";
1
2
  import { applyEntityEvent } from "../db/apply-entity-event";
2
- import { resolveTableName } from "../db/entity-table-meta";
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
- const drizzleTable = buildEntityTable(entityName, entity);
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(feature.name, entityName, entity, qualify);
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(name: string, definition: EntityDefinition): EntityRef;
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 checks = await Promise.all(
87
- snapshot.tables.map((t) =>
88
- tableExists(db, t.tableName).then((exists) => ({ name: t.tableName, exists })),
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 checks) if (!c.exists) missingTables.push(c.name);
111
+ for (const c of columnChecks) if (c.columns.length > 0) missingColumns.push(c);
92
112
  }
93
113
 
94
114
  return {
95
- ok: pending.length === 0 && checksumMismatches.length === 0 && missingTables.length === 0,
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