@cosmicdrift/kumiko-framework 0.20.0 → 0.21.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.
@@ -1,160 +0,0 @@
1
- // Snapshot-Diff + Projection-Lookup für die Welle-2-Migration-Pipeline.
2
- //
3
- // Wenn `kumiko migrate generate` ein neues Drizzle-Snapshot-File erzeugt,
4
- // vergleichen wir es mit dem vorherigen. Tabellen die schema-changes
5
- // haben (Spalten dazu/weg, Spalten-Type-Änderung) sind Kandidaten für
6
- // einen Projection-Rebuild — vorausgesetzt sie gehören zu einer
7
- // registrierten Projection.
8
- //
9
- // Der Lookup geht über getTableName(projection.table) — die Drizzle-
10
- // public-API für den physischen Tabellen-Namen einer pgTable-Definition.
11
- // Damit muss niemand Tabellen-Namen doppelt pflegen (Truth liegt in der
12
- // Projection-Definition).
13
-
14
- import type { Registry } from "../engine/types/feature";
15
- import {
16
- type ColumnSpec,
17
- loadJournal,
18
- loadLatestSnapshot,
19
- loadPreviousSnapshot,
20
- type Snapshot,
21
- } from "./schema-drift";
22
-
23
- /** Welche Tabellen haben sich zwischen prev und current geändert?
24
- * Reine Tabellen-Existenz: in current aber nicht in prev → "added".
25
- * Spalten-Veränderungen: identische Tabelle aber Spalten unterscheiden. */
26
- export type ChangedTable = {
27
- readonly fullName: string; // "schema.name" oder einfach "name" wenn empty schema
28
- readonly tableName: string; // nur "name" für tableName-Lookup
29
- readonly kind: "added" | "modified" | "removed";
30
- };
31
-
32
- export function compareSnapshots(
33
- prev: Snapshot | null,
34
- current: Snapshot,
35
- ): readonly ChangedTable[] {
36
- const changes: ChangedTable[] = [];
37
- const prevKeys = new Set(prev ? Object.keys(prev.tables) : []);
38
- const currentKeys = new Set(Object.keys(current.tables));
39
-
40
- for (const key of currentKeys) {
41
- const cur = current.tables[key];
42
- if (!cur) continue;
43
- const fullName = cur.schema && cur.schema.length > 0 ? `${cur.schema}.${cur.name}` : cur.name;
44
- if (!prevKeys.has(key)) {
45
- changes.push({ fullName, tableName: cur.name, kind: "added" });
46
- continue;
47
- }
48
- const prevTable = prev?.tables[key];
49
- if (!prevTable) continue;
50
- if (!sameColumns(prevTable.columns, cur.columns)) {
51
- changes.push({ fullName, tableName: cur.name, kind: "modified" });
52
- }
53
- }
54
-
55
- for (const key of prevKeys) {
56
- if (!currentKeys.has(key)) {
57
- const prevTable = prev?.tables[key];
58
- if (!prevTable) continue;
59
- const fullName =
60
- prevTable.schema && prevTable.schema.length > 0
61
- ? `${prevTable.schema}.${prevTable.name}`
62
- : prevTable.name;
63
- changes.push({ fullName, tableName: prevTable.name, kind: "removed" });
64
- }
65
- }
66
-
67
- return changes;
68
- }
69
-
70
- function sameColumns(
71
- a: Readonly<Record<string, ColumnSpec>>,
72
- b: Readonly<Record<string, ColumnSpec>>,
73
- ): boolean {
74
- const aKeys = Object.keys(a);
75
- const bKeys = Object.keys(b);
76
- if (aKeys.length !== bKeys.length) return false;
77
- for (const key of aKeys) {
78
- const colA = a[key];
79
- const colB = b[key];
80
- if (!colA || !colB) return false;
81
- if (colA.name !== colB.name) return false;
82
- if (colA.type !== colB.type) return false;
83
- if (Boolean(colA.notNull) !== Boolean(colB.notNull)) return false;
84
- if (Boolean(colA.primaryKey) !== Boolean(colB.primaryKey)) return false;
85
- // Default-Vergleich bewusst per JSON — Drizzle serialisiert default-
86
- // expressions als String, das passt für CREATE TABLE-Zwecke.
87
- if (JSON.stringify(colA.default) !== JSON.stringify(colB.default)) return false;
88
- }
89
- return true;
90
- }
91
-
92
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
93
- function getTableName(table: unknown): string {
94
- if (typeof table !== "object" || table === null) {
95
- throw new Error("projection-detection: table is not a pgTable object");
96
- }
97
- const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
98
- if (typeof name !== "string") {
99
- throw new Error("projection-detection: table missing drizzle name symbol");
100
- }
101
- return name;
102
- }
103
-
104
- /** Index `tableName → projection-name` aus der Registry. Nur Projections
105
- * mit table-Definition (single-stream + multi-stream-with-table) zählen.
106
- * Side-effect-only MSPs (table omitted) haben keinen Rebuild-Sinn. */
107
- export function buildProjectionTableIndex(registry: Registry): ReadonlyMap<string, string> {
108
- const index = new Map<string, string>();
109
- for (const [name, def] of registry.getAllProjections()) {
110
- index.set(getTableName(def.table), name);
111
- }
112
- for (const [name, def] of registry.getAllMultiStreamProjections()) {
113
- if (def.table) index.set(getTableName(def.table), name);
114
- }
115
- return index;
116
- }
117
-
118
- /** Aus einer Liste geänderter Tabellen die Projection-Namen extrahieren.
119
- * "removed" ignoriert: gelöschte Projection-Tabellen → die Projection
120
- * ist auch weg, kein Rebuild-Bedarf. "added" wird zurückgegeben — beim
121
- * ersten Migrate aus einer leeren DB sind das keine echten Rebuilds
122
- * (keine historischen Events), aber der Apply-Step filtert das selbst
123
- * über event-count > 0 heraus. */
124
- export function projectionsFromChanges(
125
- changes: readonly ChangedTable[],
126
- index: ReadonlyMap<string, string>,
127
- ): readonly string[] {
128
- const names = new Set<string>();
129
- for (const change of changes) {
130
- if (change.kind === "removed") continue;
131
- const projection = index.get(change.tableName);
132
- if (projection) names.add(projection);
133
- }
134
- return [...names].sort();
135
- }
136
-
137
- /** Convenience: gibt für die letzte Migration zurück welche Projections
138
- * rebuild brauchen würden. Empty wenn das gerade die erste Migration ist
139
- * (kein vorheriger Snapshot, alle Tabellen "added", aber keine Events). */
140
- export function detectProjectionsToRebuild(
141
- registry: Registry,
142
- migrationsDir: string,
143
- ): readonly string[] {
144
- const prev = loadPreviousSnapshot(migrationsDir);
145
- // Initial migration: nur "added"-Changes, keine historischen Events
146
- // zum Replayen → kein Rebuild nötig.
147
- if (prev === null) return [];
148
- const current = loadLatestSnapshot(migrationsDir);
149
- const changes = compareSnapshots(prev, current);
150
- const index = buildProjectionTableIndex(registry);
151
- return projectionsFromChanges(changes, index);
152
- }
153
-
154
- /** Tag des letzten journal-Eintrags — nutzen wir als Marker-File-Name. */
155
- export function latestMigrationTag(migrationsDir: string): string {
156
- const journal = loadJournal(migrationsDir);
157
- const last = journal.entries[journal.entries.length - 1];
158
- if (!last) throw new Error(`latestMigrationTag: empty journal in ${migrationsDir}`);
159
- return last.tag;
160
- }
@@ -1,64 +0,0 @@
1
- // Rebuild-Marker-File: zur generate-Zeit schreibt der Migration-Generator
2
- // ein Side-File `<tag>__rebuild.json` neben das SQL-File. Beim apply liest
3
- // der Apply-Step die Marker für alle neu-applied Migrations und ruft
4
- // rebuildProjection() für jede gelistete Projection.
5
- //
6
- // Format:
7
- // {
8
- // "schemaVersion": 1,
9
- // "migrationTag": "0042_brave_taskmaster",
10
- // "projections": ["publicstatus:projection:incident-state", ...]
11
- // }
12
- //
13
- // Das File wird zum Migration-File committed und durchläuft Code-Review
14
- // — die Projection-Rebuild-Liste ist damit sichtbar im PR.
15
-
16
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
- import { resolve } from "node:path";
18
- import { parseJsonOrThrow } from "../utils/safe-json";
19
-
20
- const MARKER_VERSION = 1 as const;
21
-
22
- export type RebuildMarker = {
23
- readonly schemaVersion: typeof MARKER_VERSION;
24
- readonly migrationTag: string;
25
- readonly projections: readonly string[];
26
- };
27
-
28
- function markerPath(migrationsDir: string, migrationTag: string): string {
29
- return resolve(migrationsDir, `${migrationTag}__rebuild.json`);
30
- }
31
-
32
- export function writeRebuildMarker(
33
- migrationsDir: string,
34
- migrationTag: string,
35
- projections: readonly string[],
36
- ): void {
37
- // skip: leere Projections-Liste → kein Marker-File. Reduziert Noise
38
- // bei Migrations die keine Projection berühren (Infra-Tabellen, pure
39
- // Index-Adds). Caller braucht keinen Confirm — File-Existenz ist die
40
- // Truth-Quelle.
41
- if (projections.length === 0) return;
42
- const marker: RebuildMarker = {
43
- schemaVersion: MARKER_VERSION,
44
- migrationTag,
45
- projections: [...projections].sort(),
46
- };
47
- writeFileSync(markerPath(migrationsDir, migrationTag), `${JSON.stringify(marker, null, 2)}\n`);
48
- }
49
-
50
- export function readRebuildMarker(
51
- migrationsDir: string,
52
- migrationTag: string,
53
- ): RebuildMarker | null {
54
- const path = markerPath(migrationsDir, migrationTag);
55
- if (!existsSync(path)) return null;
56
- const parsed = parseJsonOrThrow<RebuildMarker>(readFileSync(path, "utf-8"), `marker at ${path}`);
57
- if (parsed.schemaVersion !== MARKER_VERSION) {
58
- throw new Error(
59
- `readRebuildMarker: ${path} hat schemaVersion ${parsed.schemaVersion}, ` +
60
- `erwartet ${MARKER_VERSION}. Kumiko-Version-Mismatch?`,
61
- );
62
- }
63
- return parsed;
64
- }
@@ -1,379 +0,0 @@
1
- // Schema-Drift-Detection für den Boot-Gate und die migrate-validate-CLI.
2
- //
3
- // Vergleicht den Drizzle-Migrations-Stand (committed im Repo unter
4
- // drizzle/migrations/meta/) mit dem aktuellen DB-Stand. Drei Schichten:
5
- //
6
- // 1. Journal-vs-Applied: jeder Eintrag im _journal.json muss eine Zeile
7
- // in __drizzle_migrations haben (= migrate apply lief vollständig).
8
- // 2. Tables-Exist: jede Tabelle aus dem letzten Snapshot existiert.
9
- // 3. Column-Diff: information_schema-Vergleich gegen Snapshot —
10
- // missing-/extra-column, type-mismatch, nullability-mismatch. Fängt
11
- // manuelle ALTER TABLEs in Prod sowie doppelte pgTable-Definitionen
12
- // pro Tabelle (eine hand-written, eine via buildEntityTable), die
13
- // stillschweigend gegen den Snapshot driften.
14
- //
15
- // Drizzle-kit's eigene Garantie: nach `migrate apply` ist der DB-Stand
16
- // strukturell identisch mit dem letzten Snapshot. Schicht 3 catched
17
- // alles was diese Garantie nachträglich bricht — schreibender Drittsystem,
18
- // veraltete Code-Definitionen, vergessenes generate.
19
-
20
- import { readFileSync } from "node:fs";
21
- import { resolve } from "node:path";
22
- import type { DbConnection } from "../db/connection";
23
- import { selectAppliedMigrations, selectPublicTableColumns } from "../db/queries/schema-drift";
24
- import { tableExists } from "../db/schema-inspection";
25
- import { parseJsonOrThrow } from "../utils/safe-json";
26
-
27
- // --- Journal & Snapshot Loader ---
28
-
29
- export type JournalEntry = {
30
- readonly idx: number;
31
- readonly version: string;
32
- readonly when: number;
33
- readonly tag: string;
34
- readonly breakpoints: boolean;
35
- };
36
-
37
- export type Journal = {
38
- readonly version: string;
39
- readonly dialect: string;
40
- readonly entries: readonly JournalEntry[];
41
- };
42
-
43
- export function loadJournal(migrationsDir: string): Journal {
44
- const journalPath = resolve(migrationsDir, "meta/_journal.json");
45
- return parseJsonOrThrow<Journal>(readFileSync(journalPath, "utf-8"), `journal at ${journalPath}`);
46
- }
47
-
48
- /** Drizzle-Snapshot-Format. Eine Type für alle Read-Pfade — der
49
- * Boot-Gate liest nur table-name+schema, projection-detection liest
50
- * zusätzlich columns. Optional-typed `columns`-Field hält den Loader
51
- * monomorph ohne zwei verschiedene Snapshot-Types. */
52
- export type ColumnSpec = {
53
- readonly name: string;
54
- readonly type: string;
55
- readonly notNull?: boolean;
56
- readonly primaryKey?: boolean;
57
- readonly default?: unknown;
58
- };
59
-
60
- export type SnapshotTable = {
61
- readonly schema: string;
62
- readonly name: string;
63
- readonly columns: Readonly<Record<string, ColumnSpec>>;
64
- };
65
-
66
- export type Snapshot = {
67
- readonly tables: Readonly<Record<string, SnapshotTable>>;
68
- };
69
-
70
- export function loadSnapshot(snapshotPath: string): Snapshot {
71
- return parseJsonOrThrow<Snapshot>(
72
- readFileSync(snapshotPath, "utf-8"),
73
- `snapshot at ${snapshotPath}`,
74
- );
75
- }
76
-
77
- function snapshotPathForIdx(migrationsDir: string, idx: number): string {
78
- return resolve(migrationsDir, "meta", `${String(idx).padStart(4, "0")}_snapshot.json`);
79
- }
80
-
81
- /** Letzter Snapshot — der Stand der durch das jüngste Migration-File
82
- * beschrieben ist. Wirft wenn das Journal leer ist (App ohne erste
83
- * Migration). */
84
- export function loadLatestSnapshot(migrationsDir: string): Snapshot {
85
- const journal = loadJournal(migrationsDir);
86
- const latest = journal.entries[journal.entries.length - 1];
87
- if (!latest) {
88
- throw new Error(
89
- `loadLatestSnapshot: no entries in ${resolve(migrationsDir, "meta/_journal.json")}. ` +
90
- `Run 'yarn kumiko migrate generate' first.`,
91
- );
92
- }
93
- return loadSnapshot(snapshotPathForIdx(migrationsDir, latest.idx));
94
- }
95
-
96
- /** Vorletzter Snapshot — für Diff-Operationen. Returns null wenn
97
- * weniger als 2 Einträge im Journal (Initial-Migration kann gegen
98
- * nichts diff'en). */
99
- export function loadPreviousSnapshot(migrationsDir: string): Snapshot | null {
100
- const journal = loadJournal(migrationsDir);
101
- if (journal.entries.length < 2) return null;
102
- const previous = journal.entries[journal.entries.length - 2];
103
- if (!previous) return null;
104
- return loadSnapshot(snapshotPathForIdx(migrationsDir, previous.idx));
105
- }
106
-
107
- // --- DB-State Inspector ---
108
-
109
- export type AppliedMigration = {
110
- readonly hash: string;
111
- readonly createdAt: number;
112
- };
113
-
114
- /** Liest die `__drizzle_migrations`-Tabelle. Wenn sie nicht existiert
115
- * (frische DB, niemand hat bisher migrate apply gefahren) → leeres
116
- * Array. Caller soll daraus "alle pending"-Drift ableiten.
117
- *
118
- * Drizzle-kit aktuell speichert in `drizzle.__drizzle_migrations`
119
- * (eigenes Schema), Pre-0.20-Versionen in `public.__drizzle_migrations`.
120
- * Wir prüfen beide Pfade und queryen den vorhandenen — keine
121
- * hardcoded Schema-Annahme. */
122
- export async function loadAppliedMigrations(db: DbConnection): Promise<AppliedMigration[]> {
123
- const drizzleSchemaExists = await tableExists(db, "drizzle.__drizzle_migrations");
124
- const publicSchemaExists = drizzleSchemaExists
125
- ? false
126
- : await tableExists(db, "public.__drizzle_migrations");
127
- if (!drizzleSchemaExists && !publicSchemaExists) return [];
128
- const rows = await selectAppliedMigrations(
129
- db,
130
- drizzleSchemaExists ? "drizzle.__drizzle_migrations" : "public.__drizzle_migrations",
131
- );
132
- return rows.map((r) => ({
133
- hash: r.hash,
134
- createdAt: typeof r.created_at === "bigint" ? Number(r.created_at) : (r.created_at ?? 0),
135
- }));
136
- }
137
-
138
- // --- Column-Diff (Welle 2 Boot-Gate Layer 3) ---
139
-
140
- /** Liest information_schema.columns für eine Tabelle im public-Schema.
141
- * Map by column_name. Default-Werte werden bewusst ausgelassen — die
142
- * drift'en über drizzle-Versionen / PG-Reformulierungen hinweg ohne dass
143
- * sich faktisch was ändert (z.B. `now()` vs `CURRENT_TIMESTAMP`). Type +
144
- * notNull sind die belastbaren Vergleichs-Felder. */
145
- async function loadDbColumns(
146
- db: DbConnection,
147
- tableName: string,
148
- ): Promise<ReadonlyMap<string, { type: string; notNull: boolean }>> {
149
- const rows = await selectPublicTableColumns(db, tableName);
150
- const map = new Map<string, { type: string; notNull: boolean }>();
151
- for (const r of rows) {
152
- map.set(r.column_name, {
153
- type: normalizePgType(r.data_type),
154
- notNull: r.is_nullable === "NO",
155
- });
156
- }
157
- return map;
158
- }
159
-
160
- /** Normalize PG type-Strings auf Drizzle-Snapshot-Konvention. PG meldet
161
- * "timestamp with time zone" für TIMESTAMPTZ, "character varying" für
162
- * VARCHAR — Drizzle schreibt "timestamp with time zone" / "varchar" im
163
- * Snapshot. Wir kollabieren auf einen kanonischen String. */
164
- function normalizePgType(pgType: string): string {
165
- switch (pgType) {
166
- case "timestamp with time zone":
167
- return "timestamp with time zone";
168
- case "character varying":
169
- return "varchar";
170
- case "double precision":
171
- return "double precision";
172
- case "USER-DEFINED":
173
- // Custom-types wie enums — kein clean diff möglich, akzeptieren wir
174
- // als "irgendwas" und überspringen die Type-Prüfung.
175
- return "USER-DEFINED";
176
- default:
177
- return pgType;
178
- }
179
- }
180
-
181
- function normalizeSnapshotType(snapshotType: string): string {
182
- // PostgreSQL meldet im information_schema kanonisierte data_type-Strings,
183
- // Drizzle's snapshot kann mehrere äquivalente Schreibweisen produzieren:
184
- //
185
- // timestamptz → "timestamp with time zone"
186
- // timestamp(3) with time zone → "timestamp with time zone"
187
- // timestamp without time zone → unverändert
188
- // bigserial → "bigint" (serial ist Macro für sequence + bigint)
189
- // serial → "integer"
190
- // smallserial → "smallint"
191
- // varchar(N) → "character varying"
192
- //
193
- // Ohne diese Normalisierung produziert Layer-3 false-positives weil DB
194
- // und Snapshot semantisch dieselbe Spalte unterschiedlich schreiben.
195
- const lower = snapshotType.toLowerCase().replace(/\s+/g, " ").trim();
196
- if (lower === "timestamptz" || lower.match(/^timestamp\(\d+\) with time zone$/)) {
197
- return "timestamp with time zone";
198
- }
199
- if (lower === "bigserial") return "bigint";
200
- if (lower === "serial") return "integer";
201
- if (lower === "smallserial") return "smallint";
202
- if (lower.startsWith("varchar")) return "character varying";
203
- return lower;
204
- }
205
-
206
- /** Eine Differenz zwischen erwarteter (Snapshot) und tatsächlicher (DB)
207
- * Spalten-Definition. */
208
- export type ColumnIssue =
209
- | { readonly kind: "missing-column"; readonly table: string; readonly column: string }
210
- | { readonly kind: "extra-column"; readonly table: string; readonly column: string }
211
- | {
212
- readonly kind: "type-mismatch";
213
- readonly table: string;
214
- readonly column: string;
215
- readonly expected: string;
216
- readonly actual: string;
217
- }
218
- | {
219
- readonly kind: "nullability-mismatch";
220
- readonly table: string;
221
- readonly column: string;
222
- readonly expectedNotNull: boolean;
223
- readonly actualNotNull: boolean;
224
- };
225
-
226
- async function detectColumnIssues(
227
- db: DbConnection,
228
- snapshot: Snapshot,
229
- existingTables: readonly string[],
230
- ): Promise<readonly ColumnIssue[]> {
231
- const issues: ColumnIssue[] = [];
232
- const existingSet = new Set(existingTables);
233
- for (const t of Object.values(snapshot.tables)) {
234
- const fullName = t.schema && t.schema.length > 0 ? `${t.schema}.${t.name}` : t.name;
235
- if (!existingSet.has(fullName)) continue; // missing-table-Layer hat das schon
236
- const dbCols = await loadDbColumns(db, t.name);
237
- const snapCols = t.columns;
238
- // Spalten die im Snapshot stehen, aber nicht in der DB sind.
239
- for (const snapCol of Object.values(snapCols)) {
240
- const dbCol = dbCols.get(snapCol.name);
241
- if (!dbCol) {
242
- issues.push({ kind: "missing-column", table: t.name, column: snapCol.name });
243
- continue;
244
- }
245
- const expectedType = normalizeSnapshotType(snapCol.type);
246
- // USER-DEFINED ist die PG-Antwort für enums — type-Vergleich wäre
247
- // unzuverlässig (PG meldet keinen Enum-Namen über data_type). Skip.
248
- if (dbCol.type !== "USER-DEFINED" && dbCol.type !== expectedType) {
249
- issues.push({
250
- kind: "type-mismatch",
251
- table: t.name,
252
- column: snapCol.name,
253
- expected: expectedType,
254
- actual: dbCol.type,
255
- });
256
- }
257
- const expectedNotNull = snapCol.notNull === true || snapCol.primaryKey === true;
258
- if (dbCol.notNull !== expectedNotNull) {
259
- issues.push({
260
- kind: "nullability-mismatch",
261
- table: t.name,
262
- column: snapCol.name,
263
- expectedNotNull,
264
- actualNotNull: dbCol.notNull,
265
- });
266
- }
267
- }
268
- // Spalten die in der DB sind, aber nicht im Snapshot — vermutlich
269
- // manueller ALTER TABLE in Prod. Reportet als extra-column.
270
- const snapDbNames = new Set(Object.values(snapCols).map((c) => c.name));
271
- for (const dbColName of dbCols.keys()) {
272
- if (!snapDbNames.has(dbColName)) {
273
- issues.push({ kind: "extra-column", table: t.name, column: dbColName });
274
- }
275
- }
276
- }
277
- return issues;
278
- }
279
-
280
- // --- Drift Report ---
281
-
282
- export type DriftReport = {
283
- readonly ok: boolean;
284
- readonly pendingMigrations: readonly JournalEntry[];
285
- readonly missingTables: readonly string[];
286
- readonly columnIssues: readonly ColumnIssue[];
287
- };
288
-
289
- export async function detectDrift(db: DbConnection, migrationsDir: string): Promise<DriftReport> {
290
- const journal = loadJournal(migrationsDir);
291
- const applied = await loadAppliedMigrations(db);
292
-
293
- // Heuristik: Drizzle's `__drizzle_migrations` enthält keine Reihenfolge-
294
- // Information die direkt zu journal.tag matched. Praktisch: nach jeder
295
- // erfolgreichen `migrate apply` ist applied.length === entries.length.
296
- // Wenn Count abweicht → pending.
297
- const pendingMigrations =
298
- applied.length < journal.entries.length ? journal.entries.slice(applied.length) : [];
299
-
300
- const snapshot = loadLatestSnapshot(migrationsDir);
301
- // Drizzle's snapshot schreibt `schema: ""` für public — to_regclass
302
- // ohne Schema-Prefix resolved ebenfalls in public, also passt empty.
303
- const expectedTables = Object.values(snapshot.tables).map((t) =>
304
- t.schema && t.schema.length > 0 ? `${t.schema}.${t.name}` : t.name,
305
- );
306
- const exists = await Promise.all(expectedTables.map((q) => tableExists(db, q)));
307
- const missingTables = expectedTables.filter((_, i) => !exists[i]);
308
- const existingTables = expectedTables.filter((_, i) => exists[i]);
309
-
310
- // Layer 3: Column-Diff für die Tables die existieren. Pending Migrations
311
- // skippen wir — die DB ist ohnehin in einem Zwischenzustand.
312
- const columnIssues =
313
- pendingMigrations.length === 0 ? await detectColumnIssues(db, snapshot, existingTables) : [];
314
-
315
- return {
316
- ok: pendingMigrations.length === 0 && missingTables.length === 0 && columnIssues.length === 0,
317
- pendingMigrations,
318
- missingTables,
319
- columnIssues,
320
- };
321
- }
322
-
323
- export function formatDriftReport(report: DriftReport): string {
324
- if (report.ok) return "Schema is current.";
325
- const lines: string[] = ["Schema drift detected:"];
326
- if (report.pendingMigrations.length > 0) {
327
- lines.push(` ${report.pendingMigrations.length} unapplied migration(s):`);
328
- for (const m of report.pendingMigrations) {
329
- lines.push(` - ${m.tag}`);
330
- }
331
- }
332
- if (report.missingTables.length > 0) {
333
- lines.push(` ${report.missingTables.length} missing table(s):`);
334
- for (const t of report.missingTables) {
335
- lines.push(` - ${t}`);
336
- }
337
- }
338
- if (report.columnIssues.length > 0) {
339
- lines.push(` ${report.columnIssues.length} column issue(s):`);
340
- for (const issue of report.columnIssues) {
341
- switch (issue.kind) {
342
- case "missing-column":
343
- lines.push(` - ${issue.table}.${issue.column}: missing in DB`);
344
- break;
345
- case "extra-column":
346
- lines.push(` - ${issue.table}.${issue.column}: not in snapshot`);
347
- break;
348
- case "type-mismatch":
349
- lines.push(
350
- ` - ${issue.table}.${issue.column}: type ${issue.actual} (expected ${issue.expected})`,
351
- );
352
- break;
353
- case "nullability-mismatch":
354
- lines.push(
355
- ` - ${issue.table}.${issue.column}: nullable=${!issue.actualNotNull} (expected nullable=${!issue.expectedNotNull})`,
356
- );
357
- break;
358
- }
359
- }
360
- }
361
- lines.push("");
362
- lines.push("Run 'yarn kumiko migrate apply' to bring the DB up-to-date.");
363
- return lines.join("\n");
364
- }
365
-
366
- /** Throws SchemaDriftError mit human-readable message wenn Drift. */
367
- export async function assertSchemaCurrent(db: DbConnection, migrationsDir: string): Promise<void> {
368
- const report = await detectDrift(db, migrationsDir);
369
- if (!report.ok) throw new SchemaDriftError(formatDriftReport(report), report);
370
- }
371
-
372
- export class SchemaDriftError extends Error {
373
- readonly report: DriftReport;
374
- constructor(message: string, report: DriftReport) {
375
- super(message);
376
- this.name = "SchemaDriftError";
377
- this.report = report;
378
- }
379
- }