@cosmicdrift/kumiko-framework 0.16.0 → 0.19.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.
@@ -25,9 +25,11 @@ export async function createIndexIfNotExists(
25
25
  indexName: string,
26
26
  tableName: string,
27
27
  columnList: string,
28
+ whereSql?: string,
28
29
  ): Promise<void> {
30
+ const where = whereSql !== undefined ? ` WHERE ${whereSql}` : "";
29
31
  await asRawClient(db).unsafe(
30
- `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})`,
32
+ `CREATE ${indexKind} IF NOT EXISTS ${quoteTableIdent(indexName)} ON ${quoteTableIdent(tableName)} (${columnList})${where}`,
31
33
  );
32
34
  }
33
35
 
@@ -154,7 +154,7 @@ export { resolveConfigOrParam } from "./resolve-config-or-param";
154
154
  export { runsInLane } from "./run-in";
155
155
  export type { StepListOutcome } from "./run-pipeline";
156
156
  export { runPipeline, runStepList } from "./run-pipeline";
157
- export { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
157
+ export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-builder";
158
158
  export type { TransitionGraph } from "./state-machine";
159
159
  export { defineTransitions, guardTransition } from "./state-machine";
160
160
  export {
@@ -18,7 +18,7 @@ function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
18
18
  }
19
19
  }
20
20
 
21
- function fieldToZod(field: FieldDefinition, currencies: readonly string[]): z.ZodTypeAny {
21
+ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]): z.ZodTypeAny {
22
22
  switch (field.type) {
23
23
  case "text": {
24
24
  let schema = z.string();
@@ -11,10 +11,11 @@
11
11
 
12
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
13
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
14
+ import { insertMany } from "../../bun-db/query";
14
15
  import type { TenantId } from "../../engine/types";
15
16
  import { ensureTemporalPolyfill } from "../../time/polyfill";
16
17
  import { generateId as uuid } from "../../utils";
17
- import { append, createEventsTable, getStreamVersion } from "../index";
18
+ import { createEventsTable, eventsTable, getStreamVersion } from "../index";
18
19
 
19
20
  let testDb: BunTestDb;
20
21
  const tenantId: TenantId = uuid();
@@ -31,17 +32,19 @@ afterAll(async () => {
31
32
  });
32
33
 
33
34
  async function seedStream(aggregateId: string, count: number): Promise<void> {
34
- for (let v = 0; v < count; v++) {
35
- await append(testDb.db, {
36
- aggregateId,
37
- aggregateType: "perfAgg",
38
- tenantId,
39
- expectedVersion: v,
40
- type: "perfAgg.created",
41
- payload: { seq: v },
42
- metadata: { userId },
43
- });
44
- }
35
+ // Bulk-seed 2000 sequential append() calls dominate runtime and flake
36
+ // under load. We measure getStreamVersion(), not append latency.
37
+ const rows = Array.from({ length: count }, (_, i) => ({
38
+ aggregateId,
39
+ aggregateType: "perfAgg",
40
+ tenantId,
41
+ version: i + 1,
42
+ type: "perfAgg.created",
43
+ payload: { seq: i },
44
+ metadata: { userId },
45
+ createdBy: userId,
46
+ }));
47
+ await insertMany(testDb.db, eventsTable, rows);
45
48
  }
46
49
 
47
50
  describe("event-store: getStreamVersion perf on hot streams", () => {
@@ -17,6 +17,7 @@ import {
17
17
  insertRawSubsequentEvent,
18
18
  } from "../db/queries/event-store-admin";
19
19
  import type { TenantId } from "../engine/types";
20
+ import { stringifyJson } from "../utils/safe-json";
20
21
  import { VersionConflictError } from "./errors";
21
22
  import type { EventMetadata } from "./event-store";
22
23
 
@@ -68,8 +69,8 @@ function rawEventParams(event: RawEventToAppend, newVersion: number, eventVersio
68
69
  newVersion,
69
70
  type: event.type,
70
71
  eventVersion,
71
- payloadJson: JSON.stringify(event.payload),
72
- metadataJson: JSON.stringify(event.metadata),
72
+ payloadJson: stringifyJson(event.payload),
73
+ metadataJson: stringifyJson(event.metadata),
73
74
  createdAt: event.createdAt.toString(),
74
75
  createdBy: event.createdBy,
75
76
  };
@@ -129,8 +130,8 @@ export async function appendRawBatch(
129
130
  newVersion,
130
131
  e.type,
131
132
  eventVersion,
132
- JSON.stringify(e.payload),
133
- JSON.stringify(e.metadata),
133
+ stringifyJson(e.payload),
134
+ stringifyJson(e.metadata),
134
135
  e.createdAt.toString(),
135
136
  e.createdBy,
136
137
  );
@@ -9,6 +9,7 @@ import {
9
9
  } from "../db/queries/event-store";
10
10
  import { insertOne, selectMany } from "../db/query";
11
11
  import type { TenantId } from "../engine/types";
12
+ import { stringifyJson } from "../utils/safe-json";
12
13
  import { isStreamArchived } from "./archive";
13
14
  import { VersionConflictError } from "./errors";
14
15
  import { eventsTable } from "./events-schema";
@@ -173,8 +174,8 @@ async function insertSubsequentEvent(
173
174
  newVersion,
174
175
  type: event.type,
175
176
  eventVersion,
176
- payloadJson: JSON.stringify(event.payload),
177
- metadataJson: JSON.stringify(event.metadata),
177
+ payloadJson: stringifyJson(event.payload),
178
+ metadataJson: stringifyJson(event.metadata),
178
179
  createdBy: event.metadata.userId,
179
180
  expectedVersion: event.expectedVersion,
180
181
  });
@@ -17,6 +17,7 @@ import { selectMany } from "../db/query";
17
17
  import { tableExists } from "../db/schema-inspection";
18
18
  import type { TenantId } from "../engine/types";
19
19
  import { unsafePushTables } from "../stack";
20
+ import { stringifyJson } from "../utils/safe-json";
20
21
  import { isStreamArchived } from "./archive";
21
22
  import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
22
23
 
@@ -107,7 +108,7 @@ export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promis
107
108
  tenantId: args.tenantId,
108
109
  aggregateType: args.aggregateType,
109
110
  version: args.version,
110
- stateJson: JSON.stringify(args.state),
111
+ stateJson: stringifyJson(args.state),
111
112
  });
112
113
  }
113
114
 
@@ -0,0 +1,161 @@
1
+ // Integration-Test für das drizzle-freie Boot-Gate (detectKumikoDrift /
2
+ // assertKumikoSchemaCurrent). Production-Behavior: dieses Gate blockiert
3
+ // Container-Starts — jeder False-Positive blockt Boot, jeder False-Negative
4
+ // lässt Schema-Drift durch.
5
+
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
11
+ import { buildEntityTableMeta } from "../../db/entity-table-meta";
12
+ import { generateMigration, writeSnapshotJson } from "../../db/migrate-generator";
13
+ import {
14
+ baselineMigrations,
15
+ loadMigrationsFromDir,
16
+ runMigrationsFromDir,
17
+ } from "../../db/migrate-runner";
18
+ import { asRawClient } from "../../db/query";
19
+ import { createEntity, createTextField } from "../../engine";
20
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
21
+ import { assertKumikoSchemaCurrent, detectKumikoDrift, SchemaDriftError } from "../kumiko-drift";
22
+
23
+ let testDb: BunTestDb;
24
+ let dir: string;
25
+
26
+ beforeAll(async () => {
27
+ await ensureTemporalPolyfill();
28
+ testDb = await createTestDb();
29
+ });
30
+
31
+ afterAll(async () => {
32
+ await testDb.cleanup();
33
+ });
34
+
35
+ beforeEach(async () => {
36
+ dir = mkdtempSync(join(tmpdir(), "kumiko-mig-"));
37
+ // Isoliere: tracking-table + Test-Tabellen pro Test zurücksetzen.
38
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
39
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_widget"`);
40
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_gen"`);
41
+ });
42
+
43
+ afterEach(() => {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ function writeMigration(file: string, sql: string): void {
48
+ writeFileSync(join(dir, file), sql);
49
+ }
50
+
51
+ function writeSnapshot(tableNames: readonly string[]): void {
52
+ const tables = tableNames.map((tableName) => ({ tableName, columns: [] }));
53
+ writeFileSync(join(dir, ".snapshot.json"), JSON.stringify({ version: 1, tables }));
54
+ }
55
+
56
+ describe("kumiko-drift boot-gate", () => {
57
+ test("applied + table exists → ok", async () => {
58
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
59
+ writeSnapshot(["kdrift_widget"]);
60
+ await runMigrationsFromDir(testDb.db, dir);
61
+
62
+ const report = await detectKumikoDrift(testDb.db, dir);
63
+ expect(report.ok).toBe(true);
64
+ await expect(assertKumikoSchemaCurrent(testDb.db, dir)).resolves.toBeUndefined();
65
+ });
66
+
67
+ test("checked-in migration not applied → pending drift", async () => {
68
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
69
+ writeSnapshot(["kdrift_widget"]);
70
+ // NICHT applyen.
71
+ const report = await detectKumikoDrift(testDb.db, dir);
72
+ expect(report.ok).toBe(false);
73
+ expect(report.pending).toEqual(["0001_init"]);
74
+ await expect(assertKumikoSchemaCurrent(testDb.db, dir)).rejects.toBeInstanceOf(
75
+ SchemaDriftError,
76
+ );
77
+ });
78
+
79
+ test("applied migration edited afterwards → checksum mismatch", async () => {
80
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
81
+ writeSnapshot(["kdrift_widget"]);
82
+ await runMigrationsFromDir(testDb.db, dir);
83
+
84
+ // File nachträglich editieren (anderer Inhalt → andere checksum).
85
+ writeMigration(
86
+ "0001_init.sql",
87
+ `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY, "x" int);`,
88
+ );
89
+ const report = await detectKumikoDrift(testDb.db, dir);
90
+ expect(report.ok).toBe(false);
91
+ expect(report.checksumMismatches.map((m) => m.id)).toEqual(["0001_init"]);
92
+ });
93
+
94
+ test("snapshot table missing in DB → missingTables", async () => {
95
+ writeMigration("0001_init.sql", `SELECT 1;`); // applied, aber legt die Tabelle NICHT an
96
+ writeSnapshot(["kdrift_widget"]);
97
+ await runMigrationsFromDir(testDb.db, dir);
98
+
99
+ const report = await detectKumikoDrift(testDb.db, dir);
100
+ expect(report.ok).toBe(false);
101
+ expect(report.missingTables).toEqual(["kdrift_widget"]);
102
+ });
103
+
104
+ test("baseline marks migrations applied without running SQL", async () => {
105
+ // Tabelle existiert schon (wie eine adoptierte Prod-DB), Migration NICHT applyen.
106
+ await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY)`);
107
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
108
+ writeSnapshot(["kdrift_widget"]);
109
+
110
+ const result = await baselineMigrations(testDb.db, loadMigrationsFromDir(dir));
111
+ expect(result.marked).toEqual(["0001_init"]);
112
+
113
+ // Danach drift-frei (applied via baseline, Tabelle existiert), und re-baseline ist no-op.
114
+ const report = await detectKumikoDrift(testDb.db, dir);
115
+ expect(report.ok).toBe(true);
116
+ const again = await baselineMigrations(testDb.db, loadMigrationsFromDir(dir));
117
+ expect(again.marked).toEqual([]);
118
+ expect(again.alreadyTracked).toEqual(["0001_init"]);
119
+ });
120
+ });
121
+
122
+ describe("kumiko-drift end-to-end (generate → apply → gate)", () => {
123
+ test("generate from entity metas → apply → gate ok (the local-verify proof)", async () => {
124
+ const entity = createEntity({
125
+ table: "kdrift_gen",
126
+ fields: { name: createTextField({ required: true }) },
127
+ });
128
+ const meta = buildEntityTableMeta("kdriftGen", entity);
129
+ const result = generateMigration({
130
+ metas: [meta],
131
+ prevSnapshot: null,
132
+ name: "init",
133
+ sequenceNumber: 1,
134
+ });
135
+
136
+ writeFileSync(join(dir, result.filename), result.sqlContent);
137
+ writeSnapshotJson(join(dir, ".snapshot.json"), result.snapshot);
138
+
139
+ await runMigrationsFromDir(testDb.db, dir);
140
+ const report = await detectKumikoDrift(testDb.db, dir);
141
+ expect(report.ok).toBe(true);
142
+ });
143
+
144
+ test("prod adoption via commented-out SQL: apply is a recorded no-op, gate ok when tables pre-exist", async () => {
145
+ // Prod-Szenario: Tabelle existiert schon (drizzle-Ära). Das Migration-File
146
+ // ist auskommentiert → apply legt nichts an, RECORDED aber den Eintrag in
147
+ // _kumiko_migrations. Gate: applied ✓ + Tabelle existiert ✓ → Boot läuft.
148
+ await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdrift_gen" ("id" text PRIMARY KEY)`);
149
+ writeMigration(
150
+ "0001_init.sql",
151
+ `-- CREATE TABLE "kdrift_gen" ("id" text PRIMARY KEY); -- commented for prod adoption`,
152
+ );
153
+ writeSnapshot(["kdrift_gen"]);
154
+
155
+ const applyResult = await runMigrationsFromDir(testDb.db, dir);
156
+ expect(applyResult.applied).toEqual(["0001_init"]); // recorded trotz no-op-SQL
157
+
158
+ const report = await detectKumikoDrift(testDb.db, dir);
159
+ expect(report.ok).toBe(true);
160
+ });
161
+ });
@@ -1,3 +1,14 @@
1
+ // Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
2
+ // `SchemaDriftError` is re-exported from here; the legacy drizzle gate above
3
+ // keeps its own internal error until Phase 3 removes schema-drift.ts.
4
+ export {
5
+ assertKumikoSchemaCurrent,
6
+ type ChecksumMismatch,
7
+ detectKumikoDrift,
8
+ formatKumikoDriftReport,
9
+ type KumikoDriftReport,
10
+ SchemaDriftError,
11
+ } from "./kumiko-drift";
1
12
  export {
2
13
  buildProjectionTableIndex,
3
14
  type ChangedTable,
@@ -22,7 +33,6 @@ export {
22
33
  loadLatestSnapshot,
23
34
  loadPreviousSnapshot,
24
35
  loadSnapshot,
25
- SchemaDriftError,
26
36
  type Snapshot,
27
37
  type SnapshotTable,
28
38
  } from "./schema-drift";
@@ -0,0 +1,122 @@
1
+ // Drizzle-free schema-drift gate for the `kumiko/migrations` system.
2
+ //
3
+ // Replaces the drizzle-journal gate (schema-drift.ts). Validates two layers
4
+ // against the checked-in artifacts:
5
+ //
6
+ // 1. Migrations applied: every `kumiko/migrations/*.sql` has a row in
7
+ // `_kumiko_migrations`. Applied-but-edited (checksum mismatch) is drift.
8
+ // 2. Tables exist: every table in `kumiko/migrations/.snapshot.json` exists.
9
+ //
10
+ // Contract (unchanged from the legacy gate): boot VALIDATES only, never
11
+ // 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
+
17
+ import { join } from "node:path";
18
+ import type { DbConnection } from "../db/connection";
19
+ import { loadSnapshotJson } from "../db/migrate-generator";
20
+ import { fetchAppliedMigrations, loadMigrationsFromDir } from "../db/migrate-runner";
21
+ import { tableExists } from "../db/schema-inspection";
22
+
23
+ const SNAPSHOT_FILENAME = ".snapshot.json";
24
+
25
+ export type ChecksumMismatch = {
26
+ readonly id: string;
27
+ readonly expected: string; // checksum recorded in _kumiko_migrations
28
+ readonly actual: string; // checksum of the file on disk now
29
+ };
30
+
31
+ export type KumikoDriftReport = {
32
+ readonly ok: boolean;
33
+ readonly pending: readonly string[];
34
+ readonly checksumMismatches: readonly ChecksumMismatch[];
35
+ readonly missingTables: readonly string[];
36
+ };
37
+
38
+ export class SchemaDriftError extends Error {
39
+ readonly report: KumikoDriftReport;
40
+ constructor(message: string, report: KumikoDriftReport) {
41
+ super(message);
42
+ this.name = "SchemaDriftError";
43
+ this.report = report;
44
+ }
45
+ }
46
+
47
+ export async function detectKumikoDrift(
48
+ db: DbConnection,
49
+ migrationsDir: string,
50
+ ): Promise<KumikoDriftReport> {
51
+ const local = loadMigrationsFromDir(migrationsDir);
52
+ // Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table fehlt.
53
+ // Das ist kein Fehler, sondern "nichts applied" → alle local sind pending.
54
+ const trackingExists = await tableExists(db, "_kumiko_migrations");
55
+ const applied = trackingExists
56
+ ? new Map((await fetchAppliedMigrations(db)).map((a) => [a.id, a.checksum] as const))
57
+ : new Map<string, string>();
58
+
59
+ const pending: string[] = [];
60
+ const checksumMismatches: ChecksumMismatch[] = [];
61
+ for (const m of local) {
62
+ const appliedChecksum = applied.get(m.id);
63
+ if (appliedChecksum === undefined) {
64
+ pending.push(m.id);
65
+ } else if (appliedChecksum !== m.checksum) {
66
+ checksumMismatches.push({ id: m.id, expected: appliedChecksum, actual: m.checksum });
67
+ }
68
+ }
69
+
70
+ // Layer 2 — tables from the latest snapshot must exist. No snapshot (app
71
+ // hasn't generated one yet) → skip table-existence, the migrations-applied
72
+ // layer still gates.
73
+ const snapshot = loadSnapshotJson(join(migrationsDir, SNAPSHOT_FILENAME));
74
+ const missingTables: string[] = [];
75
+ if (snapshot) {
76
+ const checks = await Promise.all(
77
+ snapshot.tables.map((t) =>
78
+ tableExists(db, t.tableName).then((exists) => ({ name: t.tableName, exists })),
79
+ ),
80
+ );
81
+ for (const c of checks) if (!c.exists) missingTables.push(c.name);
82
+ }
83
+
84
+ return {
85
+ ok: pending.length === 0 && checksumMismatches.length === 0 && missingTables.length === 0,
86
+ pending,
87
+ checksumMismatches,
88
+ missingTables,
89
+ };
90
+ }
91
+
92
+ export function formatKumikoDriftReport(report: KumikoDriftReport): string {
93
+ if (report.ok) return "Schema is current.";
94
+ const lines: string[] = ["Schema drift detected:"];
95
+ if (report.pending.length > 0) {
96
+ lines.push(` ${report.pending.length} unapplied migration(s):`);
97
+ for (const id of report.pending) lines.push(` - ${id}`);
98
+ }
99
+ if (report.checksumMismatches.length > 0) {
100
+ lines.push(` ${report.checksumMismatches.length} edited-after-apply migration(s):`);
101
+ for (const m of report.checksumMismatches) {
102
+ lines.push(` - ${m.id}: db ${m.expected.slice(0, 12)}…, file ${m.actual.slice(0, 12)}…`);
103
+ }
104
+ }
105
+ if (report.missingTables.length > 0) {
106
+ lines.push(` ${report.missingTables.length} missing table(s):`);
107
+ for (const t of report.missingTables) lines.push(` - ${t}`);
108
+ }
109
+ lines.push("");
110
+ lines.push("Run 'kumiko schema apply' to bring the DB up-to-date.");
111
+ return lines.join("\n");
112
+ }
113
+
114
+ /** Throws SchemaDriftError with a human-readable message when the DB is not
115
+ * current with the checked-in kumiko/migrations. */
116
+ export async function assertKumikoSchemaCurrent(
117
+ db: DbConnection,
118
+ migrationsDir: string,
119
+ ): Promise<void> {
120
+ const report = await detectKumikoDrift(db, migrationsDir);
121
+ if (!report.ok) throw new SchemaDriftError(formatKumikoDriftReport(report), report);
122
+ }
@@ -0,0 +1,228 @@
1
+ // Shared core for the schema-migration CLI (generate | apply | baseline | status).
2
+ //
3
+ // Used by BOTH the dev `kumiko schema` command (bin/commands/schema.ts) and the
4
+ // shipped `kumiko-schema` bin (dev-server) — so apps run migrations without the
5
+ // full dev-CLI registry (which eager-loads ts-morph-heavy dev commands).
6
+ //
7
+ // NO-MAGIC-ON-DATA: reads only checked-in artifacts (kumiko/schema.ts →
8
+ // ENTITY_METAS, kumiko/migrations/*.sql). Never auto-generates at runtime,
9
+ // never applies on app-boot — apply/baseline are explicit deploy-steps.
10
+
11
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
12
+ import { join, resolve as resolvePath } from "node:path";
13
+ import {
14
+ baselineMigrations,
15
+ createDbConnection,
16
+ fetchAppliedMigrations,
17
+ generateMigration,
18
+ loadMigrationsFromDir,
19
+ loadSnapshotJson,
20
+ type renderTablesDdl,
21
+ runMigrationsFromDir,
22
+ writeSnapshotJson,
23
+ } from "./db";
24
+
25
+ export type SchemaCliOut = {
26
+ readonly log: (line: string) => void;
27
+ readonly err: (line: string) => void;
28
+ };
29
+
30
+ const SNAPSHOT_FILENAME = ".snapshot.json";
31
+
32
+ async function loadEntityMetasFromApp(
33
+ schemaFile: string,
34
+ ): Promise<Parameters<typeof renderTablesDdl>[0]> {
35
+ // bun imports TS directly — no spawn needed.
36
+ const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown };
37
+ if (!Array.isArray(mod.ENTITY_METAS)) {
38
+ throw new Error(
39
+ `Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
40
+ );
41
+ }
42
+ return mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
43
+ }
44
+
45
+ function nextSequenceNumber(migrationsDir: string): number {
46
+ if (!existsSync(migrationsDir)) return 1;
47
+ const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
48
+ let max = 0;
49
+ for (const f of files) {
50
+ const m = f.match(/^(\d+)_/);
51
+ if (m) {
52
+ const n = Number(m[1]);
53
+ if (n > max) max = n;
54
+ }
55
+ }
56
+ return max + 1;
57
+ }
58
+
59
+ /**
60
+ * Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
61
+ * `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
62
+ */
63
+ export async function runSchemaCli(
64
+ argv: readonly string[],
65
+ appCwd: string,
66
+ out: SchemaCliOut,
67
+ ): Promise<number> {
68
+ const sub = argv[0];
69
+ const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
70
+ const migrationsDir = resolvePath(appCwd, "kumiko/migrations");
71
+
72
+ switch (sub) {
73
+ case "generate": {
74
+ const name = argv[1];
75
+ if (!name) {
76
+ out.err(" Usage: kumiko-schema generate <name>");
77
+ return 1;
78
+ }
79
+ if (!existsSync(schemaFile)) {
80
+ out.err(` ${schemaFile} fehlt.`);
81
+ out.err(" App-Convention: kumiko/schema.ts mit");
82
+ out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
83
+ return 1;
84
+ }
85
+ const metas = await loadEntityMetasFromApp(schemaFile);
86
+ const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
87
+ const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
88
+ const result = generateMigration({
89
+ metas,
90
+ prevSnapshot,
91
+ name,
92
+ sequenceNumber: nextSequenceNumber(migrationsDir),
93
+ });
94
+
95
+ const isEmpty =
96
+ result.diff.newTables.length === 0 &&
97
+ result.diff.changedTables.length === 0 &&
98
+ result.diff.droppedTables.length === 0;
99
+ if (isEmpty) {
100
+ out.log(" No schema changes detected — kein neues Migration-File geschrieben.");
101
+ return 0;
102
+ }
103
+
104
+ if (!existsSync(migrationsDir)) mkdirSync(migrationsDir, { recursive: true });
105
+ writeFileSync(join(migrationsDir, result.filename), result.sqlContent);
106
+ writeSnapshotJson(snapshotPath, result.snapshot);
107
+
108
+ out.log("");
109
+ out.log(` ✓ ${result.filename}`);
110
+ out.log(
111
+ ` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}`,
112
+ );
113
+ out.log("");
114
+ out.log(" Review + ggf. hand-edit + git add + commit. Apply via: kumiko-schema apply");
115
+ out.log("");
116
+ return 0;
117
+ }
118
+
119
+ case "apply": {
120
+ const dbUrl = process.env["DATABASE_URL"];
121
+ if (!dbUrl) {
122
+ out.err(" DATABASE_URL not set.");
123
+ return 1;
124
+ }
125
+ if (!existsSync(migrationsDir)) {
126
+ out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
127
+ return 1;
128
+ }
129
+ const { db, close } = createDbConnection(dbUrl);
130
+ try {
131
+ const result = await runMigrationsFromDir(db, migrationsDir);
132
+ out.log("");
133
+ if (result.applied.length === 0) {
134
+ out.log(` ✓ All ${result.skipped.length} migrations already applied.`);
135
+ } else {
136
+ out.log(` ✓ Applied ${result.applied.length}:`);
137
+ for (const id of result.applied) out.log(` + ${id}`);
138
+ if (result.skipped.length > 0) out.log(` (${result.skipped.length} already applied)`);
139
+ }
140
+ out.log("");
141
+ return 0;
142
+ } catch (e) {
143
+ out.err("");
144
+ out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
145
+ out.err("");
146
+ return 1;
147
+ } finally {
148
+ await close();
149
+ }
150
+ }
151
+
152
+ case "baseline": {
153
+ // Adopt an existing DB: mark all checked-in migrations as applied WITHOUT
154
+ // running their SQL (prod tables already exist — cutover from the legacy
155
+ // drizzle system). Afterwards the boot-gate is drift-free.
156
+ const dbUrl = process.env["DATABASE_URL"];
157
+ if (!dbUrl) {
158
+ out.err(" DATABASE_URL not set.");
159
+ return 1;
160
+ }
161
+ if (!existsSync(migrationsDir)) {
162
+ out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
163
+ return 1;
164
+ }
165
+ const { db, close } = createDbConnection(dbUrl);
166
+ try {
167
+ const result = await baselineMigrations(db, loadMigrationsFromDir(migrationsDir));
168
+ out.log("");
169
+ out.log(` ✓ Marked ${result.marked.length} migration(s) as applied (no SQL run):`);
170
+ for (const id of result.marked) out.log(` + ${id}`);
171
+ if (result.alreadyTracked.length > 0) {
172
+ out.log(` (${result.alreadyTracked.length} already tracked)`);
173
+ }
174
+ out.log("");
175
+ return 0;
176
+ } catch (e) {
177
+ out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
178
+ return 1;
179
+ } finally {
180
+ await close();
181
+ }
182
+ }
183
+
184
+ case "status": {
185
+ const dbUrl = process.env["DATABASE_URL"];
186
+ if (!dbUrl) {
187
+ out.err(" DATABASE_URL not set.");
188
+ return 1;
189
+ }
190
+ if (!existsSync(migrationsDir)) {
191
+ out.log(" Kein kumiko/migrations/ — App ist noch auf dem alten drizzle-Pfad.");
192
+ return 0;
193
+ }
194
+ const local = loadMigrationsFromDir(migrationsDir);
195
+ const { db, close } = createDbConnection(dbUrl);
196
+ try {
197
+ // fetchAppliedMigrations wirft wenn die tracking-table noch nicht da ist.
198
+ let applied: Set<string>;
199
+ try {
200
+ applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
201
+ } catch {
202
+ applied = new Set();
203
+ }
204
+ out.log("");
205
+ out.log(` ${local.length} migrations in ${migrationsDir}:`);
206
+ for (const m of local) out.log(` ${applied.has(m.id) ? "✓" : " "} ${m.id}`);
207
+ const pending = local.filter((m) => !applied.has(m.id)).length;
208
+ out.log("");
209
+ out.log(` ${applied.size} applied, ${pending} pending.`);
210
+ out.log("");
211
+ return 0;
212
+ } finally {
213
+ await close();
214
+ }
215
+ }
216
+
217
+ default: {
218
+ out.log("");
219
+ out.log(" Subcommands:");
220
+ out.log(" generate <name> Schreibe neue Migration aus EntityTableMeta-Diff");
221
+ out.log(" apply Applied pending checked-in SQL-Files");
222
+ out.log(" baseline Markiere checked-in Migrations als applied (kein SQL-Run)");
223
+ out.log(" status Liste applied vs pending");
224
+ out.log("");
225
+ return 0;
226
+ }
227
+ }
228
+ }