@cosmicdrift/kumiko-framework 0.22.0 → 0.24.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.22.0",
3
+ "version": "0.24.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>",
@@ -0,0 +1,95 @@
1
+ // Integration-Test für `runSchemaCli` `status`-Subcommand.
2
+ // Regression für review #155 finding 4: vorher schluckte `try/catch` jeden
3
+ // Fehler (Connection-, Permission-, Query-) und reportete alles als "pending".
4
+ // Jetzt: tracking-table fehlt → 0 applied (kein Fehler), echte Fehler werden
5
+ // propagiert. Konsistent zum detectKumikoDrift-Pattern.
6
+
7
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
12
+ import { asRawClient } from "../db/query";
13
+ import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
14
+ import { ensureTemporalPolyfill } from "../time/polyfill";
15
+
16
+ let testDb: BunTestDb;
17
+ let appDir: string;
18
+ let testUrl: string;
19
+ let prevDbUrl: string | undefined;
20
+
21
+ beforeAll(async () => {
22
+ await ensureTemporalPolyfill();
23
+ testDb = await createTestDb();
24
+ const baseUrl = process.env["TEST_DATABASE_URL"];
25
+ if (!baseUrl) throw new Error("TEST_DATABASE_URL not set — required for this test file");
26
+ testUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
27
+ });
28
+
29
+ afterAll(async () => {
30
+ await testDb.cleanup();
31
+ });
32
+
33
+ beforeEach(async () => {
34
+ // runSchemaCli resolves appCwd/kumiko/migrations — writeMigration() legt das
35
+ // subdir bei Bedarf an. Tests, die den "Dir fehlt"-Pfad prüfen, rufen
36
+ // writeMigration nicht auf.
37
+ appDir = mkdtempSync(join(tmpdir(), "kumiko-cli-"));
38
+ prevDbUrl = process.env["DATABASE_URL"];
39
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
40
+ });
41
+
42
+ afterEach(() => {
43
+ rmSync(appDir, { recursive: true, force: true });
44
+ if (prevDbUrl === undefined) delete process.env["DATABASE_URL"];
45
+ else process.env["DATABASE_URL"] = prevDbUrl;
46
+ });
47
+
48
+ function writeMigration(file: string, sql: string): void {
49
+ const migDir = join(appDir, "kumiko", "migrations");
50
+ mkdirSync(migDir, { recursive: true });
51
+ writeFileSync(join(migDir, file), sql);
52
+ }
53
+
54
+ function captureOut(): { out: SchemaCliOut; lines: string[] } {
55
+ const lines: string[] = [];
56
+ return {
57
+ out: { log: (l: string) => lines.push(l), err: (l: string) => lines.push(`ERR ${l}`) },
58
+ lines,
59
+ };
60
+ }
61
+
62
+ describe("runSchemaCli status", () => {
63
+ test("fresh DB without _kumiko_migrations → 0 applied (no throw)", async () => {
64
+ process.env["DATABASE_URL"] = testUrl;
65
+ writeMigration("0001_init.sql", `SELECT 1;`);
66
+ const { out, lines } = captureOut();
67
+ const code = await runSchemaCli(["status"], appDir, out);
68
+ expect(code).toBe(0);
69
+ const joined = lines.join("\n");
70
+ expect(joined).toContain("0 applied");
71
+ expect(joined).toContain("1 pending");
72
+ // Kein ERR-Prefix → kein silent-swallow eines echten Fehlers.
73
+ expect(joined).not.toContain("ERR ");
74
+ });
75
+
76
+ test("missing kumiko/migrations dir → no-op exit 0", async () => {
77
+ // Bestätigt symmetrisch: kein Verzeichnis = nichts zu reporten, kein Crash.
78
+ process.env["DATABASE_URL"] = testUrl;
79
+ const { out, lines } = captureOut();
80
+ const code = await runSchemaCli(["status"], appDir, out);
81
+ expect(code).toBe(0);
82
+ expect(lines.join("\n")).toContain("Kein kumiko/migrations/");
83
+ });
84
+
85
+ test("unreachable DB → propagates connection error (no silent '0 applied')", async () => {
86
+ // Der gefixte Pfad MUSS connection-failures propagieren — vor dem Fix hat
87
+ // `try { applied = new Set(...) } catch { applied = new Set() }` das zu
88
+ // "0 applied" verfälscht und den Operator in die Irre geführt.
89
+ // Port 1 wird auf POSIX-Systemen für nichts gebunden → connect refused.
90
+ process.env["DATABASE_URL"] = "postgresql://nobody:nope@127.0.0.1:1/__kumiko_unreachable__";
91
+ writeMigration("0001_init.sql", `SELECT 1;`);
92
+ const { out } = captureOut();
93
+ await expect(runSchemaCli(["status"], appDir, out)).rejects.toBeDefined();
94
+ });
95
+ });
@@ -0,0 +1,233 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
6
+ import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
7
+ import { ensureTemporalPolyfill } from "../time/polyfill";
8
+
9
+ function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
10
+ const log: string[] = [];
11
+ const err: string[] = [];
12
+ return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
13
+ }
14
+
15
+ function freshAppCwd(): string {
16
+ const dir = join(
17
+ tmpdir(),
18
+ `kumiko-schema-cli-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
19
+ );
20
+ mkdirSync(dir, { recursive: true });
21
+ mkdirSync(join(dir, "kumiko"), { recursive: true });
22
+ return dir;
23
+ }
24
+
25
+ function writeSchemaFile(appCwd: string, tableName: string, extraField?: string): void {
26
+ const extra = extraField ? `, { name: "${extraField}", pgType: "text", notNull: false }` : "";
27
+ const content = `export const ENTITY_METAS = [
28
+ {
29
+ tableName: "${tableName}",
30
+ source: "unmanaged",
31
+ indexes: [],
32
+ columns: [
33
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true, defaultSql: "gen_random_uuid()" }${extra}
34
+ ],
35
+ },
36
+ ];
37
+ `;
38
+ writeFileSync(join(appCwd, "kumiko/schema.ts"), content);
39
+ }
40
+
41
+ describe("runSchemaCli — no-DB paths", () => {
42
+ let appCwd: string;
43
+ beforeEach(() => {
44
+ appCwd = freshAppCwd();
45
+ });
46
+
47
+ test("default subcommand prints usage and exits 0", async () => {
48
+ const cap = captureOut();
49
+ const code = await runSchemaCli([], appCwd, cap.out);
50
+ expect(code).toBe(0);
51
+ expect(cap.log.join("\n")).toContain("Subcommands:");
52
+ expect(cap.err).toHaveLength(0);
53
+ });
54
+
55
+ test("unknown subcommand falls through to usage", async () => {
56
+ const cap = captureOut();
57
+ const code = await runSchemaCli(["lolwut"], appCwd, cap.out);
58
+ expect(code).toBe(0);
59
+ expect(cap.log.join("\n")).toContain("Subcommands:");
60
+ });
61
+
62
+ test("generate without name exits 1 with neutral usage wording", async () => {
63
+ const cap = captureOut();
64
+ const code = await runSchemaCli(["generate"], appCwd, cap.out);
65
+ expect(code).toBe(1);
66
+ expect(cap.err.join("\n")).toContain("Usage: schema generate <name>");
67
+ expect(cap.err.join("\n")).not.toContain("kumiko-schema");
68
+ });
69
+
70
+ test("generate with missing schema.ts exits 1", async () => {
71
+ const cap = captureOut();
72
+ const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
73
+ expect(code).toBe(1);
74
+ expect(cap.err.join("\n")).toContain("kumiko/schema.ts");
75
+ expect(cap.err.join("\n")).toContain("fehlt");
76
+ });
77
+
78
+ test("generate writes 0001_<name>.sql + .snapshot.json and exits 0", async () => {
79
+ writeSchemaFile(appCwd, "tbl_a");
80
+ const cap = captureOut();
81
+ const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
82
+ expect(code).toBe(0);
83
+
84
+ const migrationsDir = join(appCwd, "kumiko/migrations");
85
+ expect(existsSync(migrationsDir)).toBe(true);
86
+ const files = readdirSync(migrationsDir);
87
+ expect(files).toContain("0001_init.sql");
88
+ expect(files).toContain(".snapshot.json");
89
+
90
+ const sql = readFileSync(join(migrationsDir, "0001_init.sql"), "utf8");
91
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS "tbl_a"');
92
+ });
93
+
94
+ test("generate second time without schema changes prints skip + exits 0", async () => {
95
+ writeSchemaFile(appCwd, "tbl_a");
96
+ const first = captureOut();
97
+ await runSchemaCli(["generate", "init"], appCwd, first.out);
98
+
99
+ const cap = captureOut();
100
+ const code = await runSchemaCli(["generate", "noop"], appCwd, cap.out);
101
+ expect(code).toBe(0);
102
+ expect(cap.log.join("\n")).toContain("No schema changes detected");
103
+ const files = readdirSync(join(appCwd, "kumiko/migrations"));
104
+ expect(files).not.toContain("0002_noop.sql");
105
+ });
106
+
107
+ test("apply without DATABASE_URL exits 1", async () => {
108
+ const dbUrl = process.env["DATABASE_URL"];
109
+ delete process.env["DATABASE_URL"];
110
+ try {
111
+ const cap = captureOut();
112
+ const code = await runSchemaCli(["apply"], appCwd, cap.out);
113
+ expect(code).toBe(1);
114
+ expect(cap.err.join("\n")).toContain("DATABASE_URL not set");
115
+ } finally {
116
+ if (dbUrl !== undefined) process.env["DATABASE_URL"] = dbUrl;
117
+ }
118
+ });
119
+
120
+ test("status without DATABASE_URL exits 1", async () => {
121
+ const dbUrl = process.env["DATABASE_URL"];
122
+ delete process.env["DATABASE_URL"];
123
+ try {
124
+ const cap = captureOut();
125
+ const code = await runSchemaCli(["status"], appCwd, cap.out);
126
+ expect(code).toBe(1);
127
+ expect(cap.err.join("\n")).toContain("DATABASE_URL not set");
128
+ } finally {
129
+ if (dbUrl !== undefined) process.env["DATABASE_URL"] = dbUrl;
130
+ }
131
+ });
132
+
133
+ test("status without kumiko/migrations exits 0 (legacy drizzle path)", async () => {
134
+ const prevDbUrl = process.env["DATABASE_URL"];
135
+ process.env["DATABASE_URL"] = "postgresql://placeholder:placeholder@localhost:1/placeholder";
136
+ try {
137
+ const cap = captureOut();
138
+ const code = await runSchemaCli(["status"], appCwd, cap.out);
139
+ expect(code).toBe(0);
140
+ expect(cap.log.join("\n")).toContain("alten drizzle-Pfad");
141
+ } finally {
142
+ if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
143
+ else delete process.env["DATABASE_URL"];
144
+ }
145
+ });
146
+ });
147
+
148
+ describe("runSchemaCli — DB-backed paths", () => {
149
+ let testDb: BunTestDb;
150
+ let dbUrl: string;
151
+ let prevDbUrl: string | undefined;
152
+
153
+ beforeAll(async () => {
154
+ await ensureTemporalPolyfill();
155
+ testDb = await createTestDb();
156
+ const baseUrl =
157
+ process.env["TEST_DATABASE_URL"] ??
158
+ process.env["DATABASE_URL"] ??
159
+ "postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
160
+ dbUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
161
+ prevDbUrl = process.env["DATABASE_URL"];
162
+ process.env["DATABASE_URL"] = dbUrl;
163
+ });
164
+
165
+ afterAll(async () => {
166
+ if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
167
+ else delete process.env["DATABASE_URL"];
168
+ await testDb?.cleanup();
169
+ });
170
+
171
+ test("apply runs pending migrations, status reports 0 pending → exit 0", async () => {
172
+ const appCwd = freshAppCwd();
173
+ writeSchemaFile(appCwd, "tbl_apply_ok");
174
+ await runSchemaCli(["generate", "apply_ok"], appCwd, captureOut().out);
175
+
176
+ const applyCap = captureOut();
177
+ const applyCode = await runSchemaCli(["apply"], appCwd, applyCap.out);
178
+ expect(applyCode).toBe(0);
179
+ expect(applyCap.log.join("\n")).toContain("Applied 1");
180
+
181
+ const statusCap = captureOut();
182
+ const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
183
+ expect(statusCode).toBe(0);
184
+ expect(statusCap.log.join("\n")).toContain("0 pending");
185
+
186
+ rmSync(appCwd, { recursive: true, force: true });
187
+ });
188
+
189
+ test("status with pending migrations exits 1 (regression-pin: CI-gating signal)", async () => {
190
+ const appCwd = freshAppCwd();
191
+ writeSchemaFile(appCwd, "tbl_pending");
192
+ await runSchemaCli(["generate", "pending_test"], appCwd, captureOut().out);
193
+
194
+ const statusCap = captureOut();
195
+ const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
196
+ expect(statusCode).toBe(1);
197
+ expect(statusCap.log.join("\n")).toContain("1 pending");
198
+
199
+ rmSync(appCwd, { recursive: true, force: true });
200
+ });
201
+
202
+ test("apply on already-applied migrations is idempotent + exits 0", async () => {
203
+ const appCwd = freshAppCwd();
204
+ writeSchemaFile(appCwd, "tbl_idem");
205
+ await runSchemaCli(["generate", "idem_test"], appCwd, captureOut().out);
206
+ await runSchemaCli(["apply"], appCwd, captureOut().out);
207
+
208
+ const cap = captureOut();
209
+ const code = await runSchemaCli(["apply"], appCwd, cap.out);
210
+ expect(code).toBe(0);
211
+ expect(cap.log.join("\n")).toContain("already applied");
212
+
213
+ rmSync(appCwd, { recursive: true, force: true });
214
+ });
215
+
216
+ test("baseline marks migrations applied without running SQL", async () => {
217
+ const appCwd = freshAppCwd();
218
+ writeSchemaFile(appCwd, "tbl_baseline");
219
+ await runSchemaCli(["generate", "baseline_test"], appCwd, captureOut().out);
220
+
221
+ const cap = captureOut();
222
+ const code = await runSchemaCli(["baseline"], appCwd, cap.out);
223
+ expect(code).toBe(0);
224
+ expect(cap.log.join("\n")).toContain("Marked 1 migration(s) as applied");
225
+
226
+ const statusCap = captureOut();
227
+ const statusCode = await runSchemaCli(["status"], appCwd, statusCap.out);
228
+ expect(statusCode).toBe(0);
229
+ expect(statusCap.log.join("\n")).toContain("0 pending");
230
+
231
+ rmSync(appCwd, { recursive: true, force: true });
232
+ });
233
+ });
@@ -9,11 +9,12 @@ import { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker } from "..
9
9
  function meta(
10
10
  tableName: string,
11
11
  extraColumn?: EntityTableMeta["columns"][number],
12
+ indexes: EntityTableMeta["indexes"] = [],
12
13
  ): EntityTableMeta {
13
14
  return {
14
15
  tableName,
15
16
  source: "unmanaged",
16
- indexes: [],
17
+ indexes,
17
18
  columns: [
18
19
  { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
19
20
  ...(extraColumn ? [extraColumn] : []),
@@ -40,6 +41,30 @@ describe("rebuildTablesFromDiff", () => {
40
41
  const snap = snapshotFromMetas([meta("read_a")]);
41
42
  expect(rebuildTablesFromDiff(diffSnapshots(snap, snap))).toEqual([]);
42
43
  });
44
+
45
+ test("index-only change → no rebuild (ALTER bringt Tabelle alleine in Soll)", () => {
46
+ const prev = snapshotFromMetas([meta("read_a")]);
47
+ const next = snapshotFromMetas([
48
+ meta("read_a", undefined, [{ name: "read_a_id_idx", columns: ["id"] }]),
49
+ ]);
50
+ expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
51
+ });
52
+
53
+ test("dropped-column-only change → no rebuild", () => {
54
+ const prev = snapshotFromMetas([
55
+ meta("read_a", { name: "old", pgType: "text", notNull: false }),
56
+ ]);
57
+ const next = snapshotFromMetas([meta("read_a")]);
58
+ expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual([]);
59
+ });
60
+
61
+ test("new-column change → rebuild (Backfill aus Events nötig)", () => {
62
+ const prev = snapshotFromMetas([meta("read_a")]);
63
+ const next = snapshotFromMetas([
64
+ meta("read_a", { name: "title", pgType: "text", notNull: false }),
65
+ ]);
66
+ expect(rebuildTablesFromDiff(diffSnapshots(prev, next))).toEqual(["read_a"]);
67
+ });
43
68
  });
44
69
 
45
70
  describe("write/read marker", () => {
@@ -83,4 +108,27 @@ describe("write/read marker", () => {
83
108
  rmSync(dir, { recursive: true, force: true });
84
109
  }
85
110
  });
111
+
112
+ test("version-mismatch marker → [] (graceful degradation, blockt v2 nicht als v1)", () => {
113
+ const dir = tmpDir();
114
+ try {
115
+ writeFileSync(
116
+ join(dir, "0005_future.rebuild.json"),
117
+ JSON.stringify({ version: 2, tables: ["read_x"] }),
118
+ );
119
+ expect(readRebuildMarker(dir, "0005_future")).toEqual([]);
120
+ } finally {
121
+ rmSync(dir, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test("missing version field → []", () => {
126
+ const dir = tmpDir();
127
+ try {
128
+ writeFileSync(join(dir, "0006_noversion.rebuild.json"), JSON.stringify({ tables: ["x"] }));
129
+ expect(readRebuildMarker(dir, "0006_noversion")).toEqual([]);
130
+ } finally {
131
+ rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ });
86
134
  });
@@ -30,7 +30,7 @@ describe("sql-inventory", () => {
30
30
  true,
31
31
  );
32
32
  expect(isRawSqlAllowed("/repo/bin/commands/schema.ts")).toBe(true);
33
- expect(isRawSqlAllowed("/repo/scripts/codemod-drizzle-chain-to-bun-db.ts")).toBe(true);
33
+ expect(isRawSqlAllowed("/repo/scripts/codemod-bun-db-swap.ts")).toBe(true);
34
34
  expect(
35
35
  isRawSqlAllowed("/repo/packages/bundled-features/src/sessions/handlers/cleanup.job.ts"),
36
36
  ).toBe(false);
@@ -33,12 +33,16 @@ function markerPathFor(migrationsDir: string, migrationId: string): string {
33
33
  }
34
34
 
35
35
  // Tabellen die nach dieser Migration einen Projection-Rebuild brauchen können:
36
- // neu angelegte + spalten-/index-geänderte. Gelöschte Tabellen NICHT (die
37
- // Projektion ist mit der Tabelle weg). Sortiert + dedupliziert für stabilen
38
- // PR-Diff.
36
+ // neu angelegte + Tabellen mit neuer Spalte. Index-/Nullability-/Default-/
37
+ // Drop-only-Änderungen brauchen KEINEN Rebuild das generierte ALTER-SQL
38
+ // bringt die Tabelle alleine in den Soll-Zustand. Über-Rebuild ist safe-but-
39
+ // wasteful (Tabellen-Lock + Full-Replay auf grossen Streams), deshalb hier
40
+ // konservativ einschränken. Sortiert + dedupliziert für stabilen PR-Diff.
39
41
  export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
40
42
  const names = new Set<string>();
41
- for (const t of diff.changedTables) names.add(t.tableName);
43
+ for (const t of diff.changedTables) {
44
+ if (t.newColumns.length > 0) names.add(t.tableName);
45
+ }
42
46
  for (const t of diff.newTables) names.add(t.tableName);
43
47
  return [...names].sort();
44
48
  }
@@ -68,7 +72,9 @@ export function readRebuildMarker(migrationsDir: string, migrationId: string): r
68
72
  } catch {
69
73
  return [];
70
74
  }
71
- if (typeof parsed !== "object" || parsed === null || !("tables" in parsed)) return [];
75
+ if (typeof parsed !== "object" || parsed === null) return [];
76
+ if (!("version" in parsed) || parsed.version !== MARKER_VERSION) return [];
77
+ if (!("tables" in parsed)) return [];
72
78
  const { tables } = parsed;
73
79
  if (!Array.isArray(tables)) return [];
74
80
  return tables.filter((t): t is string => typeof t === "string");
@@ -29,10 +29,23 @@ export async function tableExists(
29
29
  unsafe?: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
30
30
  };
31
31
  const client = dbAny.$client ?? dbAny.session?.client ?? dbAny;
32
+ // quote_ident-Round-trip auf SQL-Seite: ohne Quotes folded postgres
33
+ // unquoted identifier case-insensitiv (myWidget → mywidget), während die
34
+ // generierte DDL den Namen via quoteIdent("myWidget") → "myWidget" case-
35
+ // preserved schreibt. quote_ident sorgt für identische Quotierung beidseits.
36
+ // Schema-qualifizierte Namen (`public.events`) werden per Split einzeln quotet.
37
+ const dotIdx = fullyQualifiedName.indexOf(".");
38
+ const [sql, params] =
39
+ dotIdx >= 0
40
+ ? [
41
+ `SELECT to_regclass(quote_ident($1) || '.' || quote_ident($2)) IS NOT NULL AS exists`,
42
+ [fullyQualifiedName.slice(0, dotIdx), fullyQualifiedName.slice(dotIdx + 1)],
43
+ ]
44
+ : [`SELECT to_regclass(quote_ident($1)) IS NOT NULL AS exists`, [fullyQualifiedName]];
32
45
  const rows = await (
33
46
  client as {
34
47
  unsafe: (s: string, p?: readonly unknown[]) => Promise<readonly { exists: boolean }[]>;
35
48
  }
36
- ).unsafe(`SELECT to_regclass($1) IS NOT NULL AS exists`, [fullyQualifiedName]);
49
+ ).unsafe(sql, params);
37
50
  return rows[0]?.exists ?? false;
38
51
  }
@@ -9,13 +9,17 @@
9
9
  // Drizzle's mode:"number", so arithmetic in assertions Just Works).
10
10
 
11
11
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
12
13
  import { asRawClient, selectMany } from "../../db/query";
14
+ import { createTenantDb } from "../../db/tenant-db";
13
15
  import type { SessionUser } from "../../engine";
14
16
  import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
15
17
  import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
16
18
  import {
17
19
  createFilesFeature,
18
20
  createInMemoryFileProvider,
21
+ fileRefEntity,
22
+ fileRefsTable,
19
23
  filesStorageTrackingFeature,
20
24
  type InMemoryFileProvider,
21
25
  tenantStorageUsageTable,
@@ -63,7 +67,7 @@ beforeEach(async () => {
63
67
  await stack.eventDispatcher?.ensureRegistered();
64
68
  });
65
69
 
66
- async function upload(user: SessionUser, name: string, content: Uint8Array): Promise<void> {
70
+ async function upload(user: SessionUser, name: string, content: Uint8Array): Promise<string> {
67
71
  const token = await stack.jwt.sign(user);
68
72
  const formData = new FormData();
69
73
  formData.append("file", new File([Buffer.from(content)], name, { type: "image/png" }));
@@ -74,6 +78,28 @@ async function upload(user: SessionUser, name: string, content: Uint8Array): Pro
74
78
  body: multipartBody,
75
79
  });
76
80
  expect(res.status).toBe(201);
81
+ const body = (await res.json()) as { id: string };
82
+ return body.id;
83
+ }
84
+
85
+ async function deleteFile(user: SessionUser, id: string): Promise<void> {
86
+ const token = await stack.jwt.sign(user);
87
+ const res = await stack.app.request(`/api/files/${id}`, {
88
+ method: "DELETE",
89
+ headers: { Authorization: `Bearer ${token}` },
90
+ });
91
+ expect(res.status).toBe(200);
92
+ }
93
+
94
+ async function restoreFile(user: SessionUser, id: string): Promise<void> {
95
+ // No HTTP route for restore — drive the entity executor directly, which is
96
+ // the same path file-routes uses for create/delete. Emits fileRef.restored
97
+ // with { previous } that the MSP re-increments on.
98
+ const executor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
99
+ entityName: "fileRef",
100
+ });
101
+ const result = await executor.restore({ id }, user, createTenantDb(stack.db, user.tenantId));
102
+ if (!result.isSuccess) throw new Error(`restore failed: ${JSON.stringify(result)}`);
77
103
  }
78
104
 
79
105
  async function usageFor(tenantId: string): Promise<{ totalBytes: number; fileCount: number }> {
@@ -121,6 +147,32 @@ describe("tenant-storage-usage MSP", () => {
121
147
  expect(otherUsage).toEqual({ totalBytes: LARGE.length, fileCount: 1 });
122
148
  });
123
149
 
150
+ test("delete decrements totalBytes and fileCount by the deleted file's size", async () => {
151
+ const idSmall = await upload(admin, "a.png", SMALL);
152
+ await upload(admin, "b.png", LARGE);
153
+ await stack.eventDispatcher?.runOnce();
154
+
155
+ await deleteFile(admin, idSmall);
156
+ await stack.eventDispatcher?.runOnce();
157
+
158
+ const usage = await usageFor(admin.tenantId);
159
+ expect(usage).toEqual({ totalBytes: LARGE.length, fileCount: 1 });
160
+ });
161
+
162
+ test("restore re-increments after delete — round-trip leaves usage unchanged", async () => {
163
+ const id = await upload(admin, "a.png", SMALL);
164
+ await stack.eventDispatcher?.runOnce();
165
+ expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
166
+
167
+ await deleteFile(admin, id);
168
+ await stack.eventDispatcher?.runOnce();
169
+ expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: 0, fileCount: 0 });
170
+
171
+ await restoreFile(admin, id);
172
+ await stack.eventDispatcher?.runOnce();
173
+ expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
174
+ });
175
+
124
176
  test("lastUpdatedAt is set and advances on subsequent uploads", async () => {
125
177
  await upload(admin, "a.png", SMALL);
126
178
  await stack.eventDispatcher?.runOnce();
@@ -1,4 +1,4 @@
1
- import { createEntity, createNumberField, createTextField, createTimestampField } from "../engine";
1
+ import { createEntity, createNumberField, createTextField } from "../engine";
2
2
 
3
3
  // fileRef — das File-Metadata-Entity. Ganz normales ES-Entity: Upload/Delete
4
4
  // laufen über den Standard-Executor (file-routes.ts), die Tabelle `file_refs`
@@ -8,11 +8,16 @@ import { createEntity, createNumberField, createTextField, createTimestampField
8
8
  // Ein Delete markiert `isDeleted=true` (wiederherstellbar, kein "sofort weg");
9
9
  // echtes Erasure (Art. 17) läuft über den Forget-Hook + Retention-Cleanup.
10
10
  //
11
+ // `insertedAt`/`insertedById` sind framework-managed base columns (siehe
12
+ // buildBaseColumns in table-builder.ts) und dürfen NICHT als Entity-Fields
13
+ // dupliziert werden — fieldColumns gewinnen beim Merge, und die Field-Variante
14
+ // ohne `.default(now()).notNull()` macht inserted_at still nullable.
15
+ //
11
16
  // PII-Annotations:
12
17
  // - fileName → pii: true (Originalname enthält oft Personen-Bezug:
13
18
  // "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). Andere Felder
14
- // (storageKey, mimeType, size, entityType, entityId, fieldName,
15
- // insertedById, insertedAt) treffen die PII-Heuristik nicht.
19
+ // (storageKey, mimeType, size, entityType, entityId, fieldName) treffen
20
+ // die PII-Heuristik nicht.
16
21
  export const fileRefEntity = createEntity({
17
22
  table: "file_refs",
18
23
  softDelete: true,
@@ -24,7 +29,5 @@ export const fileRefEntity = createEntity({
24
29
  entityType: createTextField(),
25
30
  entityId: createTextField(),
26
31
  fieldName: createTextField(),
27
- insertedAt: createTimestampField({ sortable: true, filterable: true }),
28
- insertedById: createTextField(),
29
32
  },
30
33
  });
@@ -236,7 +236,14 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
236
236
  // entity, not a files-specific path.
237
237
  const result = await executor.delete({ id }, user, createTenantDb(db, user.tenantId));
238
238
  if (!result.isSuccess) {
239
- return c.json({ error: "not_found" }, 404);
239
+ // NotFound (race between guard and executor) reuses the 404-masking
240
+ // pattern from the access-deny path above. Everything else (version
241
+ // conflict 409, ownership 403, validation 422, internal 500) gets the
242
+ // real status off the error so callers can distinguish recoverable
243
+ // from terminal — pauschales 404 hier hat genau das verschleiert.
244
+ const status = result.error.httpStatus as 400 | 403 | 404 | 409 | 422 | 500;
245
+ const code = status === 404 ? "not_found" : result.error.code;
246
+ return c.json({ error: code }, status);
240
247
  }
241
248
  return c.json({ ok: true });
242
249
  });
@@ -17,15 +17,24 @@ import { defineFeature } from "../engine";
17
17
 
18
18
  // fileRef is a standard ES entity, so usage tracking subscribes to its
19
19
  // auto-verb events. `fileRef.created` payload carries the entity fields
20
- // (incl. size); `fileRef.deleted` carries `{ previous }` (the pre-delete
21
- // row), so the byte count to reverse lives at previous.size.
20
+ // (incl. size); `fileRef.deleted` / `fileRef.restored` carry `{ previous }`
21
+ // (the pre-event row), so the byte count to apply lives at previous.size.
22
22
  const FILE_REF_CREATED = entityEventName("fileRef", "created");
23
23
  const FILE_REF_DELETED = entityEventName("fileRef", "deleted");
24
+ const FILE_REF_RESTORED = entityEventName("fileRef", "restored");
24
25
 
25
26
  function readNumber(value: unknown): number {
26
27
  return typeof value === "number" ? value : 0;
27
28
  }
28
29
 
30
+ function sizeFromPrevious(payload: Record<string, unknown>): number {
31
+ const previous = payload["previous"];
32
+ if (previous && typeof previous === "object" && !Array.isArray(previous)) {
33
+ return readNumber((previous as Record<string, unknown>)["size"]); // @cast-boundary engine-payload
34
+ }
35
+ return 0;
36
+ }
37
+
29
38
  // bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
30
39
  // bytes ≈ 8 petabytes per tenant — large enough for any practical storage
31
40
  // quota). Default "bigint" mode would hand back a bigint value, which
@@ -59,12 +68,7 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
59
68
  );
60
69
  },
61
70
  [FILE_REF_DELETED]: async (event, tx) => {
62
- // Delete events carry the pre-delete row under `previous`.
63
- const previous = event.payload["previous"];
64
- const size =
65
- previous && typeof previous === "object" && !Array.isArray(previous)
66
- ? readNumber((previous as Record<string, unknown>)["size"]) // @cast-boundary engine-payload
67
- : 0;
71
+ const size = sizeFromPrevious(event.payload);
68
72
  // Decrement on delete. INSERT values are 0/0 so a delete that somehow
69
73
  // precedes any upload can't create negative usage; the on-conflict
70
74
  // path applies the real negative delta. Async (dispatcher) —
@@ -77,6 +81,19 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
77
81
  { set: { lastUpdatedAt: sql`now()` } },
78
82
  );
79
83
  },
84
+ [FILE_REF_RESTORED]: async (event, tx) => {
85
+ // Restore re-increments by the soft-deleted row's size — symmetric
86
+ // to delete. Without this handler totalBytes/fileCount drift low
87
+ // after every delete→restore round-trip.
88
+ const size = sizeFromPrevious(event.payload);
89
+ await incrementCounter(
90
+ tx,
91
+ tenantStorageUsageTable,
92
+ { tenantId: event.tenantId, totalBytes: size, fileCount: 1 },
93
+ { totalBytes: size, fileCount: 1 },
94
+ { set: { lastUpdatedAt: sql`now()` } },
95
+ );
96
+ },
80
97
  },
81
98
  });
82
99
  });
@@ -16,6 +16,7 @@ import {
16
16
  runMigrationsFromDir,
17
17
  } from "../../db/migrate-runner";
18
18
  import { asRawClient } from "../../db/query";
19
+ import { tableExists } from "../../db/schema-inspection";
19
20
  import { createEntity, createTextField } from "../../engine";
20
21
  import { ensureTemporalPolyfill } from "../../time/polyfill";
21
22
  import { assertKumikoSchemaCurrent, detectKumikoDrift, SchemaDriftError } from "../kumiko-drift";
@@ -38,6 +39,7 @@ beforeEach(async () => {
38
39
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
39
40
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_widget"`);
40
41
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_gen"`);
42
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdriftMixed"`);
41
43
  });
42
44
 
43
45
  afterEach(() => {
@@ -101,6 +103,37 @@ describe("kumiko-drift boot-gate", () => {
101
103
  expect(report.missingTables).toEqual(["kdrift_widget"]);
102
104
  });
103
105
 
106
+ test("missing migrations dir → ok (no migrations to validate)", async () => {
107
+ // Regression für review #155 finding 1: existing-App-Upgrade ohne
108
+ // ./kumiko/migrations darf nicht roh ENOENT werfen (würde sonst plain
109
+ // Error statt SchemaDriftError → kein Remediation-Hint im Boot-Log).
110
+ rmSync(dir, { recursive: true, force: true });
111
+ const report = await detectKumikoDrift(testDb.db, dir);
112
+ expect(report.ok).toBe(true);
113
+ expect(report.pending).toEqual([]);
114
+ await expect(assertKumikoSchemaCurrent(testDb.db, dir)).resolves.toBeUndefined();
115
+ });
116
+
117
+ test("mixed-case snapshot tableName resolves via quote_ident round-trip", async () => {
118
+ // Regression für review #155 finding 3: postgres folded unquoted
119
+ // identifier case-insensitiv (myWidget → mywidget) in to_regclass, während
120
+ // die DDL via quoteIdent("kdriftMixed") → "kdriftMixed" case-preserved
121
+ // schreibt. tableExists muss quote_ident-Round-trip machen, sonst False-
122
+ // positive-Drift bei jeder mixed-case Entity-Tabelle.
123
+ await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdriftMixed" ("id" text PRIMARY KEY)`);
124
+ expect(await tableExists(testDb.db, "kdriftMixed")).toBe(true);
125
+ // Lowercase-Variante DARF nicht matchen — Round-trip preserved case.
126
+ expect(await tableExists(testDb.db, "kdriftmixed")).toBe(false);
127
+
128
+ writeMigration("0001_init.sql", `CREATE TABLE "kdriftMixed" ("id" text PRIMARY KEY);`);
129
+ writeSnapshot(["kdriftMixed"]);
130
+ await asRawClient(testDb.db).unsafe(`DROP TABLE "kdriftMixed"`);
131
+ await runMigrationsFromDir(testDb.db, dir);
132
+ const report = await detectKumikoDrift(testDb.db, dir);
133
+ expect(report.missingTables).toEqual([]);
134
+ expect(report.ok).toBe(true);
135
+ });
136
+
104
137
  test("baseline marks migrations applied without running SQL", async () => {
105
138
  // Tabelle existiert schon (wie eine adoptierte Prod-DB), Migration NICHT applyen.
106
139
  await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY)`);
@@ -0,0 +1,92 @@
1
+ // Unit-Tests für formatKumikoDriftReport — per-cause Remediation.
2
+ // Regression für review #155 finding 2: vor dem Fix hat der Report immer den
3
+ // "kumiko schema apply"-Hint gezeigt, auch wenn das Problem ein checksum-
4
+ // mismatch war (den apply NICHT löst). Operator folgte der Anweisung und
5
+ // landete in einer Sackgasse.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { formatKumikoDriftReport, type KumikoDriftReport } from "../kumiko-drift";
9
+
10
+ const empty: KumikoDriftReport = {
11
+ ok: true,
12
+ pending: [],
13
+ checksumMismatches: [],
14
+ missingTables: [],
15
+ };
16
+
17
+ describe("formatKumikoDriftReport", () => {
18
+ test("ok report → short success line", () => {
19
+ expect(formatKumikoDriftReport(empty)).toBe("Schema is current.");
20
+ });
21
+
22
+ test("pending only → recommends 'schema apply'", () => {
23
+ const report: KumikoDriftReport = {
24
+ ...empty,
25
+ ok: false,
26
+ pending: ["0001_init", "0002_add_locale"],
27
+ };
28
+ const out = formatKumikoDriftReport(report);
29
+ expect(out).toContain("2 unapplied migration(s)");
30
+ expect(out).toContain("0001_init");
31
+ expect(out).toContain("Run 'kumiko schema apply'");
32
+ expect(out).not.toContain("checksum");
33
+ expect(out).not.toContain("dropped after apply");
34
+ });
35
+
36
+ test("checksum mismatch only → suggests revert/hand-correct, NOT 'schema apply'", () => {
37
+ const report: KumikoDriftReport = {
38
+ ...empty,
39
+ ok: false,
40
+ checksumMismatches: [
41
+ { id: "0001_init", expected: "abcdef0123456789", actual: "fedcba9876543210" },
42
+ ],
43
+ };
44
+ const out = formatKumikoDriftReport(report);
45
+ expect(out).toContain("1 edited-after-apply");
46
+ expect(out).toContain("Revert the edited migration");
47
+ expect(out).toContain("cannot resolve a");
48
+ expect(out).toContain("checksum mismatch");
49
+ expect(out).not.toContain("Run 'kumiko schema apply'");
50
+ });
51
+
52
+ test("missing tables without pending → suggests backup/regen, NOT 'schema apply'", () => {
53
+ const report: KumikoDriftReport = {
54
+ ...empty,
55
+ ok: false,
56
+ missingTables: ["widget"],
57
+ };
58
+ const out = formatKumikoDriftReport(report);
59
+ expect(out).toContain("1 missing table(s)");
60
+ expect(out).toContain("dropped after apply");
61
+ expect(out).toContain("Restore from backup");
62
+ expect(out).not.toContain("Run 'kumiko schema apply'");
63
+ });
64
+
65
+ test("missing tables WITH pending → only the 'schema apply' hint (pending covers it)", () => {
66
+ // Wenn pending da ist, applied das die noch fehlenden Tabellen → kein
67
+ // Backup-Restore nötig. Verhindert verwirrenden Doppel-Hint.
68
+ const report: KumikoDriftReport = {
69
+ ...empty,
70
+ ok: false,
71
+ pending: ["0002_add_widget"],
72
+ missingTables: ["widget"],
73
+ };
74
+ const out = formatKumikoDriftReport(report);
75
+ expect(out).toContain("Run 'kumiko schema apply'");
76
+ expect(out).not.toContain("Restore from backup");
77
+ });
78
+
79
+ test("pending + mismatch combined → both remediation lines", () => {
80
+ const report: KumikoDriftReport = {
81
+ ...empty,
82
+ ok: false,
83
+ pending: ["0002_add_locale"],
84
+ checksumMismatches: [
85
+ { id: "0001_init", expected: "abcdef0123456789", actual: "fedcba9876543210" },
86
+ ],
87
+ };
88
+ const out = formatKumikoDriftReport(report);
89
+ expect(out).toContain("Run 'kumiko schema apply'");
90
+ expect(out).toContain("Revert the edited migration");
91
+ });
92
+ });
@@ -14,6 +14,7 @@
14
14
  // ALTERs / stale defs) is a documented follow-up; see
15
15
  // docs/plans/migration-system-consolidation.md.
16
16
 
17
+ import { existsSync } from "node:fs";
17
18
  import { join } from "node:path";
18
19
  import type { DbConnection } from "../db/connection";
19
20
  import { loadSnapshotJson } from "../db/migrate-generator";
@@ -48,6 +49,15 @@ export async function detectKumikoDrift(
48
49
  db: DbConnection,
49
50
  migrationsDir: string,
50
51
  ): Promise<KumikoDriftReport> {
52
+ // App auf neuer Framework-Version, hat aber `./kumiko/migrations` noch nicht
53
+ // generiert (z.B. erstes Upgrade, custom Schema-Setup ohne kumiko schema
54
+ // generate) → keine local migrations → keine drift. Konsistent zum
55
+ // status-Subcommand, der ebenfalls existsSync-guarded ist. Ohne diesen Guard
56
+ // würde loadMigrationsFromDir → readdirSync synchron ENOENT werfen
57
+ // (plain Error, kein SchemaDriftError) und der Boot crasht roh.
58
+ if (!existsSync(migrationsDir)) {
59
+ return { ok: true, pending: [], checksumMismatches: [], missingTables: [] };
60
+ }
51
61
  const local = loadMigrationsFromDir(migrationsDir);
52
62
  // Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table fehlt.
53
63
  // Das ist kein Fehler, sondern "nichts applied" → alle local sind pending.
@@ -106,8 +116,24 @@ export function formatKumikoDriftReport(report: KumikoDriftReport): string {
106
116
  lines.push(` ${report.missingTables.length} missing table(s):`);
107
117
  for (const t of report.missingTables) lines.push(` - ${t}`);
108
118
  }
119
+ // Per-Cause Remediation — `kumiko schema apply` löst NUR pending. Checksum-
120
+ // mismatch ist eine Sackgasse für apply (MigrationChecksumMismatchError) und
121
+ // baseline (ON CONFLICT DO NOTHING → landet in alreadyTracked). Missing
122
+ // tables ohne pending = manuell gelöschte Tabelle nach apply → ebenfalls
123
+ // nicht durch apply heilbar.
109
124
  lines.push("");
110
- lines.push("Run 'kumiko schema apply' to bring the DB up-to-date.");
125
+ if (report.pending.length > 0) {
126
+ lines.push("Run 'kumiko schema apply' to apply the pending migration(s).");
127
+ }
128
+ if (report.checksumMismatches.length > 0) {
129
+ lines.push("Revert the edited migration file(s) to their applied content, or hand-correct");
130
+ lines.push("the checksum in _kumiko_migrations — 'kumiko schema apply' cannot resolve a");
131
+ lines.push("checksum mismatch.");
132
+ }
133
+ if (report.missingTables.length > 0 && report.pending.length === 0) {
134
+ lines.push("Missing table(s) without pending migration(s) — table was dropped after apply.");
135
+ lines.push("Restore from backup, or generate a new migration that re-creates the table.");
136
+ }
111
137
  return lines.join("\n");
112
138
  }
113
139
 
package/src/schema-cli.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  rebuildTablesFromDiff,
21
21
  type renderTablesDdl,
22
22
  runMigrationsFromDir,
23
+ tableExists,
23
24
  writeRebuildMarker,
24
25
  writeSnapshotJson,
25
26
  } from "./db";
@@ -75,7 +76,7 @@ export async function runSchemaCli(
75
76
  case "generate": {
76
77
  const name = argv[1];
77
78
  if (!name) {
78
- out.err(" Usage: kumiko-schema generate <name>");
79
+ out.err(" Usage: schema generate <name>");
79
80
  return 1;
80
81
  }
81
82
  if (!existsSync(schemaFile)) {
@@ -124,7 +125,7 @@ export async function runSchemaCli(
124
125
  );
125
126
  }
126
127
  out.log("");
127
- out.log(" Review + ggf. hand-edit + git add + commit. Apply via: kumiko-schema apply");
128
+ out.log(" Review + ggf. hand-edit + git add + commit. Apply via: schema apply");
128
129
  out.log("");
129
130
  return 0;
130
131
  }
@@ -136,7 +137,7 @@ export async function runSchemaCli(
136
137
  return 1;
137
138
  }
138
139
  if (!existsSync(migrationsDir)) {
139
- out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
140
+ out.err(` ${migrationsDir} fehlt — erst schema generate <name>.`);
140
141
  return 1;
141
142
  }
142
143
  const { db, close } = createDbConnection(dbUrl);
@@ -172,7 +173,7 @@ export async function runSchemaCli(
172
173
  return 1;
173
174
  }
174
175
  if (!existsSync(migrationsDir)) {
175
- out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
176
+ out.err(` ${migrationsDir} fehlt — erst schema generate <name>.`);
176
177
  return 1;
177
178
  }
178
179
  const { db, close } = createDbConnection(dbUrl);
@@ -207,13 +208,14 @@ export async function runSchemaCli(
207
208
  const local = loadMigrationsFromDir(migrationsDir);
208
209
  const { db, close } = createDbConnection(dbUrl);
209
210
  try {
210
- // fetchAppliedMigrations wirft wenn die tracking-table noch nicht da ist.
211
- let applied: Set<string>;
212
- try {
213
- applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
214
- } catch {
215
- applied = new Set();
216
- }
211
+ // Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table
212
+ // fehlt = "nichts applied". Connection-/Permission-Fehler dagegen
213
+ // sollen NICHT geschluckt werden (False-pending verschleiert das Problem) —
214
+ // tableExists prüft gezielt nur Existenz, alles andere propagiert.
215
+ const trackingExists = await tableExists(db, "_kumiko_migrations");
216
+ const applied = trackingExists
217
+ ? new Set((await fetchAppliedMigrations(db)).map((a) => a.id))
218
+ : new Set<string>();
217
219
  out.log("");
218
220
  out.log(` ${local.length} migrations in ${migrationsDir}:`);
219
221
  for (const m of local) out.log(` ${applied.has(m.id) ? "✓" : " "} ${m.id}`);
@@ -221,7 +223,7 @@ export async function runSchemaCli(
221
223
  out.log("");
222
224
  out.log(` ${applied.size} applied, ${pending} pending.`);
223
225
  out.log("");
224
- return 0;
226
+ return pending === 0 ? 0 : 1;
225
227
  } finally {
226
228
  await close();
227
229
  }
@@ -6,7 +6,7 @@ describe("runEventStoreSeed", () => {
6
6
  let updateCalls = 0;
7
7
  let createCalls = 0;
8
8
 
9
- const result = await runEventStoreSeed({
9
+ const result = await runEventStoreSeed<string>({
10
10
  existing: { id: "agg-1", version: 3 },
11
11
  create: async () => {
12
12
  createCalls++;
@@ -26,7 +26,7 @@ describe("runEventStoreSeed", () => {
26
26
  test('ifExists="update" calls update when row exists', async () => {
27
27
  let updateCalls = 0;
28
28
 
29
- const result = await runEventStoreSeed({
29
+ const result = await runEventStoreSeed<string>({
30
30
  existing: { id: "agg-2", version: 1 },
31
31
  ifExists: "update",
32
32
  create: async () => ({ id: "new" }),
@@ -44,7 +44,7 @@ describe("runEventStoreSeed", () => {
44
44
  test("missing row calls create", async () => {
45
45
  let createCalls = 0;
46
46
 
47
- const result = await runEventStoreSeed({
47
+ const result = await runEventStoreSeed<string>({
48
48
  existing: null,
49
49
  create: async () => {
50
50
  createCalls++;
@@ -56,4 +56,15 @@ describe("runEventStoreSeed", () => {
56
56
  expect(result.id).toBe("created");
57
57
  expect(createCalls).toBe(1);
58
58
  });
59
+
60
+ test("supports numeric id type via explicit generic", async () => {
61
+ const result = await runEventStoreSeed<number>({
62
+ existing: { id: 42, version: 1 },
63
+ ifExists: "update",
64
+ create: async () => ({ id: 99 }),
65
+ update: async (row) => ({ id: row.id }),
66
+ });
67
+
68
+ expect(result.id).toBe(42);
69
+ });
59
70
  });
@@ -1,21 +1,25 @@
1
1
  import { DEFAULT_SEED_IF_EXISTS, type SeedIfExists } from "./types";
2
2
 
3
- export type EventStoreSeedExisting = {
4
- readonly id: string | number;
3
+ export type EventStoreSeedExisting<TId extends string | number = string> = {
4
+ readonly id: TId;
5
5
  readonly version: number;
6
6
  };
7
7
 
8
- export type RunEventStoreSeedOptions<TExisting extends EventStoreSeedExisting> = {
8
+ export type RunEventStoreSeedOptions<
9
+ TId extends string | number = string,
10
+ TExisting extends EventStoreSeedExisting<TId> = EventStoreSeedExisting<TId>,
11
+ > = {
9
12
  readonly existing: TExisting | null | undefined;
10
13
  readonly ifExists?: SeedIfExists;
11
- readonly create: () => Promise<{ id: string | number }>;
12
- readonly update: (existing: TExisting) => Promise<{ id: string | number }>;
14
+ readonly create: () => Promise<{ id: TId }>;
15
+ readonly update: (existing: TExisting) => Promise<{ id: TId }>;
13
16
  };
14
17
 
15
18
  /** Shared create-or-skip/update path for event-store boot-seed helpers. */
16
- export async function runEventStoreSeed<TExisting extends EventStoreSeedExisting>(
17
- opts: RunEventStoreSeedOptions<TExisting>,
18
- ): Promise<{ id: string | number }> {
19
+ export async function runEventStoreSeed<
20
+ TId extends string | number = string,
21
+ TExisting extends EventStoreSeedExisting<TId> = EventStoreSeedExisting<TId>,
22
+ >(opts: RunEventStoreSeedOptions<TId, TExisting>): Promise<{ id: TId }> {
19
23
  const ifExists = opts.ifExists ?? DEFAULT_SEED_IF_EXISTS;
20
24
  if (opts.existing != null) {
21
25
  if (ifExists === "skip") {