@cosmicdrift/kumiko-framework 0.15.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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api/auth-routes.ts +2 -5
  3. package/src/api/routes.ts +9 -3
  4. package/src/bun-db/__tests__/query-guards.test.ts +53 -0
  5. package/src/bun-db/query.ts +164 -23
  6. package/src/compliance/profiles.ts +1 -4
  7. package/src/db/__tests__/cursor.test.ts +17 -0
  8. package/src/db/__tests__/migrate-generator.test.ts +71 -0
  9. package/src/db/__tests__/migrate-runner.test.ts +19 -0
  10. package/src/db/__tests__/pg-error.test.ts +43 -0
  11. package/src/db/assert-exists-in.ts +5 -1
  12. package/src/db/dialect.ts +11 -6
  13. package/src/db/entity-table-meta.ts +23 -8
  14. package/src/db/index.ts +25 -0
  15. package/src/db/migrate-runner.ts +35 -1
  16. package/src/db/money.ts +5 -0
  17. package/src/db/queries/test-stack.ts +3 -1
  18. package/src/db/table-builder.ts +6 -6
  19. package/src/engine/__tests__/duration-utils.test.ts +16 -0
  20. package/src/engine/__tests__/field-access.test.ts +38 -0
  21. package/src/engine/__tests__/no-return-guard.test.ts +17 -0
  22. package/src/engine/__tests__/unmanaged-table.test.ts +98 -0
  23. package/src/engine/define-feature.ts +36 -0
  24. package/src/engine/feature-ast/extractors/shared.ts +2 -3
  25. package/src/engine/index.ts +1 -1
  26. package/src/engine/registry.ts +19 -0
  27. package/src/engine/schema-builder.ts +1 -1
  28. package/src/engine/types/feature.ts +40 -0
  29. package/src/engine/types/index.ts +2 -0
  30. package/src/errors/__tests__/error-helpers.test.ts +44 -0
  31. package/src/errors/__tests__/field-issue-compat.test.ts +16 -0
  32. package/src/errors/classes.ts +5 -19
  33. package/src/errors/field-issue.ts +11 -0
  34. package/src/errors/index.ts +1 -0
  35. package/src/errors/zod-bridge.ts +3 -2
  36. package/src/es-ops/context.ts +2 -13
  37. package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +15 -12
  38. package/src/event-store/admin-api.ts +5 -4
  39. package/src/event-store/event-store.ts +3 -2
  40. package/src/event-store/snapshot.ts +2 -1
  41. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +161 -0
  42. package/src/migrations/index.ts +11 -1
  43. package/src/migrations/kumiko-drift.ts +122 -0
  44. package/src/pipeline/__tests__/dispatcher-utils.test.ts +107 -0
  45. package/src/pipeline/__tests__/redis-keys.test.ts +12 -0
  46. package/src/pipeline/dispatcher-utils.ts +8 -7
  47. package/src/stack/request-helper.ts +39 -4
  48. package/src/stack/table-helpers.ts +1 -1
  49. package/src/stack/test-stack.ts +2 -2
  50. package/src/utils/__tests__/case.test.ts +16 -0
  51. package/src/utils/__tests__/is-plain-object.test.ts +16 -0
  52. package/src/utils/__tests__/parse-string-array-json.test.ts +16 -0
  53. package/src/utils/__tests__/safe-json.test.ts +22 -0
  54. package/src/utils/case.ts +6 -0
  55. package/src/utils/index.ts +4 -1
  56. package/src/utils/is-plain-object.ts +4 -0
  57. package/src/utils/parse-string-array-json.ts +14 -0
  58. package/src/utils/safe-json.ts +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.15.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>",
@@ -6,6 +6,7 @@ import { createSystemUser } from "../engine/system-user";
6
6
  import { type SessionUser, SYSTEM_TENANT_ID, type TenantId } from "../engine/types";
7
7
  import { NotFoundError } from "../errors";
8
8
  import type { Dispatcher } from "../pipeline/dispatcher";
9
+ import { parseStringArrayJson } from "../utils/parse-string-array-json";
9
10
  import { Routes } from "./api-constants";
10
11
  import {
11
12
  AUTH_COOKIE_NAME,
@@ -819,11 +820,7 @@ export function createAuthRoutes(
819
820
  )) as { roles?: string | null } | null;
820
821
  const raw = userRow?.roles;
821
822
  if (typeof raw === "string" && raw.length > 0) {
822
- // @cast-boundary db-row — userTable.roles is JSON-encoded string[] per AuthUserRow contract
823
- const parsed = JSON.parse(raw) as unknown;
824
- if (Array.isArray(parsed) && parsed.every((r) => typeof r === "string")) {
825
- globalRoles = parsed;
826
- }
823
+ globalRoles = parseStringArrayJson(raw);
827
824
  }
828
825
  } catch (e) {
829
826
  // Non-fatal: globale Rollen kann nicht aufgelöst werden → switch
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
+ });
@@ -22,6 +22,7 @@
22
22
  import type { EntityTableMeta } from "../db/entity-table-meta";
23
23
  import { toSnakeCase } from "../db/table-builder";
24
24
  import { camelCase as envCamelCase } from "../env";
25
+ import { parseJsonSafe } from "../utils/safe-json";
25
26
 
26
27
  // Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
27
28
  // (designed for SHOUT_CASE input) — for already-camelCase keys (mock rows
@@ -106,6 +107,59 @@ export function asRawClient(db: unknown): RawClient {
106
107
  );
107
108
  }
108
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
+
109
163
  export type AnyDb = BunDbRunner | unknown;
110
164
 
111
165
  // WhereValue: primitive für eq, array für IN, null für IS NULL, oder
@@ -153,6 +207,8 @@ export type TableInfo = {
153
207
  readonly columnOf: (field: string) => string;
154
208
  // pgType per column-name, for jsonb-cast detection
155
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;
156
212
  // Inverse of columnOf — snake_case DB column → JS field-name (camelCase).
157
213
  // Used at the result boundary to rename row keys back to the API shape
158
214
  // that callers consume (`row.aggregateId` instead of `row.aggregate_id`).
@@ -173,8 +229,10 @@ export function extractTableInfo(table: TableLike): TableInfo {
173
229
  const colByField = new Map<string, string>();
174
230
  const fieldByCol = new Map<string, string>();
175
231
  const typeByCol = new Map<string, string>();
232
+ const bigintModeByCol = new Map<string, "number" | "bigint">();
176
233
  for (const c of meta.columns) {
177
234
  typeByCol.set(c.name, c.pgType);
235
+ if (c.bigintJsMode !== undefined) bigintModeByCol.set(c.name, c.bigintJsMode);
178
236
  // EntityTableMeta column names are snake_case. Map snake → snake AND
179
237
  // derive a camelCase JS field-name so result rows can be renamed back
180
238
  // to the API shape (`aggregate_id` → `aggregateId`).
@@ -187,6 +245,7 @@ export function extractTableInfo(table: TableLike): TableInfo {
187
245
  name: meta.tableName,
188
246
  columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
189
247
  pgTypeOf: (col) => typeByCol.get(col),
248
+ bigintJsModeOf: (col) => bigintModeByCol.get(col),
190
249
  fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
191
250
  hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
192
251
  };
@@ -204,6 +263,17 @@ export function extractTableInfo(table: TableLike): TableInfo {
204
263
  const colByField = new Map<string, string>();
205
264
  const fieldByCol = new Map<string, string>();
206
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
+ }
207
277
  for (const [field, { name: colName, sqlType }] of cols) {
208
278
  colByField.set(field, colName);
209
279
  fieldByCol.set(colName, field);
@@ -213,6 +283,7 @@ export function extractTableInfo(table: TableLike): TableInfo {
213
283
  name,
214
284
  columnOf: (field) => colByField.get(field) ?? toSnakeCase(field),
215
285
  pgTypeOf: (col) => typeByCol.get(col),
286
+ bigintJsModeOf: (col) => bigintModeByCol.get(col),
216
287
  fieldOf: (col) => fieldByCol.get(col) ?? snakeToCamel(col),
217
288
  hasColumn: (fieldOrColumn) => colByField.has(fieldOrColumn) || fieldByCol.has(fieldOrColumn),
218
289
  };
@@ -240,6 +311,30 @@ function isTemporalInstant(v: unknown): boolean {
240
311
  );
241
312
  }
242
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
+
243
338
  function instantFromDriver(value: unknown): Temporal.Instant | null {
244
339
  if (value === null || value === undefined) return null;
245
340
  if (isTemporalInstant(value)) return value as Temporal.Instant;
@@ -276,21 +371,31 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
276
371
  const t = instantFromDriver(value);
277
372
  if (t !== null) coerced = t;
278
373
  } else if (pgType === "jsonb" && typeof value === "string") {
279
- try {
280
- coerced = JSON.parse(value);
281
- } catch {
282
- // leave as string on parse error caller decides
283
- }
284
- } else if ((pgType === "bigint" || pgType === "bigserial") && typeof value === "string") {
285
- // postgres-js returns BIGINT as string to avoid JS-Number precision
286
- // loss past 2^53. Framework contract: bigint columns surface as
287
- // JS `bigint`. Drizzle's bigint customType did this conversion
288
- // invisibly; the native dialect rebuild needs it explicit.
289
- try {
290
- coerced = BigInt(value);
291
- } catch {
292
- // leave as string on parse error
374
+ coerced = parseJsonSafe(value, value);
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;
293
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;
294
399
  }
295
400
  const fieldName = info.fieldOf(key);
296
401
  if (fieldName !== key) changed = true;
@@ -307,8 +412,8 @@ function coerceRows<T extends Record<string, unknown>>(
307
412
  return rows.map((r) => coerceRow(r, info));
308
413
  }
309
414
 
310
- // Helper für jsonb-Werte: Bun.sql kann arrays/objects nicht direkt als
311
- // 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.
312
417
  // Plus Temporal.Instant → ISO string coercion for timestamptz columns.
313
418
  // SqlExpression (sql`now()`, sql`gen_random_uuid()`) wird als kind:"literal"
314
419
  // returned — Caller embedded das inline statt einen $N-placeholder zu setzen.
@@ -324,13 +429,21 @@ function prepareValue(value: unknown, pgType: string | undefined): PreparedValue
324
429
  if (isSqlExpression(value)) {
325
430
  return { kind: "literal", literal: value.text };
326
431
  }
327
- if (
328
- pgType === "jsonb" &&
329
- value !== null &&
330
- typeof value === "object" &&
331
- !isTemporalInstant(value)
332
- ) {
333
- 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
+ }
334
447
  }
335
448
  if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
336
449
  return { kind: "param", sql: "", bound: (value as Temporal.Instant).toString() };
@@ -368,6 +481,10 @@ function buildWhereClause(
368
481
  conditions.push(`${quoteIdent(col)} IN (${parts.join(", ")})`);
369
482
  }
370
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
+ }
371
488
  const opMap: Record<string, string> = {
372
489
  gt: ">",
373
490
  gte: ">=",
@@ -425,6 +542,10 @@ export async function selectMany<TRow = any>(
425
542
  where?: WhereObject,
426
543
  options?: SelectOptions,
427
544
  ): Promise<readonly TRow[]> {
545
+ const scoped = tenantDbDelegate(db);
546
+ if (scoped) {
547
+ return scoped.selectMany<TRow>(table, where, options);
548
+ }
428
549
  const info = extractTableInfo(table);
429
550
  let sqlText = `SELECT * FROM ${quoteIdent(info.name)}`;
430
551
  let values: unknown[] = [];
@@ -443,6 +564,11 @@ export async function selectMany<TRow = any>(
443
564
  if (parts.length > 0) sqlText += ` ORDER BY ${parts.join(", ")}`;
444
565
  }
445
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
+ }
446
572
  sqlText += ` LIMIT ${options.limit}`;
447
573
  }
448
574
  const raw = (await asRawClient(db).unsafe(sqlText, values)) as readonly Record<string, unknown>[];
@@ -469,6 +595,7 @@ export async function insertMany<TRow = any>(
469
595
  rows: ReadonlyArray<Record<string, unknown>>,
470
596
  ): Promise<readonly TRow[]> {
471
597
  if (rows.length === 0) return [];
598
+ assertNotTenantScoped(db, "insertMany");
472
599
  const info = extractTableInfo(table);
473
600
  // Use the column-set from the first row; assume all rows share keys.
474
601
  const firstRow = rows[0];
@@ -505,6 +632,10 @@ export async function insertOne<TRow = any>(
505
632
  table: TableLike,
506
633
  values: Record<string, unknown>,
507
634
  ): Promise<TRow | undefined> {
635
+ const scoped = tenantDbDelegate(db);
636
+ if (scoped) {
637
+ return scoped.insertOne<TRow>(table, values);
638
+ }
508
639
  const info = extractTableInfo(table);
509
640
  const entries = Object.entries(values)
510
641
  .filter(([k]) => info.hasColumn(k))
@@ -540,6 +671,10 @@ export async function updateMany<TRow = any>(
540
671
  set: Record<string, unknown>,
541
672
  where: WhereObject,
542
673
  ): Promise<readonly TRow[]> {
674
+ const scoped = tenantDbDelegate(db);
675
+ if (scoped) {
676
+ return scoped.updateMany<TRow>(table, set, where);
677
+ }
543
678
  const info = extractTableInfo(table);
544
679
  const setEntries = Object.entries(set).map(([k, v]) => {
545
680
  const col = info.columnOf(k);
@@ -570,6 +705,10 @@ export async function updateMany<TRow = any>(
570
705
  }
571
706
 
572
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
+ }
573
712
  const info = extractTableInfo(table);
574
713
  const w = buildWhereClause(info, where, 1);
575
714
  let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
@@ -678,6 +817,7 @@ export async function upsertOnConflict<TRow = any>(
678
817
  values: Record<string, unknown>,
679
818
  options: UpsertOnConflictOptions,
680
819
  ): Promise<TRow | undefined> {
820
+ assertNotTenantScoped(db, "upsertOnConflict");
681
821
  const info = extractTableInfo(table);
682
822
  const entries = insertEntries(info, values);
683
823
  const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
@@ -750,6 +890,7 @@ export async function incrementCounter<TRow = any>(
750
890
  increments: Record<string, number>,
751
891
  options: IncrementCounterOptions = {},
752
892
  ): Promise<TRow | undefined> {
893
+ assertNotTenantScoped(db, "incrementCounter");
753
894
  const info = extractTableInfo(table);
754
895
  const entries = insertEntries(info, values);
755
896
  const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
@@ -33,6 +33,7 @@
33
33
  //
34
34
  // Siehe docs/plans/datenschutz/compliance-profiles.md.
35
35
 
36
+ import { isPlainObject } from "../utils/is-plain-object";
36
37
  import type { BundleTier } from "./sub-processors";
37
38
 
38
39
  // --- Profile-Schema ---
@@ -302,10 +303,6 @@ export type ComplianceProfileOverride = DeepReadonly<DeepPartial<ComplianceProfi
302
303
 
303
304
  type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
304
305
 
305
- function isPlainObject(v: unknown): v is Record<string, unknown> {
306
- return typeof v === "object" && v !== null && !Array.isArray(v);
307
- }
308
-
309
306
  // Pfade die als ganzes Atom ersetzt werden (NICHT rekursiv gemergt).
310
307
  // Notwendig fuer Diskriminierte-Union-Types wo das Patch ein Schwester-
311
308
  // Property statt einer Override sein kann — z.B. retention von
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { decodeCursor, encodeCursor } from "../cursor";
3
+
4
+ describe("encodeCursor / decodeCursor", () => {
5
+ test("round-trips string ids", () => {
6
+ const id = "0194a1b2-c3d4-7890-abcd-ef1234567890";
7
+ expect(decodeCursor(encodeCursor(id))).toBe(id);
8
+ });
9
+
10
+ test("round-trips numeric ids", () => {
11
+ expect(decodeCursor(encodeCursor(42))).toBe("42");
12
+ });
13
+
14
+ test("decodeCursor throws on empty payload", () => {
15
+ expect(() => decodeCursor(encodeCursor(""))).toThrow(/Invalid cursor/);
16
+ });
17
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { EntityTableMeta } from "../entity-table-meta";
3
+ import {
4
+ diffSnapshots,
5
+ generateMigration,
6
+ renderMigrationSql,
7
+ snapshotFromMetas,
8
+ } from "../migrate-generator";
9
+
10
+ function meta(
11
+ tableName: string,
12
+ extraColumn?: EntityTableMeta["columns"][number],
13
+ ): EntityTableMeta {
14
+ return {
15
+ tableName,
16
+ source: "unmanaged",
17
+ indexes: [],
18
+ columns: [
19
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
20
+ ...(extraColumn ? [extraColumn] : []),
21
+ ],
22
+ };
23
+ }
24
+
25
+ describe("snapshotFromMetas", () => {
26
+ test("sorts tables by name for stable snapshots", () => {
27
+ const snap = snapshotFromMetas([meta("zebras"), meta("apples")]);
28
+ expect(snap.tables.map((t) => t.tableName)).toEqual(["apples", "zebras"]);
29
+ expect(snap.version).toBe(1);
30
+ });
31
+ });
32
+
33
+ describe("diffSnapshots", () => {
34
+ test("null prev → all tables are new", () => {
35
+ const next = snapshotFromMetas([meta("tasks")]);
36
+ const diff = diffSnapshots(null, next);
37
+ expect(diff.newTables.map((t) => t.tableName)).toEqual(["tasks"]);
38
+ expect(diff.droppedTables).toEqual([]);
39
+ });
40
+
41
+ test("detects dropped table and new column", () => {
42
+ const prev = snapshotFromMetas([meta("tasks"), meta("legacy")]);
43
+ const next = snapshotFromMetas([
44
+ meta("tasks", { name: "title", pgType: "text", notNull: true }),
45
+ ]);
46
+ const diff = diffSnapshots(prev, next);
47
+ expect(diff.droppedTables).toEqual(["legacy"]);
48
+ expect(diff.changedTables[0]?.newColumns.map((c) => c.name)).toEqual(["title"]);
49
+ });
50
+ });
51
+
52
+ describe("renderMigrationSql / generateMigration", () => {
53
+ test("emits CREATE TABLE for new tables", () => {
54
+ const diff = diffSnapshots(null, snapshotFromMetas([meta("tasks")]));
55
+ const sql = renderMigrationSql(diff, { name: "init", sequenceNumber: 1 });
56
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS "tasks"');
57
+ expect(sql).toContain("Migration 0001_init");
58
+ });
59
+
60
+ test("generateMigration bundles snapshot + sql", () => {
61
+ const out = generateMigration({
62
+ metas: [meta("tasks")],
63
+ prevSnapshot: null,
64
+ name: "init",
65
+ sequenceNumber: 1,
66
+ });
67
+ expect(out.snapshot.tables).toHaveLength(1);
68
+ expect(out.sqlContent).toContain("0001_init");
69
+ expect(out.filename).toBe("0001_init.sql");
70
+ });
71
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { splitSqlStatements } from "../migrate-runner";
3
+
4
+ describe("splitSqlStatements", () => {
5
+ test("splits on semicolons and strips line comments", () => {
6
+ const sql = `
7
+ CREATE TABLE "a" (id uuid); -- inline comment
8
+ CREATE TABLE "b" (id uuid);
9
+ `;
10
+ expect(splitSqlStatements(sql)).toEqual([
11
+ 'CREATE TABLE "a" (id uuid);',
12
+ 'CREATE TABLE "b" (id uuid);',
13
+ ]);
14
+ });
15
+
16
+ test("filters empty segments", () => {
17
+ expect(splitSqlStatements("-- only comments\n; ;")).toEqual([]);
18
+ });
19
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation } from "../pg-error";
3
+
4
+ describe("extractPgError", () => {
5
+ test("reads code from top-level postgres-js error", () => {
6
+ const info = extractPgError({ code: "23505", constraint_name: "users_email_uq" });
7
+ expect(info).toEqual({ code: "23505", constraint_name: "users_email_uq" });
8
+ });
9
+
10
+ test("unwraps DrizzleQueryError.cause", () => {
11
+ const info = extractPgError({
12
+ message: "wrapper",
13
+ cause: { code: "23505", constraint_name: "uq" },
14
+ });
15
+ expect(info?.code).toBe("23505");
16
+ });
17
+
18
+ test("returns null for non-objects", () => {
19
+ expect(extractPgError("nope")).toBeNull();
20
+ });
21
+ });
22
+
23
+ describe("isUniqueViolation", () => {
24
+ test("true for SQLSTATE 23505", () => {
25
+ expect(isUniqueViolation({ code: "23505" })).toBe(true);
26
+ });
27
+
28
+ test("false otherwise", () => {
29
+ expect(isUniqueViolation({ code: "23503" })).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("isTableAlreadyExists", () => {
34
+ test("true for SQLSTATE 42P07", () => {
35
+ expect(isTableAlreadyExists({ code: "42P07" })).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("constraintOf", () => {
40
+ test("returns constraint_name when present", () => {
41
+ expect(constraintOf({ constraint_name: "users_email_uq" })).toBe("users_email_uq");
42
+ });
43
+ });
@@ -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$/, "");