@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.
- package/package.json +3 -3
- package/src/api/__tests__/batch.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +128 -1
- package/src/api/__tests__/sse-route.test.ts +129 -0
- package/src/api/auth-routes.ts +21 -6
- package/src/api/server.ts +44 -0
- 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/__tests__/unchecked-system-db.test.ts +117 -0
- package/src/db/dialect.ts +13 -2
- package/src/db/event-store-executor-read.ts +12 -1
- package/src/db/index.ts +7 -2
- package/src/db/money.ts +35 -15
- package/src/db/table-builder.ts +7 -1
- package/src/db/tenant-db.ts +54 -2
- package/src/derivatives/derivatives-context.ts +9 -0
- package/src/engine/__tests__/build-app-schema.test.ts +50 -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-app-schema.ts +6 -0
- package/src/engine/build-config-feature-schema.ts +2 -2
- package/src/engine/index.ts +2 -1
- package/src/engine/registry-facade.ts +6 -0
- package/src/engine/registry-ingest.ts +1 -0
- package/src/engine/registry-state.ts +2 -0
- 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__/job-systemdb.integration.test.ts +152 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
- package/src/jobs/job-runner.ts +42 -3
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -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/dispatch-shared.ts +3 -1
- package/src/pipeline/idempotency.ts +11 -6
- package/src/ui-types/app-schema.ts +10 -0
- 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.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.
|
|
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.
|
|
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
|
+
});
|
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/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.
|
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
|
|