@cosmicdrift/kumiko-framework 0.159.1 → 0.160.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__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/api-constants.ts +1 -0
- package/src/api/routes.ts +57 -0
- package/src/bun-db/query.ts +12 -25
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/entity-table-meta-types.ts +2 -92
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +35 -1
- package/src/engine/index.ts +3 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -94
- package/src/engine/types/entity-handlers.ts +2 -30
- package/src/engine/types/feature.ts +2 -1021
- package/src/engine/types/fields.ts +2 -685
- package/src/engine/types/handlers.ts +2 -820
- package/src/engine/types/hooks.ts +2 -170
- package/src/engine/types/index.ts +44 -36
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -83
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/screen.ts +2 -747
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/workspace.ts +2 -42
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +2 -21
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -22
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +39 -1
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/rate-limit/resolver.ts +10 -30
- package/src/secrets/envelope-cipher.ts +4 -6
- package/src/secrets/types.ts +2 -177
- package/src/time/tz-context.ts +9 -56
package/src/files/types.ts
CHANGED
|
@@ -6,60 +6,11 @@ export type FileMetadata = {
|
|
|
6
6
|
readonly size: number;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
readonly contentDisposition?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
// Options fuer `writeStream`. `mimeType` ist Content-Type-Hint analog zu
|
|
18
|
-
// `write`. `contentLength` ist optional fuer Provider die einen Length-
|
|
19
|
-
// Header brauchen (S3 multipart hat einen TransferManager, kann auch ohne
|
|
20
|
-
// length); local-Provider ignoriert beides.
|
|
21
|
-
export type WriteStreamOptions = {
|
|
22
|
-
readonly mimeType?: string;
|
|
23
|
-
readonly contentLength?: number;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// Primitive storage contract: key+bytes in, bytes out. Metadata (fileName,
|
|
27
|
-
// mimeType, size) lives on the FileRef row — the provider only needs to
|
|
28
|
-
// shuttle bytes. `mimeType` on write() is a hint for providers that need a
|
|
29
|
-
// Content-Type header (S3/R2/…); local filesystems can ignore it.
|
|
30
|
-
//
|
|
31
|
-
// **Streaming (`writeStream` + `readStream`) ist PFLICHT** — beide
|
|
32
|
-
// Methoden sind required, kein optional-feature. Begruendung:
|
|
33
|
-
// - User-Data-Export (Atom 3c) braucht beide, sonst silent fail bei
|
|
34
|
-
// erstem Job mit fileRefs in Production.
|
|
35
|
-
// - Apps die nur kleine Files (Avatar-Uploads, Profile-Pics) handeln,
|
|
36
|
-
// koennen trivial via `oneShot`-Pattern den Stream-Contract erfuellen
|
|
37
|
-
// (single-chunk yield von write/read-Bytes). 5 Zeilen pro Provider.
|
|
38
|
-
// - Optional-Type wuerde TypeScript-Lying erlauben: Type sagt "kann
|
|
39
|
-
// fehlen", Worker throws zur Runtime → App-Authors sehen den Bug
|
|
40
|
-
// erst in Production. Required + TS-enforced ist ehrlich.
|
|
41
|
-
//
|
|
42
|
-
// `getSignedUrl` BLEIBT optional: object-store backends (S3/R2/GCS)
|
|
43
|
-
// implement it so clients can download directly from the provider after
|
|
44
|
-
// the server has checked access — offloads bandwidth and enables browser-
|
|
45
|
-
// native caching. Filesystem providers leave it undefined; the route then
|
|
46
|
-
// returns 501 and the client falls back to streaming via GET /files/:id.
|
|
47
|
-
// Callers must feature-detect via `typeof provider.getSignedUrl === "function"`.
|
|
48
|
-
// Hier ist Optional korrekt weil die Fallback-Pfad existiert — kein
|
|
49
|
-
// silent-fail, sondern 501 + alternativer download.
|
|
50
|
-
export type FileStorageProvider = {
|
|
51
|
-
write(key: string, data: Uint8Array, mimeType?: string): Promise<void>;
|
|
52
|
-
writeStream(
|
|
53
|
-
key: string,
|
|
54
|
-
source: AsyncIterable<Uint8Array>,
|
|
55
|
-
options?: WriteStreamOptions,
|
|
56
|
-
): Promise<void>;
|
|
57
|
-
read(key: string): Promise<Uint8Array>;
|
|
58
|
-
readStream(key: string): AsyncIterable<Uint8Array>;
|
|
59
|
-
delete(key: string): Promise<void>;
|
|
60
|
-
exists(key: string): Promise<boolean>;
|
|
61
|
-
getSignedUrl?(key: string, expiresInSeconds: number, options?: SignedUrlOptions): Promise<string>;
|
|
62
|
-
};
|
|
9
|
+
export type {
|
|
10
|
+
FileStorageProvider,
|
|
11
|
+
SignedUrlOptions,
|
|
12
|
+
WriteStreamOptions,
|
|
13
|
+
} from "@cosmicdrift/kumiko-types/file-storage-provider-types";
|
|
63
14
|
|
|
64
15
|
export type FileValidationOptions = {
|
|
65
16
|
readonly maxSize?: string | undefined;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { requestContext } from "../../api/request-context";
|
|
3
4
|
import { createRegistry, defineFeature } from "../../engine";
|
|
4
5
|
import type { AppContext, Registry } from "../../engine/types";
|
|
5
|
-
import { createTestRedis, type TestRedis } from "../../stack";
|
|
6
|
+
import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
6
7
|
import { sleep, waitFor } from "../../testing";
|
|
7
8
|
import {
|
|
8
9
|
createJobRunner,
|
|
@@ -103,6 +104,30 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
103
104
|
await sleep(500);
|
|
104
105
|
},
|
|
105
106
|
);
|
|
107
|
+
r.writeHandler(
|
|
108
|
+
"capped-event",
|
|
109
|
+
z.object({ n: z.number() }),
|
|
110
|
+
async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
|
|
111
|
+
{ access: { openToAll: true } },
|
|
112
|
+
);
|
|
113
|
+
// Event-triggered twin — exercises handleEvent's maxPerTenant guard
|
|
114
|
+
// (dispatch and handleEvent share isOverPerTenantLimit).
|
|
115
|
+
r.job(
|
|
116
|
+
"perTenantLimitedEvent",
|
|
117
|
+
{
|
|
118
|
+
trigger: { on: "test:write:capped-event" },
|
|
119
|
+
concurrency: "parallel",
|
|
120
|
+
maxPerTenant: 2,
|
|
121
|
+
},
|
|
122
|
+
async (payload) => {
|
|
123
|
+
jobLog.push({
|
|
124
|
+
name: "test:job:per-tenant-limited-event",
|
|
125
|
+
payload,
|
|
126
|
+
timestamp: Date.now(),
|
|
127
|
+
});
|
|
128
|
+
await sleep(500);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
106
131
|
|
|
107
132
|
// Job that fails
|
|
108
133
|
r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
|
|
@@ -130,6 +155,18 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
130
155
|
timestamp: Date.now(),
|
|
131
156
|
});
|
|
132
157
|
});
|
|
158
|
+
// Exercises createJobLogger (info/warn/error/debug/child) — otherwise those
|
|
159
|
+
// one-liners stay uncovered even though every job builds a logger.
|
|
160
|
+
r.job("logProbe", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
161
|
+
expect(ctx.log).toBeDefined();
|
|
162
|
+
const log = ctx.log!;
|
|
163
|
+
log.info("log-probe-info", { n: payload["n"] });
|
|
164
|
+
log.warn("log-probe-warn");
|
|
165
|
+
log.error("log-probe-error", { ok: false });
|
|
166
|
+
log.debug("log-probe-debug");
|
|
167
|
+
log.child({ probe: true }).info("log-probe-child");
|
|
168
|
+
jobLog.push({ name: "test:job:log-probe", payload, timestamp: Date.now() });
|
|
169
|
+
});
|
|
133
170
|
});
|
|
134
171
|
|
|
135
172
|
beforeAll(async () => {
|
|
@@ -423,6 +460,26 @@ describe("concurrency: debounce", () => {
|
|
|
423
460
|
});
|
|
424
461
|
});
|
|
425
462
|
|
|
463
|
+
describe("concurrency: replace", () => {
|
|
464
|
+
test("enqueuer-only: each dispatch removes prior waiting peers", async () => {
|
|
465
|
+
// No consumer → jobs stay waiting. The replace branch walks getWaiting()
|
|
466
|
+
// and removes same-name peers before add — so three dispatches leave one.
|
|
467
|
+
await withRunner(
|
|
468
|
+
async (runner) => {
|
|
469
|
+
const id1 = await runner.dispatch("test:job:replace-job", { n: 1 });
|
|
470
|
+
const id2 = await runner.dispatch("test:job:replace-job", { n: 2 });
|
|
471
|
+
const id3 = await runner.dispatch("test:job:replace-job", { n: 3 });
|
|
472
|
+
expect(id1).toBeDefined();
|
|
473
|
+
expect(id2).toBeDefined();
|
|
474
|
+
expect(id3).toBeDefined();
|
|
475
|
+
expect(id1).not.toBe("skipped");
|
|
476
|
+
expect(id3).not.toBe(id1);
|
|
477
|
+
},
|
|
478
|
+
{ consumerLane: undefined },
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
426
483
|
describe("concurrency: maxPerTenant", () => {
|
|
427
484
|
test("max=2: third dispatch for same tenant returns skipped, other tenant unaffected", async () => {
|
|
428
485
|
clearLog();
|
|
@@ -486,6 +543,18 @@ describe("concurrency: maxPerTenant", () => {
|
|
|
486
543
|
|
|
487
544
|
// --- Correlation propagation ---
|
|
488
545
|
|
|
546
|
+
describe("job logger", () => {
|
|
547
|
+
test("handler can call info/warn/error/debug/child without throw", async () => {
|
|
548
|
+
clearLog();
|
|
549
|
+
await withRunner(async (runner) => {
|
|
550
|
+
await runner.dispatch("test:job:log-probe", { n: 1 });
|
|
551
|
+
await waitFor(() => {
|
|
552
|
+
expect(jobLog.some((e) => e.name === "test:job:log-probe")).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
489
558
|
describe("correlation propagation", () => {
|
|
490
559
|
test("dispatch inside requestContext.run passes correlationId into the job", async () => {
|
|
491
560
|
clearLog();
|
|
@@ -599,6 +668,38 @@ describe("error handling", () => {
|
|
|
599
668
|
});
|
|
600
669
|
});
|
|
601
670
|
});
|
|
671
|
+
|
|
672
|
+
test("perTenant with getActiveTenantIds fans out one run per tenant", async () => {
|
|
673
|
+
clearLog();
|
|
674
|
+
const tenants = [101, 102];
|
|
675
|
+
await withRunner(
|
|
676
|
+
async (runner) => {
|
|
677
|
+
await runner.dispatch("test:job:per-tenant-fanout", { n: 7 });
|
|
678
|
+
await waitFor(() => {
|
|
679
|
+
const runs = jobLog.filter((e) => e.name === "test:job:per-tenant-fanout");
|
|
680
|
+
expect(runs.length).toBe(2);
|
|
681
|
+
expect(runs.every((e) => e.payload["n"] === 7)).toBe(true);
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
{ getActiveTenantIds: async () => tenants },
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
describe("handleEvent maxPerTenant", () => {
|
|
690
|
+
test("skips enqueue when tenant is already at the cap", async () => {
|
|
691
|
+
clearLog();
|
|
692
|
+
await withRunner(async (runner) => {
|
|
693
|
+
const user = { ...TestUsers.admin, tenantId: "cap-tenant-evt" };
|
|
694
|
+
await runner.handleEvent("test:write:capped-event", { n: 1 }, user);
|
|
695
|
+
await runner.handleEvent("test:write:capped-event", { n: 2 }, user);
|
|
696
|
+
// Third must be skipped by the maxPerTenant guard.
|
|
697
|
+
await runner.handleEvent("test:write:capped-event", { n: 3 }, user);
|
|
698
|
+
await sleep(200);
|
|
699
|
+
const started = jobLog.filter((e) => e.name === "test:job:per-tenant-limited-event");
|
|
700
|
+
expect(started.length).toBeLessThanOrEqual(2);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
602
703
|
});
|
|
603
704
|
|
|
604
705
|
// --- Registry ---
|
|
@@ -25,6 +25,17 @@ const echoFeature = defineFeature("echo", (r) => {
|
|
|
25
25
|
{ access: { openToAll: true } },
|
|
26
26
|
);
|
|
27
27
|
|
|
28
|
+
r.streamHandler(
|
|
29
|
+
"item:tail",
|
|
30
|
+
z.object({ count: z.number().int().min(0) }),
|
|
31
|
+
async function* (query) {
|
|
32
|
+
for (let i = 0; i < query.payload.count; i++) {
|
|
33
|
+
yield { i };
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{ access: { roles: ["Admin"] } },
|
|
37
|
+
);
|
|
38
|
+
|
|
28
39
|
r.hook("validation", "item:create", (data) => {
|
|
29
40
|
if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
|
|
30
41
|
return null;
|
|
@@ -214,6 +225,55 @@ describe("dispatcher.query", () => {
|
|
|
214
225
|
});
|
|
215
226
|
});
|
|
216
227
|
|
|
228
|
+
async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
|
|
229
|
+
const out: T[] = [];
|
|
230
|
+
for await (const chunk of gen) out.push(chunk);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe("dispatcher.stream", () => {
|
|
235
|
+
test("validates and calls stream handler, yielding chunks in order", async () => {
|
|
236
|
+
const dispatcher = createTestDispatcher();
|
|
237
|
+
const chunks = await collect(
|
|
238
|
+
dispatcher.stream(
|
|
239
|
+
"echo:stream:item:tail",
|
|
240
|
+
{ count: 3 },
|
|
241
|
+
createTestUser({ roles: ["Admin"] }),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(chunks).toEqual([{ i: 0 }, { i: 1 }, { i: 2 }]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("gates fire on first pull, not on the stream() call itself", async () => {
|
|
249
|
+
const dispatcher = createTestDispatcher();
|
|
250
|
+
const gen = dispatcher.stream(
|
|
251
|
+
"echo:stream:item:tail",
|
|
252
|
+
{ count: -1 },
|
|
253
|
+
createTestUser({ roles: ["Admin"] }),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await expect(collect(gen)).rejects.toMatchObject({ code: "validation_error", httpStatus: 400 });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("rejects unauthorized user", async () => {
|
|
260
|
+
const dispatcher = createTestDispatcher();
|
|
261
|
+
const guest = createTestUser({ roles: ["Guest"] });
|
|
262
|
+
|
|
263
|
+
await expect(
|
|
264
|
+
collect(dispatcher.stream("echo:stream:item:tail", { count: 1 }, guest)),
|
|
265
|
+
).rejects.toMatchObject({ code: "access_denied" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("throws for unknown stream handler", async () => {
|
|
269
|
+
const dispatcher = createTestDispatcher();
|
|
270
|
+
|
|
271
|
+
await expect(
|
|
272
|
+
collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
|
|
273
|
+
).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
217
277
|
// --- postQuery hooks on standalone (entity-less) queries ---
|
|
218
278
|
|
|
219
279
|
describe("dispatcher.query postQuery hooks", () => {
|
|
@@ -362,6 +422,20 @@ describe("dispatcher feature-gate", () => {
|
|
|
362
422
|
async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
|
|
363
423
|
{ access: { roles: ["Admin"] } },
|
|
364
424
|
);
|
|
425
|
+
r.streamHandler(
|
|
426
|
+
"widget:tail",
|
|
427
|
+
z.object({}).passthrough(),
|
|
428
|
+
async function* () {
|
|
429
|
+
// Never completes on its own — the mid-stream toggle-off test
|
|
430
|
+
// relies on the generator staying open past the first yield.
|
|
431
|
+
let i = 0;
|
|
432
|
+
while (true) {
|
|
433
|
+
yield { i };
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
{ access: { openToAll: true } },
|
|
438
|
+
);
|
|
365
439
|
});
|
|
366
440
|
}
|
|
367
441
|
|
|
@@ -397,6 +471,28 @@ describe("dispatcher feature-gate", () => {
|
|
|
397
471
|
});
|
|
398
472
|
});
|
|
399
473
|
|
|
474
|
+
test("disabling the feature mid-stream aborts an already-open stream", async () => {
|
|
475
|
+
const registry = createRegistry([toggled()]);
|
|
476
|
+
const disabled = new Set<string>();
|
|
477
|
+
const dispatcher = createDispatcher(
|
|
478
|
+
registry,
|
|
479
|
+
{},
|
|
480
|
+
{
|
|
481
|
+
effectiveFeatures: () => {
|
|
482
|
+
const all = new Set(registry.features.keys());
|
|
483
|
+
for (const d of disabled) all.delete(d);
|
|
484
|
+
return all;
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const gen = dispatcher.stream("toggled:stream:widget:tail", {}, user);
|
|
490
|
+
expect((await gen.next()).value).toEqual({ i: 0 });
|
|
491
|
+
|
|
492
|
+
disabled.add("toggled");
|
|
493
|
+
await expect(gen.next()).rejects.toThrow(/feature toggled is disabled/);
|
|
494
|
+
});
|
|
495
|
+
|
|
400
496
|
test("write of disabled feature returns WriteFailure with feature_disabled reason", async () => {
|
|
401
497
|
const registry = createRegistry([toggled()]);
|
|
402
498
|
const disabled = new Set<string>(["toggled"]);
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createEntity,
|
|
5
5
|
createRegistry,
|
|
6
6
|
createTextField,
|
|
7
|
+
type DeleteContext,
|
|
7
8
|
defineFeature,
|
|
8
9
|
type PostSaveHookFn,
|
|
9
10
|
type PreSaveHookFn,
|
|
@@ -528,3 +529,210 @@ describe("buildEventId — dedup key construction", () => {
|
|
|
528
529
|
expect(buildEventId("handler", { id: 0 }, "phase")).toBeNull();
|
|
529
530
|
});
|
|
530
531
|
});
|
|
532
|
+
|
|
533
|
+
// --- PreDelete / PostDelete pipeline ---
|
|
534
|
+
|
|
535
|
+
const deletectx: DeleteContext = {
|
|
536
|
+
kind: "delete",
|
|
537
|
+
id: 1,
|
|
538
|
+
data: { email: "test@test.de" },
|
|
539
|
+
entityName: "user",
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
describe("runPreDelete", () => {
|
|
543
|
+
test("runs feature + entity + system hooks, all inTransaction (throw on error)", async () => {
|
|
544
|
+
const calls: string[] = [];
|
|
545
|
+
const feature = defineFeature("test", (r) => {
|
|
546
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
547
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
548
|
+
access: { openToAll: true },
|
|
549
|
+
});
|
|
550
|
+
r.hook("preDelete", "user", async () => {
|
|
551
|
+
calls.push("handler");
|
|
552
|
+
});
|
|
553
|
+
r.hook("preDelete", { allOf: "user" }, async () => {
|
|
554
|
+
calls.push("entity");
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
const registry = createRegistry([feature]);
|
|
558
|
+
const systemHooks: SystemHooks = {
|
|
559
|
+
preDelete: [
|
|
560
|
+
{
|
|
561
|
+
name: "sys",
|
|
562
|
+
priority: 1000,
|
|
563
|
+
fn: async () => {
|
|
564
|
+
calls.push("system");
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
570
|
+
await pipeline.runPreDelete("test:write:user", deletectx, {});
|
|
571
|
+
expect(calls).toEqual(["handler", "entity", "system"]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("a hook throwing aborts the delete (rejects, not swallowed)", async () => {
|
|
575
|
+
const feature = defineFeature("test", (r) => {
|
|
576
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
577
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
578
|
+
access: { openToAll: true },
|
|
579
|
+
});
|
|
580
|
+
r.hook("preDelete", "user", async () => {
|
|
581
|
+
throw new Error("blocked-delete");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
const registry = createRegistry([feature]);
|
|
585
|
+
const pipeline = createLifecycleHooks(registry);
|
|
586
|
+
await expect(pipeline.runPreDelete("test:write:user", deletectx, {})).rejects.toThrow(
|
|
587
|
+
"blocked-delete",
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("system hook with a non-inTransaction phase is skipped", async () => {
|
|
592
|
+
const registry = makeRegistry();
|
|
593
|
+
const calls: string[] = [];
|
|
594
|
+
const systemHooks: SystemHooks = {
|
|
595
|
+
preDelete: [
|
|
596
|
+
{
|
|
597
|
+
name: "sys",
|
|
598
|
+
priority: 1000,
|
|
599
|
+
phase: "afterCommit",
|
|
600
|
+
fn: async () => {
|
|
601
|
+
calls.push("system");
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
};
|
|
606
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
607
|
+
await pipeline.runPreDelete("test:write:user", deletectx, {});
|
|
608
|
+
expect(calls).toEqual([]);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe("runPostDelete", () => {
|
|
613
|
+
test("runs feature then system hooks (best-effort by default phase)", async () => {
|
|
614
|
+
const calls: string[] = [];
|
|
615
|
+
const feature = defineFeature("test", (r) => {
|
|
616
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
617
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
618
|
+
access: { openToAll: true },
|
|
619
|
+
});
|
|
620
|
+
r.hook("postDelete", "user", async () => {
|
|
621
|
+
calls.push("feature");
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
const registry = createRegistry([feature]);
|
|
625
|
+
const systemHooks: SystemHooks = {
|
|
626
|
+
postDelete: [
|
|
627
|
+
{
|
|
628
|
+
name: "sys",
|
|
629
|
+
priority: 1000,
|
|
630
|
+
fn: async () => {
|
|
631
|
+
calls.push("system");
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
};
|
|
636
|
+
const pipeline = createLifecycleHooks(registry, systemHooks);
|
|
637
|
+
await pipeline.runPostDelete("test:write:user", deletectx, {});
|
|
638
|
+
expect(calls).toEqual(["feature", "system"]);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("inTransaction phase: hook errors throw", async () => {
|
|
642
|
+
const feature = defineFeature("test", (r) => {
|
|
643
|
+
r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
|
|
644
|
+
r.writeHandler("user", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
645
|
+
access: { openToAll: true },
|
|
646
|
+
});
|
|
647
|
+
r.hook(
|
|
648
|
+
"postDelete",
|
|
649
|
+
"user",
|
|
650
|
+
async () => {
|
|
651
|
+
throw new Error("postDelete-inTx-boom");
|
|
652
|
+
},
|
|
653
|
+
{ phase: "inTransaction" },
|
|
654
|
+
);
|
|
655
|
+
});
|
|
656
|
+
const registry = createRegistry([feature]);
|
|
657
|
+
const pipeline = createLifecycleHooks(registry);
|
|
658
|
+
await expect(
|
|
659
|
+
pipeline.runPostDelete("test:write:user", deletectx, {}, "inTransaction"),
|
|
660
|
+
).rejects.toThrow("postDelete-inTx-boom");
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// --- Batch hooks ---
|
|
665
|
+
|
|
666
|
+
describe("runPostSaveBatch / runPostDeleteBatch", () => {
|
|
667
|
+
test("no batch hooks registered → resolves without throwing", async () => {
|
|
668
|
+
const pipeline = createLifecycleHooks(makeRegistry());
|
|
669
|
+
await expect(pipeline.runPostSaveBatch([savectx], {})).resolves.toBeUndefined();
|
|
670
|
+
await expect(pipeline.runPostDeleteBatch([deletectx], {})).resolves.toBeUndefined();
|
|
671
|
+
// Should not throw — nothing registered.
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("runPostSaveBatch runs all system hooks concurrently with the batch payload", async () => {
|
|
675
|
+
const seen: (readonly SaveContext[])[] = [];
|
|
676
|
+
const systemHooks: SystemHooks = {
|
|
677
|
+
postSaveBatch: [
|
|
678
|
+
{
|
|
679
|
+
name: "a",
|
|
680
|
+
priority: 1000,
|
|
681
|
+
fn: async (results) => {
|
|
682
|
+
seen.push(results);
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
};
|
|
687
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
688
|
+
await pipeline.runPostSaveBatch([savectx], {});
|
|
689
|
+
expect(seen).toEqual([[savectx]]);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("runPostDeleteBatch runs all system hooks with the batch payload", async () => {
|
|
693
|
+
const seen: (readonly DeleteContext[])[] = [];
|
|
694
|
+
const systemHooks: SystemHooks = {
|
|
695
|
+
postDeleteBatch: [
|
|
696
|
+
{
|
|
697
|
+
name: "a",
|
|
698
|
+
priority: 1000,
|
|
699
|
+
fn: async (payloads) => {
|
|
700
|
+
seen.push(payloads);
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
};
|
|
705
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
706
|
+
await pipeline.runPostDeleteBatch([deletectx], {});
|
|
707
|
+
expect(seen).toEqual([[deletectx]]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("one batch hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async () => {
|
|
711
|
+
const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
712
|
+
const calls: string[] = [];
|
|
713
|
+
const systemHooks: SystemHooks = {
|
|
714
|
+
postSaveBatch: [
|
|
715
|
+
{
|
|
716
|
+
name: "failing",
|
|
717
|
+
priority: 1000,
|
|
718
|
+
fn: async () => {
|
|
719
|
+
throw new Error("batch-hook-boom");
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
name: "ok",
|
|
724
|
+
priority: 1001,
|
|
725
|
+
fn: async () => {
|
|
726
|
+
calls.push("ok-ran");
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
};
|
|
731
|
+
const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
|
|
732
|
+
// Must not throw.
|
|
733
|
+
await pipeline.runPostSaveBatch([savectx], {});
|
|
734
|
+
expect(calls).toEqual(["ok-ran"]);
|
|
735
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
736
|
+
consoleSpy.mockRestore();
|
|
737
|
+
});
|
|
738
|
+
});
|
|
@@ -559,7 +559,7 @@ export function buildHandlerContext(
|
|
|
559
559
|
export async function runHandlerInstrumented<T>(
|
|
560
560
|
ctx: DispatchContext,
|
|
561
561
|
type: string,
|
|
562
|
-
operation: "query" | "write",
|
|
562
|
+
operation: "query" | "write" | "stream",
|
|
563
563
|
user: SessionUser,
|
|
564
564
|
inner: () => Promise<T>,
|
|
565
565
|
): Promise<T> {
|
|
@@ -609,6 +609,44 @@ export async function runHandlerInstrumented<T>(
|
|
|
609
609
|
}
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
+
// Generator-native counterpart to runHandlerInstrumented — a stream's
|
|
613
|
+
// lifetime spans every `for await` pull the caller makes, so the span
|
|
614
|
+
// can't be scoped via withSpan's single-callback shape. startSpan/end
|
|
615
|
+
// bracket the whole yield* instead; metrics land in the same finally
|
|
616
|
+
// path so success/failure/throw all hit one emit, like the Promise path.
|
|
617
|
+
export async function* runStreamInstrumented<T>(
|
|
618
|
+
ctx: DispatchContext,
|
|
619
|
+
type: string,
|
|
620
|
+
user: SessionUser,
|
|
621
|
+
inner: () => AsyncGenerator<T>,
|
|
622
|
+
): AsyncGenerator<T> {
|
|
623
|
+
const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
|
|
624
|
+
const start = performance.now();
|
|
625
|
+
let success = true;
|
|
626
|
+
let errorClass: string | undefined;
|
|
627
|
+
const span = dispatcherTracer.startSpan("kumiko.dispatcher.handler", {
|
|
628
|
+
attributes: dispatcherSpanAttributes(type, "stream", user, registry.getHandlerFeature(type)),
|
|
629
|
+
});
|
|
630
|
+
try {
|
|
631
|
+
yield* inner();
|
|
632
|
+
} catch (error) {
|
|
633
|
+
success = false;
|
|
634
|
+
errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
|
|
635
|
+
span.setStatus("error", errorClass);
|
|
636
|
+
throw error;
|
|
637
|
+
} finally {
|
|
638
|
+
span.end();
|
|
639
|
+
if (!success && errorClass) {
|
|
640
|
+
emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
|
|
641
|
+
}
|
|
642
|
+
emitDispatcherHandler(
|
|
643
|
+
dispatcherMeter,
|
|
644
|
+
{ handler: type, success },
|
|
645
|
+
(performance.now() - start) / 1000,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
612
650
|
// Feature-toggle gate. Returns the error to fold into a WriteFailure in the
|
|
613
651
|
// write path, or throws for the query path (where throws flow through the
|
|
614
652
|
// same outer instrumentation wrapper as other dispatcher errors).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { DbTx } from "../db/connection";
|
|
2
|
+
import { hasAccess } from "../engine/access";
|
|
3
|
+
import type { SessionUser } from "../engine/types";
|
|
4
|
+
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
|
|
5
|
+
import { assertNoSecretLeak } from "../secrets";
|
|
6
|
+
import {
|
|
7
|
+
buildHandlerContext,
|
|
8
|
+
type DispatchContext,
|
|
9
|
+
enforceRateLimit,
|
|
10
|
+
ensureFeatureEnabled,
|
|
11
|
+
runStreamInstrumented,
|
|
12
|
+
} from "./dispatch-shared";
|
|
13
|
+
|
|
14
|
+
// Standalone stream execution — used by the public dispatcher.stream().
|
|
15
|
+
// Chunk-by-chunk analog of executeQuery: same gate order (feature → rate-
|
|
16
|
+
// limit → access → validation → handler), but yields incrementally instead
|
|
17
|
+
// of returning a single response. streamHandler never entity-maps (unlike
|
|
18
|
+
// write/queryHandler — see feature-entity-handlers.ts), so there's no
|
|
19
|
+
// field-access filter or postQuery-hook stage to run here.
|
|
20
|
+
export async function* executeStream(
|
|
21
|
+
ctx: DispatchContext,
|
|
22
|
+
type: string,
|
|
23
|
+
payload: unknown,
|
|
24
|
+
user: SessionUser,
|
|
25
|
+
tx?: DbTx,
|
|
26
|
+
): AsyncGenerator<unknown> {
|
|
27
|
+
yield* runStreamInstrumented(ctx, type, user, () =>
|
|
28
|
+
executeStreamInner(ctx, type, payload, user, tx),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function* executeStreamInner(
|
|
33
|
+
ctx: DispatchContext,
|
|
34
|
+
type: string,
|
|
35
|
+
payload: unknown,
|
|
36
|
+
user: SessionUser,
|
|
37
|
+
tx?: DbTx,
|
|
38
|
+
): AsyncGenerator<unknown> {
|
|
39
|
+
const { registry } = ctx;
|
|
40
|
+
const handler = registry.getStreamHandler(type);
|
|
41
|
+
if (!handler) throw new NotFoundError("handler", type);
|
|
42
|
+
|
|
43
|
+
await ensureFeatureEnabled(ctx, type, user.tenantId);
|
|
44
|
+
|
|
45
|
+
if (handler.rateLimit !== undefined) {
|
|
46
|
+
await enforceRateLimit(ctx, handler.rateLimit, type, user);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!hasAccess(user, handler.access)) {
|
|
50
|
+
throw new AccessDeniedError({
|
|
51
|
+
message: `access denied for ${type}`,
|
|
52
|
+
details: { handler: type },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const parsed = handler.schema.safeParse(payload);
|
|
57
|
+
if (!parsed.success) {
|
|
58
|
+
throw validationErrorFromZod(parsed.error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handlerContext = buildHandlerContext(ctx, type, user, tx);
|
|
62
|
+
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
63
|
+
|
|
64
|
+
// Consumer-driven pull (for await) is the backpressure mechanism — the
|
|
65
|
+
// handler generator only advances once the caller reads the previous
|
|
66
|
+
// chunk, no explicit buffering/throttling needed on either side.
|
|
67
|
+
for await (const chunk of chunks) {
|
|
68
|
+
// Re-checked per chunk, not just at stream-start: a feature disabled
|
|
69
|
+
// mid-stream must cut an already-open stream, not just block new ones.
|
|
70
|
+
await ensureFeatureEnabled(ctx, type, user.tenantId);
|
|
71
|
+
assertNoSecretLeak(chunk);
|
|
72
|
+
yield chunk;
|
|
73
|
+
}
|
|
74
|
+
}
|