@cosmicdrift/kumiko-framework 0.163.3 → 0.165.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/__tests__/consumer-cli.integration.test.ts +30 -0
- package/src/api/__tests__/api.test.ts +60 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -2
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
- package/src/api/routes.ts +41 -22
- package/src/api/server.ts +14 -6
- package/src/bun-db/connection.ts +3 -3
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +5 -12
- package/src/db/index.ts +0 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/queries/event-store.ts +14 -16
- package/src/engine/__tests__/boot-validator.test.ts +14 -0
- package/src/engine/__tests__/registry.test.ts +36 -0
- package/src/engine/boot-validator/entity-handler.ts +14 -25
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
- package/src/engine/feature-ast/extractors/handlers.ts +13 -18
- package/src/engine/registry-validate.ts +7 -2
- package/src/engine/types/index.ts +0 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +138 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
- package/src/event-store/event-store.ts +12 -23
- package/src/event-store/events-schema.ts +10 -3
- package/src/event-store/index.ts +1 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +52 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +31 -18
- package/src/pipeline/dispatch-stream.ts +2 -7
- package/src/pipeline/event-dispatcher-delivery.ts +2 -1
- package/src/pipeline/system-hooks.ts +50 -1
- package/src/db/__tests__/encryption.test.ts +0 -39
- package/src/db/encryption.ts +0 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.165.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.18",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.165.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -84,6 +84,36 @@ describe("runConsumerCli status", () => {
|
|
|
84
84
|
expect(code).toBe(1);
|
|
85
85
|
expect(lines.join("\n")).toContain("DATABASE_URL not set");
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
test("--instance-id flag → status looks up the per-instance consumer, not shared", async () => {
|
|
89
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
90
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
91
|
+
await insertConsumerIfAbsent(testDb.db, "test:consumer:inst", "inst-1");
|
|
92
|
+
const { out, lines } = captureOut();
|
|
93
|
+
const code = await runConsumerCli(
|
|
94
|
+
["status", "test:consumer:inst", "--instance-id", "inst-1"],
|
|
95
|
+
out,
|
|
96
|
+
);
|
|
97
|
+
expect(code).toBe(0);
|
|
98
|
+
const joined = lines.join("\n");
|
|
99
|
+
expect(joined).toContain('instance_id="inst-1"');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("runConsumerCli — unknown/empty subcommand exit codes", () => {
|
|
104
|
+
test("no subcommand at all → exit 0 (usage, not an error)", async () => {
|
|
105
|
+
const { out, lines } = captureOut();
|
|
106
|
+
const code = await runConsumerCli([], out);
|
|
107
|
+
expect(code).toBe(0);
|
|
108
|
+
expect(lines.join("\n")).toContain("Subcommands:");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an unrecognized subcommand → exit 1", async () => {
|
|
112
|
+
const { out, lines } = captureOut();
|
|
113
|
+
const code = await runConsumerCli(["bogus"], out);
|
|
114
|
+
expect(code).toBe(1);
|
|
115
|
+
expect(lines.join("\n")).toContain("Subcommands:");
|
|
116
|
+
});
|
|
87
117
|
});
|
|
88
118
|
|
|
89
119
|
describe("runConsumerCli restart", () => {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type TenantId,
|
|
9
9
|
} from "../../engine";
|
|
10
10
|
import { createTestUser, TestUsers } from "../../stack";
|
|
11
|
+
import { pumpStream } from "../routes";
|
|
11
12
|
import { buildServer } from "../server";
|
|
12
13
|
|
|
13
14
|
const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
|
|
@@ -210,6 +211,65 @@ describe("POST /api/command", () => {
|
|
|
210
211
|
});
|
|
211
212
|
});
|
|
212
213
|
|
|
214
|
+
// --- pumpStream (SSE pull loop) ---
|
|
215
|
+
|
|
216
|
+
function fakeSseWriter() {
|
|
217
|
+
const frames: Array<{ event: string; data: string }> = [];
|
|
218
|
+
return {
|
|
219
|
+
frames,
|
|
220
|
+
async writeSSE(message: { event: string; data: string }) {
|
|
221
|
+
frames.push(message);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function* delayedGenerator(values: readonly unknown[], delayMsByIndex: readonly number[]) {
|
|
227
|
+
for (let i = 0; i < values.length; i++) {
|
|
228
|
+
const delay = delayMsByIndex[i] ?? 0;
|
|
229
|
+
if (delay > 0) await Bun.sleep(delay);
|
|
230
|
+
yield values[i];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe("pumpStream", () => {
|
|
235
|
+
test("emits a ping when the handler is slow, then still delivers the pending chunk (no loss)", async () => {
|
|
236
|
+
const writer = fakeSseWriter();
|
|
237
|
+
// heartbeatMs (10) fires before the 40ms-delayed second chunk resolves.
|
|
238
|
+
const gen = delayedGenerator([{ i: 0 }, { i: 1 }], [0, 40]);
|
|
239
|
+
|
|
240
|
+
await pumpStream(writer, gen, 10);
|
|
241
|
+
|
|
242
|
+
const events = writer.frames.map((f) => f.event);
|
|
243
|
+
expect(events[0]).toBe("chunk");
|
|
244
|
+
expect(events).toContain("ping");
|
|
245
|
+
expect(events.at(-1)).toBe("done");
|
|
246
|
+
// Both chunks arrive despite the ping in between — no chunk dropped.
|
|
247
|
+
const chunkData = writer.frames.filter((f) => f.event === "chunk").map((f) => f.data);
|
|
248
|
+
expect(chunkData).toEqual([JSON.stringify({ i: 0 }), JSON.stringify({ i: 1 })]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("no heartbeat fires when the handler is faster than heartbeatMs — chunks then done", async () => {
|
|
252
|
+
const writer = fakeSseWriter();
|
|
253
|
+
const gen = delayedGenerator([{ i: 0 }, { i: 1 }, { i: 2 }], [0, 0, 0]);
|
|
254
|
+
|
|
255
|
+
await pumpStream(writer, gen, 1000);
|
|
256
|
+
|
|
257
|
+
expect(writer.frames.map((f) => f.event)).toEqual(["chunk", "chunk", "chunk", "done"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a handler generator that throws propagates the error instead of swallowing it", async () => {
|
|
261
|
+
const writer = fakeSseWriter();
|
|
262
|
+
async function* throwing() {
|
|
263
|
+
yield { i: 0 };
|
|
264
|
+
throw new Error("handler-boom");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await expect(pumpStream(writer, throwing(), 1000)).rejects.toThrow("handler-boom");
|
|
268
|
+
// The chunk before the throw still made it out.
|
|
269
|
+
expect(writer.frames.map((f) => f.event)).toEqual(["chunk"]);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
213
273
|
// --- SSE ---
|
|
214
274
|
|
|
215
275
|
describe("GET /api/sse", () => {
|
|
@@ -80,7 +80,11 @@ function preauthConfirmRequest(body: unknown): Request {
|
|
|
80
80
|
|
|
81
81
|
describe("POST /auth/mfa/preauth-confirm", () => {
|
|
82
82
|
test("is public — reachable without a JWT", async () => {
|
|
83
|
-
|
|
83
|
+
const { app } = await buildApp();
|
|
84
|
+
const res = await app.request(preauthConfirmRequest({ setupToken: "t", code: "123456" }));
|
|
85
|
+
// No Authorization header sent at all — if the route required a JWT,
|
|
86
|
+
// authMiddleware would reject with 401 before the handler ever runs.
|
|
87
|
+
expect(res.status).not.toBe(401);
|
|
84
88
|
});
|
|
85
89
|
|
|
86
90
|
test("not mounted when mfaPreauthConfirmHandler is unset", async () => {
|
|
@@ -100,12 +104,24 @@ describe("POST /auth/mfa/preauth-confirm", () => {
|
|
|
100
104
|
};
|
|
101
105
|
},
|
|
102
106
|
});
|
|
103
|
-
|
|
107
|
+
// An active limiter, not null — "before rate-limit" is only checkable
|
|
108
|
+
// when there's a rate-limit to (not) consume.
|
|
109
|
+
const { app } = await buildApp(
|
|
110
|
+
{ mfaPreauthConfirmRateLimit: createInMemoryLoginRateLimiter(2, 60_000) },
|
|
111
|
+
dispatcher,
|
|
112
|
+
);
|
|
104
113
|
const res = await app.request(preauthConfirmRequest({ setupToken: "t" }));
|
|
105
114
|
expect(res.status).toBe(400);
|
|
106
115
|
const body = (await res.json()) as { isSuccess: boolean; error: string };
|
|
107
116
|
expect(body.error).toBe("invalid_body");
|
|
108
117
|
expect(dispatched).toBe(false);
|
|
118
|
+
|
|
119
|
+
// The limiter's cap (2) must still be fully available — a malformed
|
|
120
|
+
// body must not have consumed an attempt.
|
|
121
|
+
for (let i = 0; i < 5; i++) {
|
|
122
|
+
const retry = await app.request(preauthConfirmRequest({ setupToken: "t" }));
|
|
123
|
+
expect(retry.status).toBe(400);
|
|
124
|
+
}
|
|
109
125
|
});
|
|
110
126
|
|
|
111
127
|
test("on success: dispatches to mfaPreauthConfirmHandler, mints a JWT + cookies", async () => {
|
|
@@ -2,25 +2,33 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createInMemoryLoginRateLimiter } from "../auth-routes";
|
|
3
3
|
|
|
4
4
|
describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
|
|
5
|
-
test("sweepExpired
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
test("sweepExpired removes expired entries before enforceCap can evict a fresh key", async () => {
|
|
6
|
+
// maxAttempts=1 makes every entry a single-shot probe: a second `check`
|
|
7
|
+
// on the same key only returns true if the entry was actually removed
|
|
8
|
+
// from the map (either by expiry+sweep or by enforceCap).
|
|
9
|
+
const limiter = createInMemoryLoginRateLimiter(1, 50, {
|
|
10
|
+
maxEntries: 2,
|
|
9
11
|
sweepThreshold: 2,
|
|
10
12
|
});
|
|
11
13
|
|
|
12
|
-
expect(await limiter.check("
|
|
13
|
-
expect(await limiter.check("
|
|
14
|
-
//
|
|
15
|
-
// (hits.size >= sweepThreshold) before inserting "c".
|
|
14
|
+
expect(await limiter.check("old1")).toBe(true);
|
|
15
|
+
expect(await limiter.check("old2")).toBe(true);
|
|
16
|
+
// Let both windows expire.
|
|
16
17
|
await Bun.sleep(60);
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
18
|
+
|
|
19
|
+
// hits.size (2) >= sweepThreshold (2) → sweepExpired runs first and
|
|
20
|
+
// clears old1/old2 before "new1" is inserted, so enforceCap never fires.
|
|
21
|
+
expect(await limiter.check("new1")).toBe(true);
|
|
22
|
+
expect(await limiter.check("new2")).toBe(true);
|
|
23
|
+
|
|
24
|
+
// If enforceCap had run instead of (or before) the sweep, it would have
|
|
25
|
+
// evicted "new1" as the oldest live entry, and this check would return
|
|
26
|
+
// true (fresh window) rather than false (still rate-limited).
|
|
27
|
+
expect(await limiter.check("new1")).toBe(false);
|
|
20
28
|
});
|
|
21
29
|
|
|
22
|
-
test("enforceCap drops oldest
|
|
23
|
-
const limiter = createInMemoryLoginRateLimiter(
|
|
30
|
+
test("enforceCap drops the oldest entry when the map exceeds maxEntries", async () => {
|
|
31
|
+
const limiter = createInMemoryLoginRateLimiter(1, 60_000, {
|
|
24
32
|
maxEntries: 3,
|
|
25
33
|
sweepThreshold: 10_000, // never sweep — only the hard cap matters
|
|
26
34
|
});
|
|
@@ -28,14 +36,15 @@ describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
|
|
|
28
36
|
expect(await limiter.check("k1")).toBe(true);
|
|
29
37
|
expect(await limiter.check("k2")).toBe(true);
|
|
30
38
|
expect(await limiter.check("k3")).toBe(true);
|
|
31
|
-
// 4th insert trips enforceCap → drops oldest (k1).
|
|
39
|
+
// 4th insert trips enforceCap → drops the oldest entry (k1).
|
|
32
40
|
expect(await limiter.check("k4")).toBe(true);
|
|
33
41
|
|
|
34
42
|
// k1 was dropped — a fresh check starts a new window (allowed).
|
|
35
43
|
expect(await limiter.check("k1")).toBe(true);
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
|
|
45
|
+
// k4 must still be alive in the map: a check within its still-open
|
|
46
|
+
// window is rate-limited (count >= maxAttempts), proving enforceCap
|
|
47
|
+
// dropped k1 and not k4.
|
|
48
|
+
expect(await limiter.check("k4")).toBe(false);
|
|
40
49
|
});
|
|
41
50
|
});
|
package/src/api/routes.ts
CHANGED
|
@@ -147,28 +147,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
try {
|
|
150
|
-
|
|
151
|
-
while (true) {
|
|
152
|
-
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
-
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
154
|
-
heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
|
|
155
|
-
});
|
|
156
|
-
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
157
|
-
try {
|
|
158
|
-
outcome = await Promise.race([pending, heartbeat]);
|
|
159
|
-
} finally {
|
|
160
|
-
clearTimeout(heartbeatTimer);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (outcome === "heartbeat") {
|
|
164
|
-
await stream.writeSSE({ event: "ping", data: "" });
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (outcome.done) break;
|
|
168
|
-
await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
|
|
169
|
-
pending = generator.next();
|
|
170
|
-
}
|
|
171
|
-
await stream.writeSSE({ event: "done", data: "" });
|
|
150
|
+
await pumpStream(stream, generator, SSE_HEARTBEAT_INTERVAL_MS);
|
|
172
151
|
} catch (e) {
|
|
173
152
|
const err = toKumiko(e);
|
|
174
153
|
logServerFault(err, requestId, body.type);
|
|
@@ -181,6 +160,46 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
181
160
|
return api;
|
|
182
161
|
}
|
|
183
162
|
|
|
163
|
+
export type SseWriter = {
|
|
164
|
+
readonly writeSSE: (message: { readonly event: string; readonly data: string }) => Promise<void>;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Pull loop for /api/stream: races each generator.next() against a
|
|
168
|
+
// heartbeat timer so a slow/idle handler still keeps the connection alive
|
|
169
|
+
// ("ping" frames), forwards yielded chunks as "chunk" frames, and emits
|
|
170
|
+
// "done" once the generator completes. Factored out of the route handler
|
|
171
|
+
// so the heartbeat/ping and abort/cleanup paths are unit-testable with a
|
|
172
|
+
// fast heartbeatMs instead of only reachable through SSE_HEARTBEAT_INTERVAL_MS
|
|
173
|
+
// (15s) in a full HTTP round-trip.
|
|
174
|
+
export async function pumpStream(
|
|
175
|
+
stream: SseWriter,
|
|
176
|
+
generator: AsyncGenerator<unknown>,
|
|
177
|
+
heartbeatMs: number,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
let pending = generator.next();
|
|
180
|
+
while (true) {
|
|
181
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
182
|
+
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
183
|
+
heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
|
|
184
|
+
});
|
|
185
|
+
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
186
|
+
try {
|
|
187
|
+
outcome = await Promise.race([pending, heartbeat]);
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(heartbeatTimer);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (outcome === "heartbeat") {
|
|
193
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (outcome.done) break;
|
|
197
|
+
await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
|
|
198
|
+
pending = generator.next();
|
|
199
|
+
}
|
|
200
|
+
await stream.writeSSE({ event: "done", data: "" });
|
|
201
|
+
}
|
|
202
|
+
|
|
184
203
|
function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
|
|
185
204
|
return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
|
|
186
205
|
}
|
package/src/api/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { createEventDispatcher } from "../pipeline/event-dispatcher";
|
|
|
34
34
|
import { createLifecycleHooks, type SystemHooks } from "../pipeline/lifecycle-pipeline";
|
|
35
35
|
import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context";
|
|
36
36
|
import {
|
|
37
|
+
createJobTriggerEventConsumer,
|
|
37
38
|
createSearchEventConsumer,
|
|
38
39
|
createSseBroadcastEventConsumer,
|
|
39
40
|
} from "../pipeline/system-hooks";
|
|
@@ -105,12 +106,14 @@ export type ServerOptions = {
|
|
|
105
106
|
// runs a dedicated dispatcher process, or a test needs to control the
|
|
106
107
|
// consumer lifecycle manually.
|
|
107
108
|
disabled?: boolean;
|
|
108
|
-
// Opt out of the auto-built system consumers (SSE, Search)
|
|
109
|
-
// running feature r.multiStreamProjection consumers. Useful
|
|
110
|
-
// that assert only on subscriber behaviour, or for a
|
|
111
|
-
// routes SSE via a different transport. Default:
|
|
112
|
-
// respective dependency (sseBroker /
|
|
113
|
-
|
|
109
|
+
// Opt out of the auto-built system consumers (SSE, Search, Job-Trigger)
|
|
110
|
+
// while still running feature r.multiStreamProjection consumers. Useful
|
|
111
|
+
// for tests that assert only on subscriber behaviour, or for a
|
|
112
|
+
// deployment that routes SSE via a different transport. Default: sse/
|
|
113
|
+
// search enabled when the respective dependency (sseBroker /
|
|
114
|
+
// context.searchAdapter) is available; jobTrigger enabled when a
|
|
115
|
+
// jobRunner is wired via dispatcherOptions.
|
|
116
|
+
systemConsumers?: { sse?: boolean; search?: boolean; jobTrigger?: boolean };
|
|
114
117
|
// Raw postgres.js client for LISTEN/NOTIFY wake-up (Sprint E.4). When
|
|
115
118
|
// present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL — delivery
|
|
116
119
|
// latency drops from pollIntervalMs to TCP-round-trip. The poll timer
|
|
@@ -398,6 +401,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
398
401
|
|
|
399
402
|
const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
|
|
400
403
|
const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
|
|
404
|
+
const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
|
|
405
|
+
const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
|
|
401
406
|
|
|
402
407
|
const systemConsumers: EventConsumer[] = [];
|
|
403
408
|
if (sseConsumerEnabled) {
|
|
@@ -406,6 +411,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
406
411
|
if (searchConsumerEnabled && searchAdapter) {
|
|
407
412
|
systemConsumers.push(createSearchEventConsumer(searchAdapter, options.registry));
|
|
408
413
|
}
|
|
414
|
+
if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
|
|
415
|
+
systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
|
|
416
|
+
}
|
|
409
417
|
|
|
410
418
|
// MultiStreamProjections: one EventConsumer per MSP. Handler routes by
|
|
411
419
|
// event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
|
package/src/bun-db/connection.ts
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// event-dispatcher.ts. Bun.sql 1.2.20 hat kein listen() (PR oven-sh/bun#25511
|
|
7
7
|
// pending). Nach Landung des Bun-LISTEN-Supports: peer raus.
|
|
8
8
|
|
|
9
|
+
import type { PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
|
|
9
10
|
import postgres from "postgres";
|
|
10
11
|
import { readPositiveIntEnv } from "../utils/env-parse";
|
|
11
12
|
|
|
13
|
+
export type { PgListenClient };
|
|
14
|
+
|
|
12
15
|
// Bun.SQL ist callable als tagged template `sql\`...\`` PLUS hat methods
|
|
13
16
|
// (.begin / .unsafe / .end / .file / .reserve etc.). DbConnection-Type
|
|
14
17
|
// reflektiert die Instance-Shape.
|
|
@@ -21,9 +24,6 @@ export type BunDbTx = BunDbConnection;
|
|
|
21
24
|
// Beide austauschbar im normalen call-path.
|
|
22
25
|
export type BunDbRunner = BunDbConnection | BunDbTx;
|
|
23
26
|
|
|
24
|
-
// Postgres-js peer NUR für event-dispatcher LISTEN.
|
|
25
|
-
export type PgListenClient = ReturnType<typeof postgres>;
|
|
26
|
-
|
|
27
27
|
export type BunDbConnectionOptions = {
|
|
28
28
|
readonly maxConnections?: number;
|
|
29
29
|
readonly idleTimeoutSeconds?: number;
|
|
@@ -50,7 +50,7 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
beforeAll(async () => {
|
|
53
|
-
await
|
|
53
|
+
await unsafeCreateEntityTable(testDb.db, restrictedEntity, "esWriteRestricted");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
beforeEach(async () => {
|
|
@@ -158,7 +158,7 @@ describe("event-store-executor write-verbs — restore without softDelete", () =
|
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
beforeAll(async () => {
|
|
161
|
-
await
|
|
161
|
+
await unsafeCreateEntityTable(testDb.db, hardDeleteEntity, "esWriteHard");
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
beforeEach(async () => {
|
|
@@ -199,7 +199,7 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
|
|
|
199
199
|
});
|
|
200
200
|
|
|
201
201
|
beforeAll(async () => {
|
|
202
|
-
await
|
|
202
|
+
await unsafeCreateEntityTable(testDb.db, ownedFieldEntity, "esWriteOwnedField");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
beforeEach(async () => {
|
|
@@ -264,7 +264,7 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
|
|
|
264
264
|
});
|
|
265
265
|
|
|
266
266
|
beforeAll(async () => {
|
|
267
|
-
await
|
|
267
|
+
await unsafeCreateEntityTable(testDb.db, versionEntity, "esWriteVersion");
|
|
268
268
|
});
|
|
269
269
|
|
|
270
270
|
beforeEach(async () => {
|
|
@@ -298,13 +298,6 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
|
|
|
298
298
|
});
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
-
async function unsafeCreateEntityTableFor(
|
|
302
|
-
entity: Parameters<typeof buildEntityTable>[1],
|
|
303
|
-
name: string,
|
|
304
|
-
): Promise<void> {
|
|
305
|
-
await unsafeCreateEntityTable(testDb.db, entity, name);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
301
|
// =============================================================================
|
|
309
302
|
// Concurrent update race → EventStoreVersionConflict catch + entityCache.del
|
|
310
303
|
// on forget/restore (create/update/delete already exercise cache in the
|
|
@@ -341,7 +334,7 @@ describe("event-store-executor write-verbs — concurrent version race + cache",
|
|
|
341
334
|
});
|
|
342
335
|
|
|
343
336
|
beforeAll(async () => {
|
|
344
|
-
await
|
|
337
|
+
await unsafeCreateEntityTable(testDb.db, raceEntity, "esWriteRace");
|
|
345
338
|
});
|
|
346
339
|
|
|
347
340
|
beforeEach(async () => {
|
package/src/db/index.ts
CHANGED
|
@@ -35,8 +35,6 @@ export {
|
|
|
35
35
|
enrichRowWithReferences,
|
|
36
36
|
enrichWithReferences,
|
|
37
37
|
} from "./eagerload";
|
|
38
|
-
export type { EncryptionProvider } from "./encryption";
|
|
39
|
-
export { createEncryptionProvider } from "./encryption";
|
|
40
38
|
export {
|
|
41
39
|
collectEncryptedFieldNames,
|
|
42
40
|
configuredEntityFieldEncryption,
|
package/src/db/pg-error.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// unwrap both layers so callers don't have to know which layer produced the
|
|
4
4
|
// error. Used by the event-store to distinguish a unique-violation on the
|
|
5
5
|
// aggregate-version index (optimistic-concurrency conflict) from the one on
|
|
6
|
-
// the
|
|
6
|
+
// the idempotency-key index (caller-side replay signal).
|
|
7
7
|
|
|
8
8
|
export type PgErrorInfo = {
|
|
9
9
|
readonly code: string | undefined;
|
|
@@ -6,6 +6,20 @@ export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void>
|
|
|
6
6
|
await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Tenant-scoped partial unique index over metadata.idempotencyKey.
|
|
10
|
+
// Expression index straight on the jsonb column — no dedicated key column,
|
|
11
|
+
// so it needs no INSERT-path change and covers admin-api's raw appends too
|
|
12
|
+
// (same metadata jsonb). CREATE ... IF NOT EXISTS makes this safe to call
|
|
13
|
+
// on every boot, same "ensure" pattern as ensureSnapshotVersionColumn: heals
|
|
14
|
+
// installs that predate the index without a table rebuild.
|
|
15
|
+
export async function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void> {
|
|
16
|
+
await asRawClient(db).unsafe(
|
|
17
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "events_idempotency_uq" ON "kumiko_events" ` +
|
|
18
|
+
`("tenant_id", (("metadata"->>'idempotencyKey'))) ` +
|
|
19
|
+
`WHERE "metadata"->>'idempotencyKey' IS NOT NULL`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export type SubsequentEventInsertParams = {
|
|
10
24
|
readonly aggregateId: string;
|
|
11
25
|
readonly aggregateType: string;
|
|
@@ -85,22 +99,6 @@ export async function selectAggregateMaxVersion(db: AnyDb, aggregateId: string):
|
|
|
85
99
|
return rows[0]?.v ?? 0;
|
|
86
100
|
}
|
|
87
101
|
|
|
88
|
-
/** tenant_id the aggregate's events were written under — no membership/tenant
|
|
89
|
-
* filter. A r.systemScope() aggregate (e.g. user) lives in whichever tenant
|
|
90
|
-
* its creating executor used, which need not be a tenant the subject holds a
|
|
91
|
-
* membership in. Returns null for unknown streams. */
|
|
92
|
-
export async function selectAggregateStreamTenant(
|
|
93
|
-
db: AnyDb,
|
|
94
|
-
aggregateId: string,
|
|
95
|
-
aggregateType: string,
|
|
96
|
-
): Promise<string | null> {
|
|
97
|
-
const rows = (await asRawClient(db).unsafe(
|
|
98
|
-
`SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
|
|
99
|
-
[aggregateId, aggregateType],
|
|
100
|
-
)) as ReadonlyArray<{ t: string | null }>;
|
|
101
|
-
return rows[0]?.t ?? null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
102
|
export async function selectEventsHighWaterMark(db: AnyDb): Promise<bigint> {
|
|
105
103
|
const rows = (await asRawClient(db).unsafe(
|
|
106
104
|
`SELECT COALESCE(MAX("id"), 0)::bigint AS max FROM "kumiko_events"`,
|
|
@@ -599,6 +599,20 @@ describe("boot-validator", () => {
|
|
|
599
599
|
expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
|
|
600
600
|
});
|
|
601
601
|
|
|
602
|
+
test("object-form streamHandler with access rule passes boot", () => {
|
|
603
|
+
const features = [
|
|
604
|
+
defineFeature("a", (r) => {
|
|
605
|
+
r.streamHandler({
|
|
606
|
+
name: "chat:complete",
|
|
607
|
+
schema: z.object({}),
|
|
608
|
+
handler: async function* () {},
|
|
609
|
+
access: { roles: ["User"] },
|
|
610
|
+
});
|
|
611
|
+
}),
|
|
612
|
+
];
|
|
613
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
614
|
+
});
|
|
615
|
+
|
|
602
616
|
test("accepts openToAll access rule on a stream handler", () => {
|
|
603
617
|
const features = [
|
|
604
618
|
defineFeature("a", (r) => {
|
|
@@ -139,6 +139,42 @@ describe("getAllStreamHandlers", () => {
|
|
|
139
139
|
});
|
|
140
140
|
expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
test("two distinct feature names that kebab-collide throw on the qualified stream-handler clash", () => {
|
|
144
|
+
// "registry-test-kebab-dup" and "registryTestKebabDup" are different raw
|
|
145
|
+
// feature.name values (so the earlier Duplicate-feature guard doesn't
|
|
146
|
+
// fire) but toKebab() collapses both to the same qualified name.
|
|
147
|
+
const featureA = defineFeature("registry-test-kebab-dup", (r) => {
|
|
148
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
149
|
+
});
|
|
150
|
+
const featureB = defineFeature("registryTestKebabDup", (r) => {
|
|
151
|
+
r.streamHandler("chat:complete", z.object({}), async function* () {});
|
|
152
|
+
});
|
|
153
|
+
expect(() => createRegistry([featureA, featureB])).toThrow(/Duplicate stream handler/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("object-form streamHandler registration preserves schema/access/rateLimit", () => {
|
|
157
|
+
const schema = z.object({ prompt: z.string() });
|
|
158
|
+
const handlerFn = async function* () {};
|
|
159
|
+
const feature = defineFeature("registry-test-object-form", (r) => {
|
|
160
|
+
r.streamHandler({
|
|
161
|
+
name: "chat:complete",
|
|
162
|
+
schema,
|
|
163
|
+
handler: handlerFn,
|
|
164
|
+
access: { openToAll: true },
|
|
165
|
+
rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 60 },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const registry = createRegistry([feature]);
|
|
170
|
+
const registered = registry.getStreamHandler("registry-test-object-form:stream:chat:complete");
|
|
171
|
+
|
|
172
|
+
expect(registered).toBeDefined();
|
|
173
|
+
expect(registered?.schema).toBe(schema);
|
|
174
|
+
expect(registered?.handler).toBe(handlerFn);
|
|
175
|
+
expect(registered?.access).toEqual({ openToAll: true });
|
|
176
|
+
expect(registered?.rateLimit).toEqual({ per: "ip+handler", limit: 5, windowSeconds: 60 });
|
|
177
|
+
});
|
|
142
178
|
});
|
|
143
179
|
|
|
144
180
|
describe("extensionSelector boot-validation", () => {
|
|
@@ -101,32 +101,21 @@ const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet<string> = new Set(["user", "user
|
|
|
101
101
|
// at runtime, but we fail at boot to turn an easy-to-miss security regression
|
|
102
102
|
// into a loud configuration error.
|
|
103
103
|
export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
|
|
121
|
-
}
|
|
122
|
-
for (const [name, handler] of Object.entries(feature.streamHandlers)) {
|
|
123
|
-
if (!handler.access) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
|
|
126
|
-
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
127
|
-
);
|
|
104
|
+
const kinds = [
|
|
105
|
+
{ kind: "write" as const, label: "Write", handlers: feature.writeHandlers },
|
|
106
|
+
{ kind: "query" as const, label: "Query", handlers: feature.queryHandlers },
|
|
107
|
+
{ kind: "stream" as const, label: "Stream", handlers: feature.streamHandlers },
|
|
108
|
+
];
|
|
109
|
+
for (const { kind, label, handlers } of kinds) {
|
|
110
|
+
for (const [name, handler] of Object.entries(handlers)) {
|
|
111
|
+
if (!handler.access) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
|
|
114
|
+
`Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
128
118
|
}
|
|
129
|
-
validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
|
|
130
119
|
}
|
|
131
120
|
}
|
|
132
121
|
|