@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.
- package/package.json +1 -1
- package/src/api/auth-routes.ts +2 -5
- package/src/api/routes.ts +9 -3
- package/src/bun-db/__tests__/query-guards.test.ts +53 -0
- package/src/bun-db/query.ts +164 -23
- package/src/compliance/profiles.ts +1 -4
- package/src/db/__tests__/cursor.test.ts +17 -0
- package/src/db/__tests__/migrate-generator.test.ts +71 -0
- package/src/db/__tests__/migrate-runner.test.ts +19 -0
- package/src/db/__tests__/pg-error.test.ts +43 -0
- package/src/db/assert-exists-in.ts +5 -1
- package/src/db/dialect.ts +11 -6
- package/src/db/entity-table-meta.ts +23 -8
- package/src/db/index.ts +25 -0
- package/src/db/migrate-runner.ts +35 -1
- package/src/db/money.ts +5 -0
- package/src/db/queries/test-stack.ts +3 -1
- package/src/db/table-builder.ts +6 -6
- package/src/engine/__tests__/duration-utils.test.ts +16 -0
- package/src/engine/__tests__/field-access.test.ts +38 -0
- package/src/engine/__tests__/no-return-guard.test.ts +17 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +98 -0
- package/src/engine/define-feature.ts +36 -0
- package/src/engine/feature-ast/extractors/shared.ts +2 -3
- package/src/engine/index.ts +1 -1
- package/src/engine/registry.ts +19 -0
- package/src/engine/schema-builder.ts +1 -1
- package/src/engine/types/feature.ts +40 -0
- package/src/engine/types/index.ts +2 -0
- package/src/errors/__tests__/error-helpers.test.ts +44 -0
- package/src/errors/__tests__/field-issue-compat.test.ts +16 -0
- package/src/errors/classes.ts +5 -19
- package/src/errors/field-issue.ts +11 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/zod-bridge.ts +3 -2
- package/src/es-ops/context.ts +2 -13
- package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +15 -12
- package/src/event-store/admin-api.ts +5 -4
- package/src/event-store/event-store.ts +3 -2
- package/src/event-store/snapshot.ts +2 -1
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +161 -0
- package/src/migrations/index.ts +11 -1
- package/src/migrations/kumiko-drift.ts +122 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +107 -0
- package/src/pipeline/__tests__/redis-keys.test.ts +12 -0
- package/src/pipeline/dispatcher-utils.ts +8 -7
- package/src/stack/request-helper.ts +39 -4
- package/src/stack/table-helpers.ts +1 -1
- package/src/stack/test-stack.ts +2 -2
- package/src/utils/__tests__/case.test.ts +16 -0
- package/src/utils/__tests__/is-plain-object.test.ts +16 -0
- package/src/utils/__tests__/parse-string-array-json.test.ts +16 -0
- package/src/utils/__tests__/safe-json.test.ts +22 -0
- package/src/utils/case.ts +6 -0
- package/src/utils/index.ts +4 -1
- package/src/utils/is-plain-object.ts +4 -0
- package/src/utils/parse-string-array-json.ts +14 -0
- 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.
|
|
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/auth-routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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:
|
|
311
|
-
// jsonb
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
!isTemporalInstant(value)
|
|
332
|
-
|
|
333
|
-
|
|
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$/, "");
|