@cosmicdrift/kumiko-framework 0.197.1 → 0.199.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 (47) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/batch.integration.test.ts +1 -1
  3. package/src/api/__tests__/server-boot-guards.test.ts +128 -1
  4. package/src/api/__tests__/sse-route.test.ts +129 -0
  5. package/src/api/auth-routes.ts +21 -6
  6. package/src/api/server.ts +44 -0
  7. package/src/api/sse-route.ts +13 -1
  8. package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
  9. package/src/bun-db/query.ts +5 -1
  10. package/src/db/__tests__/compound-types.test.ts +12 -2
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
  12. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
  13. package/src/db/__tests__/money.test.ts +49 -18
  14. package/src/db/__tests__/unchecked-system-db.test.ts +117 -0
  15. package/src/db/dialect.ts +13 -2
  16. package/src/db/event-store-executor-read.ts +12 -1
  17. package/src/db/index.ts +7 -2
  18. package/src/db/money.ts +35 -15
  19. package/src/db/table-builder.ts +7 -1
  20. package/src/db/tenant-db.ts +54 -2
  21. package/src/derivatives/derivatives-context.ts +9 -0
  22. package/src/engine/__tests__/build-app-schema.test.ts +50 -0
  23. package/src/engine/__tests__/nav.test.ts +12 -4
  24. package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
  25. package/src/engine/build-app-schema.ts +6 -0
  26. package/src/engine/build-config-feature-schema.ts +2 -2
  27. package/src/engine/index.ts +2 -1
  28. package/src/engine/registry-facade.ts +6 -0
  29. package/src/engine/registry-ingest.ts +1 -0
  30. package/src/engine/registry-state.ts +2 -0
  31. package/src/engine/types/index.ts +7 -1
  32. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
  33. package/src/entrypoint/index.ts +20 -3
  34. package/src/files/__tests__/files.integration.test.ts +16 -0
  35. package/src/files/file-routes.ts +12 -1
  36. package/src/jobs/__tests__/job-systemdb.integration.test.ts +152 -0
  37. package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
  38. package/src/jobs/job-runner.ts +42 -3
  39. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
  40. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -0
  41. package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
  42. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
  43. package/src/pipeline/dispatch-batch.ts +2 -2
  44. package/src/pipeline/dispatch-shared.ts +3 -1
  45. package/src/pipeline/idempotency.ts +11 -6
  46. package/src/ui-types/app-schema.ts +10 -0
  47. 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.1",
3
+ "version": "0.199.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.1",
189
+ "@cosmicdrift/kumiko-types": "0.199.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.1",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.199.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
@@ -1,6 +1,6 @@
1
1
  // buildServer boot-time guards + httpRoute verb wiring (PUT branch).
2
2
 
3
- import { describe, expect, test } from "bun:test";
3
+ import { describe, expect, spyOn, test } from "bun:test";
4
4
  import {
5
5
  createEntity,
6
6
  createFileField,
@@ -8,10 +8,22 @@ import {
8
8
  createTextField,
9
9
  defineFeature,
10
10
  } from "../../engine";
11
+ import { createInMemorySearchAdapter } from "../../search";
11
12
  import { buildServer } from "../server";
12
13
 
13
14
  const JWT_SECRET = "server-boot-guards-test-secret-min-32-chars";
14
15
 
16
+ // Find the "[kumiko:boot] ... SearchAdapter" line among all console.warn calls
17
+ // so the unrelated instanceIdWasRandom warning (fires whenever
18
+ // KUMIKO_INSTANCE_ID is unset, as it is in this test run) can't false-fire
19
+ // or hide the assertion.
20
+ function searchAdapterWarning(calls: unknown[][]): string | undefined {
21
+ const hit = calls.find(
22
+ (args) => typeof args[0] === "string" && args[0].includes("SearchAdapter is wired"),
23
+ );
24
+ return hit ? String(hit[0]) : undefined;
25
+ }
26
+
15
27
  describe("buildServer — file-storage provider guard", () => {
16
28
  const fileFieldFeature = defineFeature("needs-files", (r) => {
17
29
  r.entity(
@@ -69,3 +81,118 @@ describe("buildServer — feature httpRoute PUT mounting", () => {
69
81
  expect(await res.json()).toEqual({ method: "PUT", ok: true });
70
82
  });
71
83
  });
84
+
85
+ describe("buildServer — search-adapter boot warning (#2051)", () => {
86
+ const searchableFeature = defineFeature("has-search", (r) => {
87
+ r.entity(
88
+ "note",
89
+ createEntity({
90
+ table: "boot_guard_notes",
91
+ fields: { title: createTextField({ searchable: true }) },
92
+ }),
93
+ );
94
+ r.screen({ id: "note-list", type: "entityList", entity: "note", columns: ["title"] });
95
+ });
96
+
97
+ const nonSearchableFeature = defineFeature("no-search", (r) => {
98
+ r.entity(
99
+ "note",
100
+ createEntity({
101
+ table: "boot_guard_plain_notes",
102
+ fields: { title: createTextField() },
103
+ }),
104
+ );
105
+ r.screen({ id: "note-list", type: "entityList", entity: "note", columns: ["title"] });
106
+ });
107
+
108
+ // Pins the `screen.searchable === false` exclusion specifically: without
109
+ // it, this would false-positive purely off the entity having a searchable
110
+ // field, ignoring that the screen (whitelisted per entity-list-screens.ts
111
+ // SEARCHABLE_FALSE_WHITELIST) never renders the search box.
112
+ const explicitlyNonSearchableScreenFeature = defineFeature("opted-out-search", (r) => {
113
+ r.entity(
114
+ "download-attempt",
115
+ createEntity({
116
+ table: "boot_guard_download_attempts",
117
+ fields: { title: createTextField({ searchable: true }) },
118
+ }),
119
+ );
120
+ r.screen({
121
+ id: "download-attempt-list",
122
+ type: "entityList",
123
+ entity: "download-attempt",
124
+ columns: ["title"],
125
+ searchable: false,
126
+ });
127
+ });
128
+
129
+ test("warns naming the entity when a searchable screen has no context.searchAdapter", () => {
130
+ const calls: unknown[][] = [];
131
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
132
+ calls.push(args);
133
+ });
134
+ try {
135
+ buildServer({
136
+ registry: createRegistry([searchableFeature]),
137
+ context: {},
138
+ jwtSecret: JWT_SECRET,
139
+ });
140
+ const logged = searchAdapterWarning(calls);
141
+ expect(logged).toBeDefined();
142
+ expect(logged).toContain("note");
143
+ } finally {
144
+ spy.mockRestore();
145
+ }
146
+ });
147
+
148
+ test("stays silent when context.searchAdapter is wired", () => {
149
+ const calls: unknown[][] = [];
150
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
151
+ calls.push(args);
152
+ });
153
+ try {
154
+ buildServer({
155
+ registry: createRegistry([searchableFeature]),
156
+ context: { searchAdapter: createInMemorySearchAdapter() },
157
+ jwtSecret: JWT_SECRET,
158
+ });
159
+ expect(searchAdapterWarning(calls)).toBeUndefined();
160
+ } finally {
161
+ spy.mockRestore();
162
+ }
163
+ });
164
+
165
+ test("stays silent when no screen has a searchable field", () => {
166
+ const calls: unknown[][] = [];
167
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
168
+ calls.push(args);
169
+ });
170
+ try {
171
+ buildServer({
172
+ registry: createRegistry([nonSearchableFeature]),
173
+ context: {},
174
+ jwtSecret: JWT_SECRET,
175
+ });
176
+ expect(searchAdapterWarning(calls)).toBeUndefined();
177
+ } finally {
178
+ spy.mockRestore();
179
+ }
180
+ });
181
+
182
+ test("stays silent when the entity has a searchable field but the screen opts out (searchable: false)", () => {
183
+ const calls: unknown[][] = [];
184
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
185
+ calls.push(args);
186
+ });
187
+ try {
188
+ buildServer({
189
+ registry: createRegistry([explicitlyNonSearchableScreenFeature]),
190
+ context: {},
191
+ jwtSecret: JWT_SECRET,
192
+ });
193
+ expect(searchAdapterWarning(calls)).toBeUndefined();
194
+ } finally {
195
+ spy.mockRestore();
196
+ }
197
+ });
198
+ });
@@ -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,
package/src/api/server.ts CHANGED
@@ -286,6 +286,27 @@ export function buildServer(options: ServerOptions): KumikoServer {
286
286
  );
287
287
  }
288
288
 
289
+ // #2051 — a screen whose search box renders (kumiko-screen.tsx gates it on
290
+ // `screen.searchable ?? entity-has-searchable-field`, the same condition
291
+ // used below) sends `payload.search` on every query. Without a
292
+ // context.searchAdapter that throws UnprocessableError at request time
293
+ // (#2032) — surface the misconfig at boot instead of on the first search.
294
+ // Warn, not throw: unlike missing file-storage (uploads always fail),
295
+ // list screens still work without search: only the search box is broken.
296
+ // Several deployed apps (offlot-app, publicstatus, kumiko-studio,
297
+ // kumiko-enterprise) currently run in exactly this state.
298
+ if (!options.context.searchAdapter) {
299
+ const unwiredEntities = entitiesWithSearchableScreen(options.registry);
300
+ if (unwiredEntities.length > 0) {
301
+ console.warn(
302
+ `[kumiko:boot] ${unwiredEntities.length} entit${unwiredEntities.length === 1 ? "y" : "ies"} ` +
303
+ `have a searchable list screen but no SearchAdapter is wired on context.searchAdapter: ` +
304
+ `${unwiredEntities.join(", ")}. Search requests against ${unwiredEntities.length === 1 ? "it" : "them"} will fail with a 422 (search_adapter_not_wired) at runtime. ` +
305
+ "Wire a SearchAdapter (e.g. createMeilisearchAdapter) on context.searchAdapter, or remove `searchable: true` from the affected fields.",
306
+ );
307
+ }
308
+ }
309
+
289
310
  // Stateless JWTs (no sessionChecker → no revocation) default to a shorter
290
311
  // TTL than session-backed ones, since a leaked stateless token can't be
291
312
  // revoked and stays valid until it expires. Explicit jwtTtl always wins.
@@ -841,6 +862,29 @@ function registryDeclaresFileFields(registry: Registry): boolean {
841
862
  return false;
842
863
  }
843
864
 
865
+ // Entities whose entityList screen WOULD render a search box based on
866
+ // screen/field config alone: `screen.searchable` wins when set, otherwise
867
+ // the box shows iff the entity has ≥1 searchable field. `screen.searchable
868
+ // === false` is excluded — the boot-validator (entity-list-screens.ts) only
869
+ // allows that on the whitelisted download-attempt-list-style screens, which
870
+ // never render the box. This is the config-only half of the rule; the
871
+ // client additionally gates on FeatureSchema.searchAdapterMissing (#2062,
872
+ // set from the same `!options.context.searchAdapter` check below) so the
873
+ // box is actually suppressed once this function finds a hit.
874
+ function entitiesWithSearchableScreen(registry: Registry): readonly string[] {
875
+ const entities = new Set<string>();
876
+ for (const feature of registry.features.values()) {
877
+ for (const screen of Object.values(feature.screens)) {
878
+ if (screen.type !== "entityList") continue;
879
+ const isSearchable =
880
+ screen.searchable === true ||
881
+ (screen.searchable === undefined && registry.getSearchableFields(screen.entity).length > 0);
882
+ if (isSearchable) entities.add(screen.entity);
883
+ }
884
+ }
885
+ return [...entities];
886
+ }
887
+
844
888
  // Upload-route policy carried by createFilesFeature(opts?) — read from the
845
889
  // feature's exports so buildServer applies it without a parallel ServerOptions
846
890
  // surface. Absent feature / opts → defaults in createFileRoutes.
@@ -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