@cosmicdrift/kumiko-framework 0.16.0 → 0.18.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.16.0",
3
+ "version": "0.18.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>",
package/src/api/routes.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  ValidationError,
10
10
  } from "../errors";
11
11
  import type { Dispatcher } from "../pipeline/dispatcher";
12
+ import { stringifyJson } from "../utils/safe-json";
12
13
  import { Routes } from "./api-constants";
13
14
  import { getUser } from "./auth-middleware";
14
15
  import { requestContext } from "./request-context";
@@ -25,7 +26,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
25
26
  if (!result.isSuccess) {
26
27
  return writeErrorResponse(c, reraiseAsKumikoError(result.error));
27
28
  }
28
- return c.json(result);
29
+ return jsonResponse(c, result);
29
30
  } catch (e) {
30
31
  return writeErrorResponse(c, toKumiko(e));
31
32
  }
@@ -66,7 +67,8 @@ export function createApiRoutes(dispatcher: Dispatcher) {
66
67
  // Keep failedIndex + results alongside the error envelope so callers
67
68
  // can tell which command in the batch failed and inspect the partial
68
69
  // results from the successful commands before the rollback.
69
- return c.json(
70
+ return jsonResponse(
71
+ c,
70
72
  {
71
73
  isSuccess: false,
72
74
  error,
@@ -88,7 +90,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
88
90
 
89
91
  try {
90
92
  const result = await dispatcher.query(body.type, body.payload, user);
91
- return c.json({ data: result });
93
+ return jsonResponse(c, { data: result });
92
94
  } catch (e) {
93
95
  return queryErrorResponse(c, toKumiko(e));
94
96
  }
@@ -109,6 +111,10 @@ export function createApiRoutes(dispatcher: Dispatcher) {
109
111
  return api;
110
112
  }
111
113
 
114
+ function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
115
+ return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
116
+ }
117
+
112
118
  function toKumiko(e: unknown): KumikoError {
113
119
  if (isKumikoError(e)) return e;
114
120
  if (e instanceof Error) return new InternalError({ cause: e });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { incrementCounter, selectMany } from "../query";
3
+
4
+ const meta = {
5
+ source: "unmanaged" as const,
6
+ tableName: "read_items",
7
+ indexes: [],
8
+ columns: [
9
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
10
+ { name: "tenant_id", pgType: "uuid", notNull: true },
11
+ { name: "count", pgType: "integer", notNull: true },
12
+ ],
13
+ };
14
+
15
+ // A db whose unsafe() blows up — proves the guard throws BEFORE any SQL runs.
16
+ const explodingDb = {
17
+ unsafe: async () => {
18
+ throw new Error("unsafe must not be reached when a guard rejects");
19
+ },
20
+ };
21
+
22
+ describe("bun-db guards — limit injection", () => {
23
+ test("selectMany rejects a non-integer limit", async () => {
24
+ await expect(selectMany(explodingDb, meta, undefined, { limit: 1.5 })).rejects.toThrow(
25
+ "limit must be a non-negative integer",
26
+ );
27
+ });
28
+
29
+ test("selectMany rejects a negative limit", async () => {
30
+ await expect(selectMany(explodingDb, meta, undefined, { limit: -1 })).rejects.toThrow(
31
+ "limit must be a non-negative integer",
32
+ );
33
+ });
34
+ });
35
+
36
+ describe("bun-db guards — silent tenant-scope bypass", () => {
37
+ // A TenantDb-shaped handle: has .raw.unsafe + the scoped methods + tenantId.
38
+ const tenantDb = {
39
+ raw: { unsafe: async () => [] as unknown[] },
40
+ tenantId: "00000000-0000-4000-8000-000000000001",
41
+ selectMany: async () => [],
42
+ fetchOne: async () => undefined,
43
+ insertOne: async () => undefined,
44
+ updateMany: async () => [],
45
+ deleteMany: async () => {},
46
+ };
47
+
48
+ test("incrementCounter refuses a tenant-scoped db (would bypass the filter)", async () => {
49
+ await expect(
50
+ incrementCounter(tenantDb, meta, { tenantId: tenantDb.tenantId }, { count: 1 }),
51
+ ).rejects.toThrow("does not apply the tenant filter");
52
+ });
53
+ });
@@ -107,6 +107,59 @@ export function asRawClient(db: unknown): RawClient {
107
107
  );
108
108
  }
109
109
 
110
+ /**
111
+ * When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
112
+ * unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
113
+ * delegate so reads/writes keep tenant filter + tenantId injection.
114
+ */
115
+ type TenantDbDelegate = {
116
+ selectMany<TRow>(
117
+ table: TableLike,
118
+ where?: WhereObject,
119
+ options?: SelectOptions,
120
+ ): Promise<readonly TRow[]>;
121
+ fetchOne<TRow>(table: TableLike, where: WhereObject): Promise<TRow | undefined>;
122
+ insertOne<TRow>(table: TableLike, values: Record<string, unknown>): Promise<TRow | undefined>;
123
+ updateMany<TRow>(
124
+ table: TableLike,
125
+ set: Record<string, unknown>,
126
+ where: WhereObject,
127
+ ): Promise<readonly TRow[]>;
128
+ deleteMany(table: TableLike, where: WhereObject): Promise<void>;
129
+ };
130
+
131
+ function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
132
+ if (typeof db !== "object" || db === null) return undefined;
133
+ const d = db as Record<string, unknown>;
134
+ const raw = d["raw"];
135
+ if (
136
+ raw &&
137
+ typeof (raw as Record<string, unknown>)["unsafe"] === "function" &&
138
+ typeof d["selectMany"] === "function" &&
139
+ typeof d["fetchOne"] === "function" &&
140
+ typeof d["insertOne"] === "function" &&
141
+ typeof d["updateMany"] === "function" &&
142
+ typeof d["deleteMany"] === "function" &&
143
+ "tenantId" in d
144
+ ) {
145
+ return db as TenantDbDelegate;
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ // Guard for helpers that do NOT delegate to TenantDb (upsert/increment/insertMany):
151
+ // passing a TenantDb here would silently run unscoped against `.raw`, bypassing
152
+ // the tenant filter. Fail loudly so the caller picks `ctx.db.<method>` (scoped)
153
+ // or `ctx.db.raw` (explicit cross-tenant) on purpose.
154
+ function assertNotTenantScoped(db: unknown, fnName: string): void {
155
+ if (tenantDbDelegate(db) !== undefined) {
156
+ throw new Error(
157
+ `${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
158
+ `Pass ctx.db.raw for an explicit cross-tenant op, or use a scoped TenantDb method.`,
159
+ );
160
+ }
161
+ }
162
+
110
163
  export type AnyDb = BunDbRunner | unknown;
111
164
 
112
165
  // WhereValue: primitive für eq, array für IN, null für IS NULL, oder
@@ -154,6 +207,8 @@ export type TableInfo = {
154
207
  readonly columnOf: (field: string) => string;
155
208
  // pgType per column-name, for jsonb-cast detection
156
209
  readonly pgTypeOf: (column: string) => string | undefined;
210
+ // bigint/bigserial JS round-trip mode per DB column name
211
+ readonly bigintJsModeOf: (column: string) => "number" | "bigint" | undefined;
157
212
  // Inverse of columnOf — snake_case DB column → JS field-name (camelCase).
158
213
  // Used at the result boundary to rename row keys back to the API shape
159
214
  // that callers consume (`row.aggregateId` instead of `row.aggregate_id`).
@@ -174,8 +229,10 @@ export function extractTableInfo(table: TableLike): TableInfo {
174
229
  const colByField = new Map<string, string>();
175
230
  const fieldByCol = new Map<string, string>();
176
231
  const typeByCol = new Map<string, string>();
232
+ const bigintModeByCol = new Map<string, "number" | "bigint">();
177
233
  for (const c of meta.columns) {
178
234
  typeByCol.set(c.name, c.pgType);
235
+ if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
179
236
  // EntityTableMeta column names are snake_case. Map snake → snake AND
180
237
  // derive a camelCase JS field-name so result rows can be renamed back
181
238
  // to the API shape (`aggregate_id` → `aggregateId`).
@@ -188,6 +245,7 @@ export function extractTableInfo(table: TableLike): TableInfo {
188
245
  name: meta.tableName,
189
246
  columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
190
247
  pgTypeOf: (col) => typeByCol.get(col),
248
+ bigintJsModeOf: (col) => bigintModeByCol.get(col),
191
249
  fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
192
250
  hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
193
251
  };
@@ -205,6 +263,17 @@ export function extractTableInfo(table: TableLike): TableInfo {
205
263
  const colByField = new Map<string, string>();
206
264
  const fieldByCol = new Map<string, string>();
207
265
  const typeByCol = new Map<string, string>();
266
+ const bigintModeByCol = new Map<string, "number" | "bigint">();
267
+ if (
268
+ table !== null &&
269
+ typeof table === "object" &&
270
+ "columns" in table &&
271
+ Array.isArray((table as EntityTableMeta).columns)
272
+ ) {
273
+ for (const c of (table as EntityTableMeta).columns) {
274
+ if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
275
+ }
276
+ }
208
277
  for (const [field, { name: colName, sqlType }] of cols) {
209
278
  colByField.set(field, colName);
210
279
  fieldByCol.set(colName, field);
@@ -214,6 +283,7 @@ export function extractTableInfo(table: TableLike): TableInfo {
214
283
  name,
215
284
  columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
216
285
  pgTypeOf: (col) => typeByCol.get(col),
286
+ bigintJsModeOf: (col) => bigintModeByCol.get(col),
217
287
  fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
218
288
  hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
219
289
  };
@@ -241,6 +311,30 @@ function isTemporalInstant(v: unknown): boolean {
241
311
  );
242
312
  }
243
313
 
314
+ // Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
315
+ // drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
316
+ // unmanaged columns). Uses BigInt equality check so 9007199254740993n never
317
+ // silently rounds to a safe integer.
318
+ function coerceBigIntFromDriver(value: bigint | string): bigint | number {
319
+ let bi: bigint;
320
+ if (typeof value === "string") {
321
+ try {
322
+ bi = BigInt(value);
323
+ } catch {
324
+ return value as unknown as bigint;
325
+ }
326
+ } else {
327
+ bi = value;
328
+ }
329
+ const min = BigInt(Number.MIN_SAFE_INTEGER);
330
+ const max = BigInt(Number.MAX_SAFE_INTEGER);
331
+ if (bi >= min && bi <= max) {
332
+ const n = Number(bi);
333
+ if (BigInt(n) === bi) return n;
334
+ }
335
+ return bi;
336
+ }
337
+
244
338
  function instantFromDriver(value: unknown): Temporal.Instant | null {
245
339
  if (value === null || value === undefined) return null;
246
340
  if (isTemporalInstant(value)) return value as Temporal.Instant;
@@ -278,16 +372,30 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
278
372
  if (t !== null) coerced = t;
279
373
  } else if (pgType === "jsonb" && typeof value === "string") {
280
374
  coerced = parseJsonSafe(value, value);
281
- } else if ((pgType === "bigint" || pgType === "bigserial") && typeof value === "string") {
282
- // postgres-js returns BIGINT as string to avoid JS-Number precision
283
- // loss past 2^53. Framework contract: bigint columns surface as
284
- // JS `bigint`. Drizzle's bigint customType did this conversion
285
- // invisibly; the native dialect rebuild needs it explicit.
286
- try {
287
- coerced = BigInt(value);
288
- } catch {
289
- // leave as string on parse error
375
+ } else if (
376
+ (pgType === "bigint" || pgType === "bigserial") &&
377
+ (typeof value === "string" || typeof value === "bigint")
378
+ ) {
379
+ const jsMode = info.bigintJsModeOf(key);
380
+ if (jsMode === "number") {
381
+ coerced = coerceBigIntFromDriver(value);
382
+ } else if (typeof value === "string") {
383
+ try {
384
+ coerced = BigInt(value);
385
+ } catch {
386
+ // leave as string on parse error
387
+ }
388
+ } else {
389
+ coerced = value;
290
390
  }
391
+ } else if (
392
+ (pgType === "integer" || pgType === "int4" || pgType === "smallint" || pgType === "int2") &&
393
+ (typeof value === "bigint" || typeof value === "string")
394
+ ) {
395
+ // Bun.SQL / some drivers return int4 as bigint or numeric string.
396
+ // Drizzle coerced to number for numberField columns — match that.
397
+ const n = typeof value === "bigint" ? Number(value) : Number(value);
398
+ if (!Number.isNaN(n)) coerced = n;
291
399
  }
292
400
  const fieldName = info.fieldOf(key);
293
401
  if (fieldName !== key) changed = true;
@@ -304,8 +412,8 @@ function coerceRows<T extends Record<string, unknown>>(
304
412
  return rows.map((r) => coerceRow(r, info));
305
413
  }
306
414
 
307
- // Helper für jsonb-Werte: Bun.sql kann arrays/objects nicht direkt als
308
- // jsonb binden — wir JSON.stringify + ::jsonb cast.
415
+ // Helper für jsonb-Werte: structured values binden als JS object/array mit
416
+ // ::jsonb cast. JSON.stringify vor dem cast würde JSON-string-scalars erzeugen.
309
417
  // Plus Temporal.Instant → ISO string coercion for timestamptz columns.
310
418
  // SqlExpression (sql`now()`, sql`gen_random_uuid()`) wird als kind:"literal"
311
419
  // returned — Caller embedded das inline statt einen $N-placeholder zu setzen.
@@ -321,13 +429,21 @@ function prepareValue(value: unknown, pgType: string | undefined): PreparedValue
321
429
  if (isSqlExpression(value)) {
322
430
  return { kind: "literal", literal: value.text };
323
431
  }
324
- if (
325
- pgType === "jsonb" &&
326
- value !== null &&
327
- typeof value === "object" &&
328
- !isTemporalInstant(value)
329
- ) {
330
- return { kind: "param", sql: "::jsonb", bound: JSON.stringify(value) };
432
+ if (pgType === "jsonb" && value !== null) {
433
+ if (typeof value === "boolean") {
434
+ return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
435
+ }
436
+ if (typeof value === "object" && !isTemporalInstant(value)) {
437
+ // Plain objects: bind directly — JSON.stringify + ::jsonb stores a JSON string scalar.
438
+ if (!Array.isArray(value)) {
439
+ return { kind: "param", sql: "::jsonb", bound: value };
440
+ }
441
+ // All-boolean arrays are inferred as boolean[] by postgres-js; route via text::jsonb.
442
+ if (value.length > 0 && value.every((entry) => typeof entry === "boolean")) {
443
+ return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
444
+ }
445
+ return { kind: "param", sql: "::jsonb", bound: value };
446
+ }
331
447
  }
332
448
  if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
333
449
  return { kind: "param", sql: "", bound: (value as Temporal.Instant).toString() };
@@ -365,6 +481,10 @@ function buildWhereClause(
365
481
  conditions.push(`${quoteIdent(col)} IN (${parts.join(", ")})`);
366
482
  }
367
483
  } else if (isWhereOperator(value)) {
484
+ if (value.ne === null && Object.keys(value).length === 1) {
485
+ conditions.push(`${quoteIdent(col)} IS NOT NULL`);
486
+ continue;
487
+ }
368
488
  const opMap: Record<string, string> = {
369
489
  gt: ">",
370
490
  gte: ">=",
@@ -422,6 +542,10 @@ export async function selectMany<TRow = any>(
422
542
  where?: WhereObject,
423
543
  options?: SelectOptions,
424
544
  ): Promise<readonly TRow[]> {
545
+ const scoped = tenantDbDelegate(db);
546
+ if (scoped) {
547
+ return scoped.selectMany<TRow>(table, where, options);
548
+ }
425
549
  const info = extractTableInfo(table);
426
550
  let sqlText = `SELECT * FROM ${quoteIdent(info.name)}`;
427
551
  let values: unknown[] = [];
@@ -440,6 +564,11 @@ export async function selectMany<TRow = any>(
440
564
  if (parts.length > 0) sqlText += ` ORDER BY ${parts.join(", ")}`;
441
565
  }
442
566
  if (options?.limit !== undefined) {
567
+ // Interpolated, not bound — guard against a non-integer slipping in via an
568
+ // `any`-typed call site and injecting SQL.
569
+ if (!Number.isInteger(options.limit) || options.limit < 0) {
570
+ throw new Error(`selectMany: limit must be a non-negative integer, got ${options.limit}`);
571
+ }
443
572
  sqlText += ` LIMIT ${options.limit}`;
444
573
  }
445
574
  const raw = (await asRawClient(db).unsafe(sqlText, values)) as readonly Record<string, unknown>[];
@@ -466,6 +595,7 @@ export async function insertMany<TRow = any>(
466
595
  rows: ReadonlyArray<Record<string, unknown>>,
467
596
  ): Promise<readonly TRow[]> {
468
597
  if (rows.length === 0) return [];
598
+ assertNotTenantScoped(db, "insertMany");
469
599
  const info = extractTableInfo(table);
470
600
  // Use the column-set from the first row; assume all rows share keys.
471
601
  const firstRow = rows[0];
@@ -502,6 +632,10 @@ export async function insertOne<TRow = any>(
502
632
  table: TableLike,
503
633
  values: Record<string, unknown>,
504
634
  ): Promise<TRow | undefined> {
635
+ const scoped = tenantDbDelegate(db);
636
+ if (scoped) {
637
+ return scoped.insertOne<TRow>(table, values);
638
+ }
505
639
  const info = extractTableInfo(table);
506
640
  const entries = Object.entries(values)
507
641
  .filter(([k]) => info.hasColumn(k))
@@ -537,6 +671,10 @@ export async function updateMany<TRow = any>(
537
671
  set: Record<string, unknown>,
538
672
  where: WhereObject,
539
673
  ): Promise<readonly TRow[]> {
674
+ const scoped = tenantDbDelegate(db);
675
+ if (scoped) {
676
+ return scoped.updateMany<TRow>(table, set, where);
677
+ }
540
678
  const info = extractTableInfo(table);
541
679
  const setEntries = Object.entries(set).map(([k, v]) => {
542
680
  const col = info.columnOf(k);
@@ -567,6 +705,10 @@ export async function updateMany<TRow = any>(
567
705
  }
568
706
 
569
707
  export async function deleteMany(db: AnyDb, table: TableLike, where: WhereObject): Promise<void> {
708
+ const scoped = tenantDbDelegate(db);
709
+ if (scoped) {
710
+ return scoped.deleteMany(table, where);
711
+ }
570
712
  const info = extractTableInfo(table);
571
713
  const w = buildWhereClause(info, where, 1);
572
714
  let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
@@ -675,6 +817,7 @@ export async function upsertOnConflict<TRow = any>(
675
817
  values: Record<string, unknown>,
676
818
  options: UpsertOnConflictOptions,
677
819
  ): Promise<TRow | undefined> {
820
+ assertNotTenantScoped(db, "upsertOnConflict");
678
821
  const info = extractTableInfo(table);
679
822
  const entries = insertEntries(info, values);
680
823
  const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
@@ -747,6 +890,7 @@ export async function incrementCounter<TRow = any>(
747
890
  increments: Record<string, number>,
748
891
  options: IncrementCounterOptions = {},
749
892
  ): Promise<TRow | undefined> {
893
+ assertNotTenantScoped(db, "incrementCounter");
750
894
  const info = extractTableInfo(table);
751
895
  const entries = insertEntries(info, values);
752
896
  const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
@@ -4,6 +4,10 @@ import { NotFoundError } from "../errors";
4
4
  import type { DbConnection } from "./connection";
5
5
  import type { TenantDb } from "./tenant-db";
6
6
 
7
+ function isTenantDb(db: DbConnection | TenantDb): db is TenantDb {
8
+ return typeof (db as TenantDb).fetchOne === "function" && "raw" in db;
9
+ }
10
+
7
11
  /**
8
12
  * Generic constraint helper: asserts a value exists in a table.
9
13
  * Returns a ready-to-return NotFoundError when the row is missing, or null
@@ -32,7 +36,7 @@ export async function assertExistsIn(
32
36
  if (options.tenantId !== undefined) where["tenantId"] = options.tenantId;
33
37
  if (options.where) Object.assign(where, options.where);
34
38
 
35
- const row = await fetchOne(db, entity, where);
39
+ const row = isTenantDb(db) ? await db.fetchOne(entity, where) : await fetchOne(db, entity, where);
36
40
 
37
41
  if (!row) {
38
42
  const entityName = options.entityName ?? String(options.field).replace(/Id$/, "");
package/src/db/dialect.ts CHANGED
@@ -94,6 +94,7 @@ type ColumnFinal = {
94
94
  readonly unique: boolean;
95
95
  readonly identity: boolean;
96
96
  readonly defaultSql?: string;
97
+ readonly bigintJsMode?: "number" | "bigint";
97
98
  };
98
99
 
99
100
  export type ColumnBuilder<TValue = unknown> = {
@@ -112,7 +113,11 @@ export type ColumnBuilder<TValue = unknown> = {
112
113
  $onUpdate(fn: () => unknown): ColumnBuilder<TValue>;
113
114
  };
114
115
 
115
- function buildColumn(sqlName: string, pgType: PgType): ColumnBuilder<unknown> {
116
+ function buildColumn(
117
+ sqlName: string,
118
+ pgType: PgType,
119
+ opts?: { bigintJsMode?: "number" | "bigint" },
120
+ ): ColumnBuilder<unknown> {
116
121
  let notNull = false;
117
122
  let primaryKey = false;
118
123
  let unique = false;
@@ -152,6 +157,7 @@ function buildColumn(sqlName: string, pgType: PgType): ColumnBuilder<unknown> {
152
157
  unique,
153
158
  identity,
154
159
  ...(defaultSql !== undefined && { defaultSql }),
160
+ ...(opts?.bigintJsMode !== undefined && { bigintJsMode: opts.bigintJsMode }),
155
161
  };
156
162
  },
157
163
  notNull() {
@@ -219,11 +225,9 @@ export function serial(name: string): ColumnBuilder<number> {
219
225
  return buildColumn(name, "serial") as ColumnBuilder<number>;
220
226
  }
221
227
 
222
- export function bigint(
223
- name: string,
224
- _opts?: { mode?: "bigint" | "number" },
225
- ): ColumnBuilder<bigint> {
226
- return buildColumn(name, "bigint") as ColumnBuilder<bigint>;
228
+ export function bigint(name: string, opts?: { mode?: "bigint" | "number" }): ColumnBuilder<bigint> {
229
+ const jsMode = opts?.mode === "number" ? "number" : "bigint";
230
+ return buildColumn(name, "bigint", { bigintJsMode: jsMode }) as ColumnBuilder<bigint>;
227
231
  }
228
232
 
229
233
  export function bigserial(
@@ -407,6 +411,7 @@ export function table<TCols extends ColumnMap>(
407
411
  ...(final.primaryKey && { primaryKey: true }),
408
412
  ...(final.identity && { identity: true }),
409
413
  ...(final.defaultSql !== undefined && { defaultSql: final.defaultSql }),
414
+ ...(final.bigintJsMode !== undefined && { bigintJsMode: final.bigintJsMode }),
410
415
  };
411
416
  columnMetas.push(meta);
412
417
 
@@ -51,6 +51,9 @@ export type ColumnMeta = {
51
51
  readonly defaultSql?: string;
52
52
  readonly primaryKey?: boolean;
53
53
  readonly identity?: boolean;
54
+ // bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
55
+ // drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
56
+ readonly bigintJsMode?: "number" | "bigint";
54
57
  };
55
58
 
56
59
  export type IndexMeta = {
@@ -199,6 +202,7 @@ function fieldToColumnMeta(
199
202
  name: snake,
200
203
  pgType: "bigint",
201
204
  notNull: field.required === true,
205
+ bigintJsMode: "number",
202
206
  ...(def !== undefined && { defaultSql: def }),
203
207
  },
204
208
  ];
@@ -211,7 +215,7 @@ function fieldToColumnMeta(
211
215
  case "money": {
212
216
  const cur = entity.defaultCurrency ?? "EUR";
213
217
  return [
214
- { name: snake, pgType: "bigint", notNull: field.required === true },
218
+ { name: snake, pgType: "bigint", notNull: field.required === true, bigintJsMode: "bigint" },
215
219
  {
216
220
  name: `${snake}_currency`,
217
221
  pgType: "text",
@@ -329,23 +333,22 @@ export function buildEntityTableMeta(
329
333
  indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
330
334
  }
331
335
 
332
- // Explizit deklarierte indexes (EntityIndexDef). `def.where` ist heute
333
- // ein drizzle SQL-AST wir können daraus keinen zuverlässigen Raw-SQL-
334
- // String rendern (queryChunks sind internal). Wenn ein where gesetzt
335
- // ist, markieren wir den IndexMeta mit needsManualWhere=true; der DDL-
336
- // Renderer emittiert das Statement dann als AUSKOMMENTIERT mit Warn-
337
- // Hinweis. App-Author muss das im generierten SQL-File hand-editieren.
336
+ // Explizit deklarierte indexes (EntityIndexDef). `def.where` ist ein
337
+ // SqlExpression (`sql\`…\`` aus @cosmicdrift/kumiko-framework/db)
338
+ // renderbar via `.text`. Unbekannte where-Shapes bleiben needsManualWhere.
338
339
  for (const def of (entity.indexes ?? []) as readonly EntityIndexDef[]) {
339
340
  const cols = def.columns.map(
340
341
  (fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName),
341
342
  );
342
343
  const suffix = def.unique === true ? "unique" : "idx";
343
344
  const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
345
+ const whereSql = sqlExpressionText(def.where);
344
346
  indexes.push({
345
347
  name: indexName,
346
348
  columns: cols,
347
349
  ...(def.unique === true && { unique: true }),
348
- ...(def.where !== undefined && { needsManualWhere: true }),
350
+ ...(whereSql !== undefined && { whereSql }),
351
+ ...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
349
352
  });
350
353
  }
351
354
 
@@ -377,6 +380,18 @@ export type UnmanagedTableInput = {
377
380
  readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
378
381
  };
379
382
 
383
+ function sqlExpressionText(where: unknown): string | undefined {
384
+ if (
385
+ typeof where === "object" &&
386
+ where !== null &&
387
+ (where as { kind?: unknown }).kind === "sql-expr" &&
388
+ typeof (where as { text?: unknown }).text === "string"
389
+ ) {
390
+ return (where as { text: string }).text;
391
+ }
392
+ return undefined;
393
+ }
394
+
380
395
  export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
381
396
  return {
382
397
  tableName: input.tableName,
package/src/db/index.ts CHANGED
@@ -51,6 +51,31 @@ export type {
51
51
  } from "./event-store-executor";
52
52
  export { createEventStoreExecutor, entityEventName } from "./event-store-executor";
53
53
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
54
+ export {
55
+ diffSnapshots,
56
+ type GenerateMigrationInput,
57
+ type GenerateMigrationOutput,
58
+ generateMigration,
59
+ loadSnapshotJson,
60
+ renderMigrationSql,
61
+ type SchemaDiff,
62
+ type Snapshot,
63
+ snapshotFromMetas,
64
+ writeSnapshotJson,
65
+ } from "./migrate-generator";
66
+ export {
67
+ type AppliedMigration,
68
+ type ApplyResult,
69
+ type BaselineResult,
70
+ baselineMigrations,
71
+ fetchAppliedMigrations,
72
+ loadMigrationsFromDir,
73
+ type Migration,
74
+ MigrationChecksumMismatchError,
75
+ runMigrations,
76
+ runMigrationsFromDir,
77
+ splitSqlStatements,
78
+ } from "./migrate-runner";
54
79
  export { flattenMoney, rehydrateMoney } from "./money";
55
80
  export {
56
81
  constraintOf,
@@ -111,7 +111,9 @@ async function executeRaw(db: DbRunner, sqlText: string): Promise<void> {
111
111
  await rawClient(db).unsafe(sqlText);
112
112
  }
113
113
 
114
- async function fetchAppliedMigrations(db: DbConnection): Promise<readonly AppliedMigration[]> {
114
+ export async function fetchAppliedMigrations(
115
+ db: DbConnection,
116
+ ): Promise<readonly AppliedMigration[]> {
115
117
  const result = await rawClient(db).unsafe(
116
118
  `SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`,
117
119
  );
@@ -204,3 +206,35 @@ export async function runMigrationsFromDir(db: DbConnection, dir: string): Promi
204
206
  const migrations = loadMigrationsFromDir(dir);
205
207
  return runMigrations(db, migrations);
206
208
  }
209
+
210
+ export type BaselineResult = {
211
+ readonly marked: readonly string[];
212
+ readonly alreadyTracked: readonly string[];
213
+ };
214
+
215
+ // Marks migrations as applied in `_kumiko_migrations` WITHOUT executing their
216
+ // SQL. For adopting an existing DB whose tables already exist — e.g. the
217
+ // cutover from the legacy drizzle-kit system, where re-running 0001_init would
218
+ // hit CREATE-TABLE conflicts. Idempotent: already-tracked ids are left as-is.
219
+ export async function baselineMigrations(
220
+ db: DbConnection,
221
+ migrations: readonly Migration[],
222
+ ): Promise<BaselineResult> {
223
+ await executeRaw(db, MIGRATIONS_TABLE_DDL);
224
+ const applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
225
+ const marked: string[] = [];
226
+ const alreadyTracked: string[] = [];
227
+ const client = rawClient(db);
228
+ for (const m of migrations) {
229
+ if (applied.has(m.id)) {
230
+ alreadyTracked.push(m.id);
231
+ continue;
232
+ }
233
+ await client.unsafe(
234
+ `INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2) ON CONFLICT ("id") DO NOTHING`,
235
+ [m.id, m.checksum],
236
+ );
237
+ marked.push(m.id);
238
+ }
239
+ return { marked, alreadyTracked };
240
+ }
package/src/db/money.ts CHANGED
@@ -106,6 +106,11 @@ export function rehydrateMoney(
106
106
  let amount: number;
107
107
  if (typeof amountRaw === "number") {
108
108
  amount = amountRaw;
109
+ } else if (typeof amountRaw === "bigint") {
110
+ amount = Number(amountRaw);
111
+ if (Number.isNaN(amount)) {
112
+ throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a number`);
113
+ }
109
114
  } else if (typeof amountRaw === "string" && amountRaw !== "") {
110
115
  // PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
111
116
  amount = Number(amountRaw);
@@ -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
+ }
@@ -4,6 +4,44 @@ import type { SessionUser } from "../engine/types";
4
4
 
5
5
  export type BatchCommand = { type: string; payload: unknown };
6
6
 
7
+ type WireErrorBody = {
8
+ readonly code?: string;
9
+ readonly details?: {
10
+ readonly causeName?: string;
11
+ readonly causeMessage?: string;
12
+ readonly causeStack?: string;
13
+ };
14
+ };
15
+
16
+ function formatWriteFailure(type: string, body: unknown): string {
17
+ const parsed = body as {
18
+ isSuccess?: boolean;
19
+ error?: WireErrorBody | string;
20
+ };
21
+ const code =
22
+ (typeof parsed.error === "object" ? parsed.error?.code : undefined) ??
23
+ (typeof parsed.error === "string" ? parsed.error : "unknown");
24
+ const details =
25
+ typeof parsed.error === "object" && parsed.error?.details !== undefined
26
+ ? parsed.error.details
27
+ : undefined;
28
+ const causeMessage =
29
+ details && typeof details === "object" && "causeMessage" in details
30
+ ? String((details as { causeMessage?: unknown }).causeMessage ?? "")
31
+ : "";
32
+ const causeName =
33
+ details && typeof details === "object" && "causeName" in details
34
+ ? String((details as { causeName?: unknown }).causeName ?? "")
35
+ : "";
36
+ if (code === "internal_error" && (causeMessage || causeName)) {
37
+ return `Expected write "${type}" to succeed but got error: ${code} (${causeName}: ${causeMessage})`;
38
+ }
39
+ if (details !== undefined) {
40
+ return `Expected write "${type}" to succeed but got error: ${code} — ${JSON.stringify(details)}`;
41
+ }
42
+ return `Expected write "${type}" to succeed but got error: ${code}`;
43
+ }
44
+
7
45
  export type RequestHelper = {
8
46
  write: (
9
47
  type: string,
@@ -123,10 +161,7 @@ export function createRequestHelper(app: Hono, jwt: JwtHelper): RequestHelper {
123
161
  // follow the error-contract shape { error: { code, i18nKey, ... } } with
124
162
  // a 4xx/5xx status — no isSuccess flag. Detect either.
125
163
  if (body.isSuccess !== true) {
126
- const code =
127
- (typeof body.error === "object" ? body.error?.code : undefined) ??
128
- (typeof body.error === "string" ? body.error : "unknown");
129
- throw new Error(`Expected write "${type}" to succeed but got error: ${code}`);
164
+ throw new Error(formatWriteFailure(type, body));
130
165
  }
131
166
  return body.data as T; // @cast-boundary engine-bridge
132
167
  },
@@ -107,7 +107,7 @@ export async function unsafePushTables(
107
107
  if (!prevIdxNames.has(idx.name)) {
108
108
  const kind = idx.unique ? "UNIQUE INDEX" : "INDEX";
109
109
  const colList = idx.columns.map((c) => `"${c}"`).join(", ");
110
- await createIndexIfNotExists(db, kind, idx.name, meta.tableName, colList);
110
+ await createIndexIfNotExists(db, kind, idx.name, meta.tableName, colList, idx.whereSql);
111
111
  }
112
112
  }
113
113
  } else {
@@ -4,5 +4,5 @@ export { readPositiveIntEnv } from "./env-parse";
4
4
  export { generateId } from "./ids";
5
5
  export { isPlainObject } from "./is-plain-object";
6
6
  export { parseStringArrayJson } from "./parse-string-array-json";
7
- export { parseJsonOrThrow, parseJsonSafe } from "./safe-json";
7
+ export { parseJsonOrThrow, parseJsonSafe, stringifyJson } from "./safe-json";
8
8
  export { parseRoles } from "./serialization";
@@ -28,3 +28,22 @@ export function parseJsonOrThrow<T>(raw: string, context: string): T {
28
28
  throw new Error(`Invalid JSON in ${context}: ${msg}`);
29
29
  }
30
30
  }
31
+
32
+ /** JSON.stringify that survives BigInt / Temporal values from DB rows. */
33
+ export function stringifyJson(value: unknown): string {
34
+ return JSON.stringify(value, (_key, v) => {
35
+ if (typeof v === "bigint") {
36
+ const asNumber = Number(v);
37
+ if (
38
+ asNumber <= Number.MAX_SAFE_INTEGER &&
39
+ asNumber >= Number.MIN_SAFE_INTEGER &&
40
+ BigInt(asNumber) === v
41
+ ) {
42
+ return asNumber;
43
+ }
44
+ return v.toString();
45
+ }
46
+ if (v instanceof Temporal.Instant) return v.toString();
47
+ return v;
48
+ });
49
+ }