@cosmicdrift/kumiko-framework 0.19.1 → 0.21.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/LICENSE +1 -1
- package/package.json +2 -2
- package/src/compliance/sub-processors.ts +1 -1
- package/src/db/__tests__/rebuild-marker.test.ts +86 -0
- package/src/db/index.ts +5 -0
- package/src/db/rebuild-marker.ts +75 -0
- package/src/engine/types/handlers.ts +1 -1
- package/src/errors/__tests__/classes.test.ts +5 -5
- package/src/errors/kumiko-error.ts +1 -1
- package/src/migrations/index.ts +2 -29
- package/src/migrations/projection-table-index.ts +35 -0
- package/src/pipeline/msp-rebuild.ts +1 -1
- package/src/schema-cli.ts +13 -0
- package/src/db/queries/schema-drift.ts +0 -35
- package/src/migrations/__tests__/compare-snapshots.test.ts +0 -150
- package/src/migrations/__tests__/detect-drift.integration.test.ts +0 -328
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.test.ts +0 -134
- package/src/migrations/__tests__/rebuild-marker.test.ts +0 -79
- package/src/migrations/projection-detection.ts +0 -160
- package/src/migrations/rebuild-marker.ts +0 -64
- package/src/migrations/schema-drift.ts +0 -379
|
@@ -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
|
-
}
|