@cosmicdrift/kumiko-framework 0.197.1 → 0.198.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 +3 -3
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/sse-route.ts +13 -1
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
- package/src/bun-db/query.ts +5 -1
- package/src/db/__tests__/compound-types.test.ts +12 -2
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
- package/src/db/__tests__/money.test.ts +49 -18
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/derivatives/derivatives-context.ts +9 -0
- package/src/engine/__tests__/build-app-schema.test.ts +20 -0
- package/src/engine/__tests__/nav.test.ts +12 -4
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/types/index.ts +7 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
- package/src/entrypoint/index.ts +20 -3
- package/src/files/__tests__/files.integration.test.ts +16 -0
- package/src/files/file-routes.ts +12 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +32 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
- package/src/pipeline/dispatch-batch.ts +2 -2
- package/src/pipeline/idempotency.ts +11 -6
- package/src/ui-types/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.198.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>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.198.0",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.198.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -407,7 +407,7 @@ describe("POST /api/batch", () => {
|
|
|
407
407
|
|
|
408
408
|
test("idempotency: corrupted cache entry is treated as miss and re-runs", async () => {
|
|
409
409
|
const requestId = "batch-rid-corrupt";
|
|
410
|
-
const cacheKey = `${RedisKeys.idempotency}${requestId}`;
|
|
410
|
+
const cacheKey = `${RedisKeys.idempotency}${admin.tenantId}:${admin.id}:${requestId}`;
|
|
411
411
|
|
|
412
412
|
// Prove key-coupling first: seed a well-formed cached entry under the
|
|
413
413
|
// exact manually-built key and confirm the batch short-circuits on it
|
|
@@ -46,6 +46,66 @@ async function buildSseApp(broker: SseBroker): Promise<{ app: Hono; token: strin
|
|
|
46
46
|
return { app, token };
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// createTrackingBroker's addClient discards the `send` callback — fine for
|
|
50
|
+
// the channel-scoping tests above, but frame-naming tests need to capture
|
|
51
|
+
// it and actually push an event through.
|
|
52
|
+
function createSendCapturingBroker(): {
|
|
53
|
+
broker: SseBroker;
|
|
54
|
+
send: Promise<(event: SseEvent) => void>;
|
|
55
|
+
} {
|
|
56
|
+
let resolveSend!: (send: (event: SseEvent) => void) => void;
|
|
57
|
+
const send = new Promise<(event: SseEvent) => void>((resolve) => {
|
|
58
|
+
resolveSend = resolve;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const broker: SseBroker = {
|
|
62
|
+
addClient(_channel, sendFn) {
|
|
63
|
+
resolveSend(sendFn);
|
|
64
|
+
return "test-client-id";
|
|
65
|
+
},
|
|
66
|
+
removeClient() {},
|
|
67
|
+
pushToChannel() {},
|
|
68
|
+
getClientCount() {
|
|
69
|
+
return 0;
|
|
70
|
+
},
|
|
71
|
+
getTotalClientCount() {
|
|
72
|
+
return 0;
|
|
73
|
+
},
|
|
74
|
+
subscribeAccessInvalidation() {
|
|
75
|
+
return () => {};
|
|
76
|
+
},
|
|
77
|
+
publishAccessInvalidation() {},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return { broker, send };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The stream's first frame is always the immediate heartbeat `ping` (see
|
|
84
|
+
// SSE_HEARTBEAT_INTERVAL_MS's while-loop in sse-route.ts) — skip it and
|
|
85
|
+
// return the first real frame.
|
|
86
|
+
async function readNextEntityFrame(
|
|
87
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
88
|
+
): Promise<{ event: string; data: string }> {
|
|
89
|
+
const decoder = new TextDecoder();
|
|
90
|
+
let buffer = "";
|
|
91
|
+
while (true) {
|
|
92
|
+
const { value, done } = await reader.read();
|
|
93
|
+
if (done) throw new Error("SSE stream ended before a non-ping frame arrived");
|
|
94
|
+
buffer += decoder.decode(value, { stream: true });
|
|
95
|
+
let separatorIndex = buffer.indexOf("\n\n");
|
|
96
|
+
while (separatorIndex !== -1) {
|
|
97
|
+
const frame = buffer.slice(0, separatorIndex);
|
|
98
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
99
|
+
const eventName = frame.match(/^event: (.*)$/m)?.[1];
|
|
100
|
+
if (eventName !== undefined && eventName !== "ping") {
|
|
101
|
+
const data = frame.match(/^data: (.*)$/m)?.[1] ?? "";
|
|
102
|
+
return { event: eventName, data };
|
|
103
|
+
}
|
|
104
|
+
separatorIndex = buffer.indexOf("\n\n");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
49
109
|
describe("sse-route security", () => {
|
|
50
110
|
test("subscribes to authenticated tenant channel, ignores client query-param", async () => {
|
|
51
111
|
const { broker, subscribedChannel } = createTrackingBroker();
|
|
@@ -114,3 +174,72 @@ describe("sse-route security", () => {
|
|
|
114
174
|
expect(channel).toBe("tenant:00000000-0000-4000-8000-000000000001");
|
|
115
175
|
});
|
|
116
176
|
});
|
|
177
|
+
|
|
178
|
+
describe("sse-route frame naming", () => {
|
|
179
|
+
test("entity events broadcast under the entity-name frame, not the verb", async () => {
|
|
180
|
+
const { broker, send } = createSendCapturingBroker();
|
|
181
|
+
const { app, token } = await buildSseApp(broker);
|
|
182
|
+
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const responsePromise = Promise.resolve(
|
|
185
|
+
app.request("/api/sse", {
|
|
186
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
187
|
+
signal: controller.signal,
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const sendEvent = await send;
|
|
192
|
+
const response = await responsePromise;
|
|
193
|
+
const reader = response.body!.getReader();
|
|
194
|
+
|
|
195
|
+
sendEvent({
|
|
196
|
+
type: "user.created",
|
|
197
|
+
data: {
|
|
198
|
+
id: "u1",
|
|
199
|
+
aggregateType: "user",
|
|
200
|
+
version: 1,
|
|
201
|
+
payload: {},
|
|
202
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const frame = await readNextEntityFrame(reader);
|
|
207
|
+
controller.abort();
|
|
208
|
+
|
|
209
|
+
expect(frame.event).toBe("user");
|
|
210
|
+
expect(JSON.parse(frame.data)).toEqual({
|
|
211
|
+
id: "u1",
|
|
212
|
+
aggregateType: "user",
|
|
213
|
+
version: 1,
|
|
214
|
+
payload: {},
|
|
215
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("non-entity events (no aggregateType) keep event.type as the frame name", async () => {
|
|
220
|
+
const { broker, send } = createSendCapturingBroker();
|
|
221
|
+
const { app, token } = await buildSseApp(broker);
|
|
222
|
+
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
const responsePromise = Promise.resolve(
|
|
225
|
+
app.request("/api/sse", {
|
|
226
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
227
|
+
signal: controller.signal,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const sendEvent = await send;
|
|
232
|
+
const response = await responsePromise;
|
|
233
|
+
const reader = response.body!.getReader();
|
|
234
|
+
|
|
235
|
+
sendEvent({
|
|
236
|
+
type: "channel-in-app:event:delivered",
|
|
237
|
+
data: { id: "m1", userId: "u1", notificationType: "info", title: "Hi" },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const frame = await readNextEntityFrame(reader);
|
|
241
|
+
controller.abort();
|
|
242
|
+
|
|
243
|
+
expect(frame.event).toBe("channel-in-app:event:delivered");
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -1195,12 +1195,27 @@ export function createAuthRoutes(
|
|
|
1195
1195
|
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
|
|
1196
1196
|
return c.json({ isSuccess: false, error: result.error }, status);
|
|
1197
1197
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1198
|
+
// @cast-boundary engine-payload — same three-shape union as /auth/login
|
|
1199
|
+
// (see gateEnforceMfa): a straight session, an MFA challenge, or a
|
|
1200
|
+
// hard mfa-setup-required block. Only the auth-session branch also
|
|
1201
|
+
// carries tenantId/role (invite-specific).
|
|
1202
|
+
const data = result.data as
|
|
1203
|
+
| { kind: "auth-session"; session: SessionUser; tenantId: TenantId; role: string }
|
|
1204
|
+
| { kind: "mfa-challenge"; challengeToken: string }
|
|
1205
|
+
| { kind: "mfa-setup-required"; preauthSetupToken: string };
|
|
1206
|
+
|
|
1207
|
+
if (data.kind === "mfa-setup-required") {
|
|
1208
|
+
return c.json({
|
|
1209
|
+
isSuccess: true,
|
|
1210
|
+
mfaSetupRequired: true,
|
|
1211
|
+
preauthSetupToken: data.preauthSetupToken,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (data.kind === "mfa-challenge") {
|
|
1216
|
+
return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1204
1219
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1205
1220
|
return c.json({
|
|
1206
1221
|
isSuccess: true,
|
package/src/api/sse-route.ts
CHANGED
|
@@ -27,6 +27,15 @@ import type { SseBroker } from "./sse-broker";
|
|
|
27
27
|
*/
|
|
28
28
|
export const SSE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
29
29
|
|
|
30
|
+
// Entity events carry aggregateType in data (system-hooks.ts's SSE-broadcast
|
|
31
|
+
// consumer) — the wire frame is named after the entity so the client can
|
|
32
|
+
// wire a single listener per entity instead of one per verb. Non-entity
|
|
33
|
+
// events (e.g. channel-in-app:event:delivered) have no aggregateType and
|
|
34
|
+
// keep their event.type as the frame name.
|
|
35
|
+
function isEntityEventData(data: Record<string, unknown>): data is { aggregateType: string } {
|
|
36
|
+
return typeof data["aggregateType"] === "string";
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
export function createSseRoute(broker: SseBroker) {
|
|
31
40
|
const route = new Hono();
|
|
32
41
|
|
|
@@ -40,7 +49,10 @@ export function createSseRoute(broker: SseBroker) {
|
|
|
40
49
|
const clientId = broker.addClient(
|
|
41
50
|
channel,
|
|
42
51
|
(event) => {
|
|
43
|
-
|
|
52
|
+
const wireEventName = isEntityEventData(event.data)
|
|
53
|
+
? event.data.aggregateType
|
|
54
|
+
: event.type;
|
|
55
|
+
stream.writeSSE({ event: wireEventName, data: JSON.stringify(event.data) });
|
|
44
56
|
},
|
|
45
57
|
() => stream.close(),
|
|
46
58
|
);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sql } from "../../db/dialect";
|
|
3
|
+
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
4
|
+
import { insertOne, updateMany } from "../query";
|
|
5
|
+
|
|
6
|
+
const meta: EntityTableMeta = {
|
|
7
|
+
source: "unmanaged",
|
|
8
|
+
tableName: "sql_expr_brand_items",
|
|
9
|
+
indexes: [],
|
|
10
|
+
columns: [
|
|
11
|
+
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
12
|
+
{ name: "payload", pgType: "jsonb", notNull: false },
|
|
13
|
+
{ name: "created_at", pgType: "timestamptz", notNull: false },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Captures exactly what insertOne/updateMany hand to the driver, so the
|
|
18
|
+
// assertions below check the actual SQL text + bound params — not just that
|
|
19
|
+
// the call didn't throw.
|
|
20
|
+
function makeRecordingDb() {
|
|
21
|
+
const calls: Array<{ sqlText: string; params: readonly unknown[] }> = [];
|
|
22
|
+
const db = {
|
|
23
|
+
unsafe: async (sqlText: string, params: readonly unknown[]) => {
|
|
24
|
+
calls.push({ sqlText, params });
|
|
25
|
+
return [{ id: "1" }];
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
return { db, calls };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("bun-db sql-expr brand — request-supplied objects can't fake a SQL literal", () => {
|
|
32
|
+
test("insertOne treats an unbranded {kind:'sql-expr'} jsonb value as ordinary data, never inlined SQL", async () => {
|
|
33
|
+
const { db, calls } = makeRecordingDb();
|
|
34
|
+
const forged = {
|
|
35
|
+
kind: "sql-expr",
|
|
36
|
+
text: "'; DROP TABLE sql_expr_brand_items; --",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
await insertOne(db, meta, { id: "1", payload: forged });
|
|
40
|
+
|
|
41
|
+
expect(calls).toHaveLength(1);
|
|
42
|
+
const { sqlText, params } = calls[0]!;
|
|
43
|
+
expect(sqlText).not.toContain("DROP TABLE");
|
|
44
|
+
expect(sqlText).toContain("$2");
|
|
45
|
+
expect(params).toContainEqual(forged);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("updateMany treats an unbranded {kind:'sql-expr'} jsonb value as ordinary data, never inlined SQL", async () => {
|
|
49
|
+
const { db, calls } = makeRecordingDb();
|
|
50
|
+
const forged = {
|
|
51
|
+
kind: "sql-expr",
|
|
52
|
+
text: "'; DROP TABLE sql_expr_brand_items; --",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
await updateMany(db, meta, { payload: forged }, { id: "1" });
|
|
56
|
+
|
|
57
|
+
expect(calls).toHaveLength(1);
|
|
58
|
+
const { sqlText, params } = calls[0]!;
|
|
59
|
+
expect(sqlText).not.toContain("DROP TABLE");
|
|
60
|
+
expect(params).toContainEqual(forged);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("insertOne still inlines a legitimately-built sql`...` expression as a literal", async () => {
|
|
64
|
+
const { db, calls } = makeRecordingDb();
|
|
65
|
+
|
|
66
|
+
await insertOne(db, meta, { id: "1", createdAt: sql`now()` });
|
|
67
|
+
|
|
68
|
+
expect(calls).toHaveLength(1);
|
|
69
|
+
const { sqlText, params } = calls[0]!;
|
|
70
|
+
expect(sqlText).toContain("now()");
|
|
71
|
+
expect(params).not.toContain("now()");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("updateMany still inlines a legitimately-built sql`...` expression as a literal", async () => {
|
|
75
|
+
const { db, calls } = makeRecordingDb();
|
|
76
|
+
|
|
77
|
+
await updateMany(db, meta, { createdAt: sql`now()` }, { id: "1" });
|
|
78
|
+
|
|
79
|
+
expect(calls).toHaveLength(1);
|
|
80
|
+
const { sqlText } = calls[0]!;
|
|
81
|
+
expect(sqlText).toContain('"created_at" = now()');
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type {
|
|
|
29
29
|
// globalThis, so instantFromDriver crashed on timestamptz reads (#1480).
|
|
30
30
|
import { Temporal } from "temporal-polyfill";
|
|
31
31
|
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto/blind-index";
|
|
32
|
+
import { SQL_EXPR_BRAND } from "../db/dialect";
|
|
32
33
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
33
34
|
import { extractPgError } from "../db/pg-error";
|
|
34
35
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
@@ -501,8 +502,11 @@ type PreparedValue =
|
|
|
501
502
|
| { readonly kind: "param"; readonly sql: string; readonly bound: unknown }
|
|
502
503
|
| { readonly kind: "literal"; readonly literal: string };
|
|
503
504
|
|
|
505
|
+
// Checks the brand Symbol, not the `kind` string — a client-supplied jsonb
|
|
506
|
+
// value can fake `kind: "sql-expr"` over JSON but can never carry a Symbol,
|
|
507
|
+
// so request data can't be smuggled in as a raw SQL literal.
|
|
504
508
|
function isSqlExpression(v: unknown): v is { kind: "sql-expr"; text: string } {
|
|
505
|
-
return typeof v === "object" && v !== null &&
|
|
509
|
+
return typeof v === "object" && v !== null && SQL_EXPR_BRAND in v;
|
|
506
510
|
}
|
|
507
511
|
|
|
508
512
|
// A `date` column takes a plain "yyyy-mm-dd" string (or PlainDate.toString())
|
|
@@ -85,7 +85,12 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
|
|
|
85
85
|
label: "ACME",
|
|
86
86
|
pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" },
|
|
87
87
|
// rehydrateMoney converts minor units (DB) → major units (API, ÷100).
|
|
88
|
-
buyingPrice: {
|
|
88
|
+
buyingPrice: {
|
|
89
|
+
amount: 45_000,
|
|
90
|
+
currency: "EUR",
|
|
91
|
+
amountScaled: 4_500_000,
|
|
92
|
+
amountMinor: 4_500_000,
|
|
93
|
+
},
|
|
89
94
|
});
|
|
90
95
|
});
|
|
91
96
|
|
|
@@ -98,7 +103,12 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
|
|
|
98
103
|
const round = rehydrateCompoundTypes(flattenCompoundTypes(original, mixedEntity), mixedEntity);
|
|
99
104
|
// pickup bekommt utc dazu beim Read (war beim Insert nicht gesetzt)
|
|
100
105
|
expect((round["pickup"] as { utc: string }).utc).toBe("2026-04-15T09:00:00Z");
|
|
101
|
-
expect(round["buyingPrice"]).toEqual({
|
|
106
|
+
expect(round["buyingPrice"]).toEqual({
|
|
107
|
+
amount: 100,
|
|
108
|
+
currency: "EUR",
|
|
109
|
+
amountScaled: 10_000,
|
|
110
|
+
amountMinor: 10_000,
|
|
111
|
+
});
|
|
102
112
|
expect(round["label"]).toBe("ACME");
|
|
103
113
|
});
|
|
104
114
|
|
|
@@ -8,6 +8,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
8
8
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
9
9
|
import { asRawClient } from "../../db/query";
|
|
10
10
|
import { createEntity, createNumberField, createTextField } from "../../engine";
|
|
11
|
+
import { UnprocessableError } from "../../errors";
|
|
11
12
|
import { createEventsTable } from "../../event-store";
|
|
12
13
|
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
13
14
|
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
@@ -362,12 +363,21 @@ describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-F
|
|
|
362
363
|
// der executor zur Definition-Time keinen ctx-Adapter kennt.
|
|
363
364
|
const exec = createEventStoreExecutor(table, entity, { entityName: "pagerItem" });
|
|
364
365
|
|
|
365
|
-
test("ohne searchAdapter: search-Param
|
|
366
|
+
test("ohne searchAdapter: search-Param wirft statt still zu verpuffen (#2032)", async () => {
|
|
366
367
|
for (let i = 0; i < 3; i++) {
|
|
367
368
|
await exec.create({ title: `item-${i}`, rank: i }, admin, tdb);
|
|
368
369
|
}
|
|
369
|
-
const
|
|
370
|
-
expect(
|
|
370
|
+
const call = exec.list({ limit: 50, search: "irgendwas" }, admin, tdb);
|
|
371
|
+
await expect(call).rejects.toThrow(UnprocessableError);
|
|
372
|
+
await expect(call.catch((e: unknown) => e)).resolves.toMatchObject({
|
|
373
|
+
code: "unprocessable",
|
|
374
|
+
httpStatus: 422,
|
|
375
|
+
details: {
|
|
376
|
+
reason: "search_adapter_not_wired",
|
|
377
|
+
entity: "pagerItem",
|
|
378
|
+
hint: expect.stringContaining("SearchAdapter"),
|
|
379
|
+
},
|
|
380
|
+
});
|
|
371
381
|
});
|
|
372
382
|
|
|
373
383
|
test("mit runtimeOptions.searchAdapter: search filtert auf returned IDs", async () => {
|
|
@@ -88,7 +88,12 @@ describe("event-store-executor — money column rehydration through raw SQL (fw#
|
|
|
88
88
|
const res = await exec.list({ limit: 50 }, admin, tdb);
|
|
89
89
|
expect(res.rows).toHaveLength(1);
|
|
90
90
|
const row = res.rows[0] as Record<string, unknown>;
|
|
91
|
-
expect(row["grossTotal"]).toEqual({
|
|
91
|
+
expect(row["grossTotal"]).toEqual({
|
|
92
|
+
amount: 136.85,
|
|
93
|
+
currency: "EUR",
|
|
94
|
+
amountScaled: 13685,
|
|
95
|
+
amountMinor: 13685,
|
|
96
|
+
});
|
|
92
97
|
expect("grossTotalCurrency" in row).toBe(false);
|
|
93
98
|
});
|
|
94
99
|
|
|
@@ -111,7 +116,12 @@ describe("event-store-executor — money column rehydration through raw SQL (fw#
|
|
|
111
116
|
> | null;
|
|
112
117
|
expect(row).not.toBeNull();
|
|
113
118
|
if (!row) return;
|
|
114
|
-
expect(row["grossTotal"]).toEqual({
|
|
119
|
+
expect(row["grossTotal"]).toEqual({
|
|
120
|
+
amount: 42.5,
|
|
121
|
+
currency: "USD",
|
|
122
|
+
amountScaled: 4250,
|
|
123
|
+
amountMinor: 4250,
|
|
124
|
+
});
|
|
115
125
|
expect("grossTotalCurrency" in row).toBe(false);
|
|
116
126
|
});
|
|
117
127
|
|
|
@@ -99,23 +99,42 @@ describe("flattenMoney — Insert/Update Convert (major units → minor units)",
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
describe("rehydrateMoney — Read Convert (minor units → major units)", () => {
|
|
102
|
-
test("{ <name>:
|
|
102
|
+
test("{ <name>: scaledUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency, amountScaled, amountMinor } }", () => {
|
|
103
103
|
const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity);
|
|
104
|
-
expect(out).toEqual({
|
|
104
|
+
expect(out).toEqual({
|
|
105
|
+
buyingPrice: { amount: 450, currency: "EUR", amountScaled: 45000, amountMinor: 45000 },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("amountMinor is a deprecated alias of amountScaled, same value", () => {
|
|
110
|
+
const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity)[
|
|
111
|
+
"buyingPrice"
|
|
112
|
+
] as MoneyRead;
|
|
113
|
+
expect(out.amountMinor).toBe(out.amountScaled);
|
|
105
114
|
});
|
|
106
115
|
|
|
107
116
|
test("PG-BIGINT als String wird zu number gecastet", () => {
|
|
108
117
|
// Postgres-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
109
118
|
const out = rehydrateMoney({ buyingPrice: "45000", buyingPriceCurrency: "EUR" }, orderEntity);
|
|
110
|
-
expect(out["buyingPrice"]).toEqual({
|
|
119
|
+
expect(out["buyingPrice"]).toEqual({
|
|
120
|
+
amount: 450,
|
|
121
|
+
currency: "EUR",
|
|
122
|
+
amountScaled: 45000,
|
|
123
|
+
amountMinor: 45000,
|
|
124
|
+
});
|
|
111
125
|
});
|
|
112
126
|
|
|
113
127
|
test("fehlende Currency-Spalte fällt auf entity.defaultCurrency", () => {
|
|
114
128
|
const out = rehydrateMoney({ buyingPrice: 45000 }, orderEntity);
|
|
115
|
-
expect(out["buyingPrice"]).toEqual({
|
|
129
|
+
expect(out["buyingPrice"]).toEqual({
|
|
130
|
+
amount: 450,
|
|
131
|
+
currency: "EUR",
|
|
132
|
+
amountScaled: 45000,
|
|
133
|
+
amountMinor: 45000,
|
|
134
|
+
});
|
|
116
135
|
});
|
|
117
136
|
|
|
118
|
-
test("
|
|
137
|
+
test("amountScaled bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
|
|
119
138
|
const rows = [10, 20, 30].map(
|
|
120
139
|
(minor) =>
|
|
121
140
|
rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
|
|
@@ -124,7 +143,7 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
124
143
|
);
|
|
125
144
|
const [a, b, c] = rows;
|
|
126
145
|
|
|
127
|
-
expect(a!.
|
|
146
|
+
expect(a!.amountScaled + b!.amountScaled).toBe(c!.amountScaled);
|
|
128
147
|
});
|
|
129
148
|
|
|
130
149
|
test("null/undefined amount → Field wird aus Output entfernt", () => {
|
|
@@ -143,12 +162,12 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
143
162
|
orderEntity,
|
|
144
163
|
);
|
|
145
164
|
expect(out).toEqual({
|
|
146
|
-
buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 },
|
|
147
|
-
sellingPrice: { amount: 600, currency: "USD", amountMinor: 60000 },
|
|
165
|
+
buyingPrice: { amount: 450, currency: "EUR", amountScaled: 45000, amountMinor: 45000 },
|
|
166
|
+
sellingPrice: { amount: 600, currency: "USD", amountScaled: 60000, amountMinor: 60000 },
|
|
148
167
|
});
|
|
149
168
|
});
|
|
150
169
|
|
|
151
|
-
test("Round-Trip: flatten dann rehydrate ergibt dasselbe amount/currency, plus
|
|
170
|
+
test("Round-Trip: flatten dann rehydrate ergibt dasselbe amount/currency, plus amountScaled", () => {
|
|
152
171
|
const original = {
|
|
153
172
|
buyingPrice: { amount: 450.5, currency: "EUR" },
|
|
154
173
|
sellingPrice: { amount: 56799.16, currency: "USD" },
|
|
@@ -156,15 +175,25 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
156
175
|
const flat = flattenMoney(original, orderEntity);
|
|
157
176
|
const rehydrated = rehydrateMoney(flat, orderEntity);
|
|
158
177
|
expect(rehydrated).toEqual({
|
|
159
|
-
buyingPrice: { amount: 450.5, currency: "EUR", amountMinor: 45050 },
|
|
160
|
-
sellingPrice: {
|
|
178
|
+
buyingPrice: { amount: 450.5, currency: "EUR", amountScaled: 45050, amountMinor: 45050 },
|
|
179
|
+
sellingPrice: {
|
|
180
|
+
amount: 56799.16,
|
|
181
|
+
currency: "USD",
|
|
182
|
+
amountScaled: 5679916,
|
|
183
|
+
amountMinor: 5679916,
|
|
184
|
+
},
|
|
161
185
|
});
|
|
162
186
|
});
|
|
163
187
|
|
|
164
|
-
test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR,
|
|
188
|
+
test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR, amountScaled:45000 }", () => {
|
|
165
189
|
const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
|
|
166
190
|
const out = rehydrateMoney(flat, orderEntity);
|
|
167
|
-
expect(out["buyingPrice"]).toEqual({
|
|
191
|
+
expect(out["buyingPrice"]).toEqual({
|
|
192
|
+
amount: 450,
|
|
193
|
+
currency: "EUR",
|
|
194
|
+
amountScaled: 45000,
|
|
195
|
+
amountMinor: 45000,
|
|
196
|
+
});
|
|
168
197
|
});
|
|
169
198
|
|
|
170
199
|
test("ist pure — input wird nicht mutiert", () => {
|
|
@@ -180,7 +209,7 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
180
209
|
).toThrow(/not a safe integer — DB corruption/);
|
|
181
210
|
});
|
|
182
211
|
|
|
183
|
-
test("fractional string amount (fw#1833) → loud throw statt
|
|
212
|
+
test("fractional string amount (fw#1833) → loud throw statt amountScaled mit Nachkommastelle", () => {
|
|
184
213
|
expect(() =>
|
|
185
214
|
rehydrateMoney({ buyingPrice: "45000.7", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
186
215
|
).toThrow(/not a safe integer — DB corruption/);
|
|
@@ -208,7 +237,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
|
|
|
208
237
|
|
|
209
238
|
// DB liefert dieselben Spalten zurück
|
|
210
239
|
const out = rehydrateMoney(flat, orderEntity);
|
|
211
|
-
expect(out).toEqual({
|
|
240
|
+
expect(out).toEqual({
|
|
241
|
+
buyingPrice: { amount: 990, currency: "USD", amountScaled: 99_000, amountMinor: 99_000 },
|
|
242
|
+
});
|
|
212
243
|
});
|
|
213
244
|
|
|
214
245
|
test("List-Pfad: mehrere Rows hintereinander rehydraten", () => {
|
|
@@ -219,9 +250,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
|
|
|
219
250
|
];
|
|
220
251
|
const apiRows = dbRows.map((r) => rehydrateMoney(r, orderEntity));
|
|
221
252
|
expect(apiRows).toEqual([
|
|
222
|
-
{ buyingPrice: { amount: 1, currency: "EUR", amountMinor: 100 } },
|
|
223
|
-
{ buyingPrice: { amount: 2, currency: "USD", amountMinor: 200 } },
|
|
224
|
-
{ buyingPrice: { amount: 3, currency: "GBP", amountMinor: 300 } },
|
|
253
|
+
{ buyingPrice: { amount: 1, currency: "EUR", amountScaled: 100, amountMinor: 100 } },
|
|
254
|
+
{ buyingPrice: { amount: 2, currency: "USD", amountScaled: 200, amountMinor: 200 } },
|
|
255
|
+
{ buyingPrice: { amount: 3, currency: "GBP", amountScaled: 300, amountMinor: 300 } },
|
|
225
256
|
]);
|
|
226
257
|
});
|
|
227
258
|
});
|
package/src/db/dialect.ts
CHANGED
|
@@ -377,10 +377,16 @@ export function primaryKey(opts: {
|
|
|
377
377
|
// Limits: no nested SqlExpression composition (drizzle's recursive
|
|
378
378
|
// `sql\`${other}\``) — schema-files use single-level expressions only.
|
|
379
379
|
|
|
380
|
+
// Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
|
|
381
|
+
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (bun-db/query.ts)
|
|
382
|
+
// can't be tricked into treating request data as a raw SQL literal.
|
|
383
|
+
export const SQL_EXPR_BRAND: unique symbol = Symbol("sql-expr");
|
|
384
|
+
|
|
380
385
|
export type SqlExpression = {
|
|
381
386
|
readonly kind: "sql-expr";
|
|
382
387
|
readonly text: string;
|
|
383
388
|
readonly params: readonly unknown[];
|
|
389
|
+
readonly [SQL_EXPR_BRAND]: true;
|
|
384
390
|
};
|
|
385
391
|
|
|
386
392
|
export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlExpression {
|
|
@@ -397,10 +403,15 @@ export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]
|
|
|
397
403
|
}
|
|
398
404
|
}
|
|
399
405
|
}
|
|
400
|
-
return { kind: "sql-expr", text: parts.join(""), params };
|
|
406
|
+
return { kind: "sql-expr", text: parts.join(""), params, [SQL_EXPR_BRAND]: true };
|
|
401
407
|
}
|
|
402
408
|
|
|
403
|
-
sql.raw = (text: string): SqlExpression => ({
|
|
409
|
+
sql.raw = (text: string): SqlExpression => ({
|
|
410
|
+
kind: "sql-expr",
|
|
411
|
+
text,
|
|
412
|
+
params: [],
|
|
413
|
+
[SQL_EXPR_BRAND]: true,
|
|
414
|
+
});
|
|
404
415
|
|
|
405
416
|
// ---- table() — the schema-table factory ----
|
|
406
417
|
//
|
|
@@ -5,6 +5,7 @@ import { coerceRow, extractTableInfo } from "../db/query";
|
|
|
5
5
|
import { buildOwnershipClause, shiftParams } from "../engine/ownership";
|
|
6
6
|
import type { EntityId } from "../engine/types";
|
|
7
7
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
8
|
+
import { UnprocessableError } from "../errors";
|
|
8
9
|
import { getStreamVersion } from "../event-store";
|
|
9
10
|
import { rehydrateCompoundTypes } from "./compound-types";
|
|
10
11
|
import { decodeCursor, encodeCursor } from "./cursor";
|
|
@@ -54,7 +55,17 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
54
55
|
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
55
56
|
// beim Definition-Time noch keinen Server-Context hat).
|
|
56
57
|
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
57
|
-
if (payload.search
|
|
58
|
+
if (payload.search) {
|
|
59
|
+
// #2032 — a search term with no adapter wired must fail loud, not
|
|
60
|
+
// silently return the unfiltered list dressed up as a search result.
|
|
61
|
+
if (!effectiveSearchAdapter) {
|
|
62
|
+
throw new UnprocessableError("search_adapter_not_wired", {
|
|
63
|
+
details: {
|
|
64
|
+
entity: entityName,
|
|
65
|
+
hint: "Wire a SearchAdapter for this entity, or remove `searchable` from the field/screen.",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
58
69
|
const results = await effectiveSearchAdapter.search(user.tenantId, payload.search, {
|
|
59
70
|
filterType: entityName,
|
|
60
71
|
});
|