@cosmicdrift/kumiko-framework 0.16.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/api/routes.ts +9 -3
- package/src/bun-db/__tests__/query-guards.test.ts +53 -0
- package/src/bun-db/query.ts +162 -18
- 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/engine/index.ts +1 -1
- package/src/engine/schema-builder.ts +1 -1
- 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/schema-cli.ts +228 -0
- package/src/stack/request-helper.ts +39 -4
- package/src/stack/table-helpers.ts +1 -1
- package/src/utils/index.ts +1 -1
- 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.19.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>",
|
|
@@ -127,6 +127,10 @@
|
|
|
127
127
|
"types": "./src/secrets/index.ts",
|
|
128
128
|
"default": "./src/secrets/index.ts"
|
|
129
129
|
},
|
|
130
|
+
"./schema-cli": {
|
|
131
|
+
"types": "./src/schema-cli.ts",
|
|
132
|
+
"default": "./src/schema-cli.ts"
|
|
133
|
+
},
|
|
130
134
|
"./stack": {
|
|
131
135
|
"types": "./src/stack/index.ts",
|
|
132
136
|
"default": "./src/stack/index.ts"
|
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
|
@@ -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 (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
coerced =
|
|
288
|
-
}
|
|
289
|
-
|
|
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:
|
|
308
|
-
// 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.
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
!isTemporalInstant(value)
|
|
329
|
-
|
|
330
|
-
|
|
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(
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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
|
-
...(
|
|
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,
|
package/src/db/migrate-runner.ts
CHANGED
|
@@ -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(
|
|
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);
|