@cosmicdrift/kumiko-framework 0.197.0 → 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.
Files changed (36) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/batch.integration.test.ts +1 -1
  3. package/src/api/__tests__/sse-route.test.ts +129 -0
  4. package/src/api/auth-routes.ts +21 -6
  5. package/src/api/sse-route.ts +13 -1
  6. package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
  7. package/src/bun-db/query.ts +5 -1
  8. package/src/db/__tests__/compound-types.test.ts +12 -2
  9. package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
  10. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
  11. package/src/db/__tests__/money.test.ts +49 -18
  12. package/src/db/dialect.ts +13 -2
  13. package/src/db/event-store-executor-read.ts +12 -1
  14. package/src/db/money.ts +35 -15
  15. package/src/db/table-builder.ts +7 -1
  16. package/src/derivatives/__tests__/derivatives-context.test.ts +43 -0
  17. package/src/derivatives/__tests__/variant-route.integration.test.ts +48 -1
  18. package/src/derivatives/derivatives-context.ts +32 -2
  19. package/src/engine/__tests__/build-app-schema.test.ts +20 -0
  20. package/src/engine/__tests__/nav.test.ts +12 -4
  21. package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
  22. package/src/engine/build-config-feature-schema.ts +2 -2
  23. package/src/engine/index.ts +2 -1
  24. package/src/engine/types/index.ts +7 -1
  25. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
  26. package/src/entrypoint/index.ts +20 -3
  27. package/src/files/__tests__/files.integration.test.ts +16 -0
  28. package/src/files/file-routes.ts +12 -1
  29. package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
  30. package/src/jobs/job-runner.ts +32 -1
  31. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
  32. package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
  33. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
  34. package/src/pipeline/dispatch-batch.ts +2 -2
  35. package/src/pipeline/idempotency.ts +11 -6
  36. 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.197.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.197.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.197.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
+ });
@@ -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
- const data = result.data as {
1199
- kind: "auth-session";
1200
- session: SessionUser;
1201
- tenantId: TenantId;
1202
- role: string;
1203
- }; // @cast-boundary engine-payload
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,
@@ -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
- stream.writeSSE({ event: event.type, data: JSON.stringify(event.data) });
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
+ });
@@ -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 && (v as { kind?: unknown }).kind === "sql-expr";
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: { amount: 45_000, currency: "EUR", amountMinor: 4_500_000 },
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({ amount: 100, currency: "EUR", amountMinor: 10_000 });
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 ist no-op (alle rows zurück)", async () => {
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 res = await exec.list({ limit: 50, search: "irgendwas" }, admin, tdb);
370
- expect(res.rows.length).toBe(3);
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({ amount: 136.85, currency: "EUR", amountMinor: 13685 });
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({ amount: 42.5, currency: "USD", amountMinor: 4250 });
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>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency, amountMinor } }", () => {
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({ buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 } });
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({ amount: 450, currency: "EUR", amountMinor: 45000 });
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({ amount: 450, currency: "EUR", amountMinor: 45000 });
129
+ expect(out["buyingPrice"]).toEqual({
130
+ amount: 450,
131
+ currency: "EUR",
132
+ amountScaled: 45000,
133
+ amountMinor: 45000,
134
+ });
116
135
  });
117
136
 
118
- test("amountMinor bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
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!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
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 amountMinor", () => {
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: { amount: 56799.16, currency: "USD", amountMinor: 5679916 },
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, amountMinor:45000 }", () => {
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({ amount: 450, currency: "EUR", amountMinor: 45000 });
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 amountMinor mit Nachkommastelle", () => {
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({ buyingPrice: { amount: 990, currency: "USD", amountMinor: 99_000 } });
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 => ({ kind: "sql-expr", text, params: [] });
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 && effectiveSearchAdapter && entityName) {
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
  });