@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.
Files changed (32) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +30 -0
  3. package/src/api/__tests__/api.test.ts +60 -0
  4. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -2
  5. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
  6. package/src/api/routes.ts +41 -22
  7. package/src/api/server.ts +14 -6
  8. package/src/bun-db/connection.ts +3 -3
  9. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +5 -12
  10. package/src/db/index.ts +0 -2
  11. package/src/db/pg-error.ts +1 -1
  12. package/src/db/queries/event-store.ts +14 -16
  13. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  14. package/src/engine/__tests__/registry.test.ts +36 -0
  15. package/src/engine/boot-validator/entity-handler.ts +14 -25
  16. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  17. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  18. package/src/engine/registry-validate.ts +7 -2
  19. package/src/engine/types/index.ts +0 -2
  20. package/src/event-store/__tests__/event-store.integration.test.ts +138 -0
  21. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +5 -8
  22. package/src/event-store/event-store.ts +12 -23
  23. package/src/event-store/events-schema.ts +10 -3
  24. package/src/event-store/index.ts +1 -2
  25. package/src/pipeline/__tests__/dispatcher.test.ts +52 -0
  26. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  27. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +31 -18
  28. package/src/pipeline/dispatch-stream.ts +2 -7
  29. package/src/pipeline/event-dispatcher-delivery.ts +2 -1
  30. package/src/pipeline/system-hooks.ts +50 -1
  31. package/src/db/__tests__/encryption.test.ts +0 -39
  32. package/src/db/encryption.ts +0 -45
@@ -258,6 +258,7 @@ describe("render → parse roundtrip — mixed patterns (header data + opaque bo
258
258
  // from the parsed FeaturePattern shape alone.
259
259
  const RAW_REF_FEATURE = `
260
260
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
261
+ import { chatCompleteHandler } from "./handlers";
261
262
 
262
263
  const eventEntity = {
263
264
  fields: { name: { type: "text", required: true } },
@@ -284,6 +285,7 @@ defineFeature("refs", (r) => {
284
285
  r.entity("event", eventEntity);
285
286
  r.entity("task", { fields: buildFields() });
286
287
  r.writeHandler(makeHandler());
288
+ r.streamHandler(chatCompleteHandler);
287
289
  r.screen(eventListScreen);
288
290
  });
289
291
  `;
@@ -297,6 +299,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
297
299
  { kind: "entity", entityName: "event", definition: { __raw: "eventEntity" } },
298
300
  { kind: "entity", entityName: "task", definition: { fields: { __raw: "buildFields()" } } },
299
301
  { kind: "writeHandler", handlerName: undefined },
302
+ { kind: "streamHandler", handlerName: undefined },
300
303
  { kind: "screen", definition: { __raw: "eventListScreen" } },
301
304
  ]);
302
305
  });
@@ -310,6 +313,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
310
313
  expect(rendered).toContain("eventEntity");
311
314
  expect(rendered).toContain("buildFields()");
312
315
  expect(rendered).toContain("r.writeHandler(makeHandler())");
316
+ expect(rendered).toContain("r.streamHandler(chatCompleteHandler)");
313
317
  expect(rendered).toContain("r.screen(eventListScreen);");
314
318
  // Would only appear if buildFields()'s return value got inlined.
315
319
  expect(rendered).not.toContain("title:");
@@ -204,21 +204,24 @@ export function extractWriteHandler(
204
204
  });
205
205
  }
206
206
 
207
- export function extractQueryHandler(
208
- call: CallExpression,
209
- sourceFile: SourceFile,
210
- ): ExtractOutput<QueryHandlerPattern> {
211
- const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
212
- if (parsed.kind === "error") return parsed;
213
- return ok({
214
- kind: "queryHandler",
207
+ function readHandlerFields(parsed: Extract<ExtractOutput<ParsedHandlerCall>, { kind: "pattern" }>) {
208
+ return {
215
209
  source: parsed.pattern.source,
216
210
  handlerName: parsed.pattern.handlerName,
217
211
  schemaSource: parsed.pattern.schemaSource,
218
212
  handlerBody: parsed.pattern.handlerBody,
219
213
  ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
220
214
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
221
- });
215
+ };
216
+ }
217
+
218
+ export function extractQueryHandler(
219
+ call: CallExpression,
220
+ sourceFile: SourceFile,
221
+ ): ExtractOutput<QueryHandlerPattern> {
222
+ const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
223
+ if (parsed.kind === "error") return parsed;
224
+ return ok({ kind: "queryHandler", ...readHandlerFields(parsed) });
222
225
  }
223
226
 
224
227
  export function extractStreamHandler(
@@ -227,13 +230,5 @@ export function extractStreamHandler(
227
230
  ): ExtractOutput<StreamHandlerPattern> {
228
231
  const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
229
232
  if (parsed.kind === "error") return parsed;
230
- return ok({
231
- kind: "streamHandler",
232
- source: parsed.pattern.source,
233
- handlerName: parsed.pattern.handlerName,
234
- schemaSource: parsed.pattern.schemaSource,
235
- handlerBody: parsed.pattern.handlerBody,
236
- ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
237
- ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
238
- });
233
+ return ok({ kind: "streamHandler", ...readHandlerFields(parsed) });
239
234
  }
@@ -564,7 +564,11 @@ export function validateEntityHookTargets(
564
564
  }
565
565
 
566
566
  export function validateJobTriggers(state: RegistryState): void {
567
- // Validate: job event triggers must reference existing handlers.
567
+ // Validate: job event triggers must reference an existing write/query
568
+ // handler OR an existing r.defineEvent registration. The latter is
569
+ // delivered async via the job-trigger event-consumer (server.ts), not
570
+ // the synchronous write-handler dispatch path — see
571
+ // createJobTriggerEventConsumer in pipeline/system-hooks.ts.
568
572
  // Multi-Trigger-Form: jeden Eintrag im Array gegen allHandlers prüfen,
569
573
  // auch wenn nur einer fehlt fail-fast.
570
574
  const allHandlers = allHandlerQns(state);
@@ -575,8 +579,9 @@ export function validateJobTriggers(state: RegistryState): void {
575
579
  for (const t of triggers) {
576
580
  const rawName = resolveName(t);
577
581
  if (allHandlers.has(rawName)) continue;
582
+ if (state.eventMap.has(rawName)) continue;
578
583
  throw new Error(
579
- `Job "${jobName}" triggers on "${rawName}" but no handler with that name exists`,
584
+ `Job "${jobName}" triggers on "${rawName}" but no handler or event with that name exists`,
580
585
  );
581
586
  }
582
587
  }
@@ -1,7 +1,5 @@
1
1
  // Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
2
2
  // runtime helpers below that stay framework-side.
3
- // Duplicate types (OnDeleteStrategy, ConfigScope, ConcurrencyMode, LifecycleHookType)
4
- // are defined ONLY in constants.ts — re-exported here for backwards compatibility.
5
3
 
6
4
  export type {
7
5
  ConfigAccessor,
@@ -6,6 +6,7 @@ import { generateId as uuid } from "../../utils";
6
6
  import {
7
7
  append,
8
8
  createEventsTable,
9
+ IdempotentAppendConflictError,
9
10
  loadAggregate,
10
11
  loadAggregateAsOf,
11
12
  loadAllEventsByType,
@@ -93,6 +94,143 @@ describe("event-store: append + load", () => {
93
94
  });
94
95
  });
95
96
 
97
+ describe("event-store: idempotency-key conflict", () => {
98
+ test("second append with a reused idempotencyKey (new aggregate+version) throws IdempotentAppendConflictError", async () => {
99
+ const first = uuid();
100
+ const second = uuid();
101
+ const key = uuid();
102
+
103
+ await append(testDb.db, {
104
+ aggregateId: first,
105
+ aggregateType: "task",
106
+ tenantId: tenantA,
107
+ expectedVersion: 0,
108
+ type: "task.created",
109
+ payload: { title: "Orig" },
110
+ metadata: { userId: userA, idempotencyKey: key },
111
+ });
112
+
113
+ // A retried command that re-runs after the Redis idempotency guard
114
+ // missed its window: different aggregate, fresh expectedVersion=0 — the
115
+ // aggregate-version unique index has nothing to say about this pair, so
116
+ // only the idempotency-key index catches the duplicate.
117
+ await expect(
118
+ append(testDb.db, {
119
+ aggregateId: second,
120
+ aggregateType: "task",
121
+ tenantId: tenantA,
122
+ expectedVersion: 0,
123
+ type: "task.created",
124
+ payload: { title: "Retry" },
125
+ metadata: { userId: userA, idempotencyKey: key },
126
+ }),
127
+ ).rejects.toThrow(IdempotentAppendConflictError);
128
+
129
+ const events = await loadAggregate(testDb.db, second, tenantA);
130
+ expect(events).toHaveLength(0);
131
+ });
132
+
133
+ test("subsequent-event append (expectedVersion > 0) also enforces the idempotency key", async () => {
134
+ const aggregateId = uuid();
135
+ const key = uuid();
136
+
137
+ await append(testDb.db, {
138
+ aggregateId,
139
+ aggregateType: "task",
140
+ tenantId: tenantA,
141
+ expectedVersion: 0,
142
+ type: "task.created",
143
+ payload: { title: "Orig" },
144
+ metadata: { userId: userA },
145
+ });
146
+
147
+ await append(testDb.db, {
148
+ aggregateId,
149
+ aggregateType: "task",
150
+ tenantId: tenantA,
151
+ expectedVersion: 1,
152
+ type: "task.updated",
153
+ payload: { title: "V2" },
154
+ metadata: { userId: userA, idempotencyKey: key },
155
+ });
156
+
157
+ // Retry of the v2 update: goes through insertSubsequentEventRow's raw
158
+ // INSERT ... SELECT ... WHERE EXISTS path, not insertFirstEvent — the
159
+ // idempotency index must catch it there too.
160
+ await expect(
161
+ append(testDb.db, {
162
+ aggregateId,
163
+ aggregateType: "task",
164
+ tenantId: tenantA,
165
+ expectedVersion: 2,
166
+ type: "task.updated",
167
+ payload: { title: "V3-retry" },
168
+ metadata: { userId: userA, idempotencyKey: key },
169
+ }),
170
+ ).rejects.toThrow(IdempotentAppendConflictError);
171
+
172
+ const events = await loadAggregate(testDb.db, aggregateId, tenantA);
173
+ expect(events).toHaveLength(2);
174
+ });
175
+
176
+ test("same idempotencyKey on a different tenant does not conflict", async () => {
177
+ const key = uuid();
178
+
179
+ const a = await append(testDb.db, {
180
+ aggregateId: uuid(),
181
+ aggregateType: "task",
182
+ tenantId: tenantA,
183
+ expectedVersion: 0,
184
+ type: "task.created",
185
+ payload: {},
186
+ metadata: { userId: userA, idempotencyKey: key },
187
+ });
188
+ const b = await append(testDb.db, {
189
+ aggregateId: uuid(),
190
+ aggregateType: "task",
191
+ tenantId: tenantB,
192
+ expectedVersion: 0,
193
+ type: "task.created",
194
+ payload: {},
195
+ metadata: { userId: userA, idempotencyKey: key },
196
+ });
197
+
198
+ expect(a.version).toBe(1);
199
+ expect(b.version).toBe(1);
200
+ });
201
+
202
+ test("omitting idempotencyKey allows unlimited appends, unchanged from before", async () => {
203
+ // Same tenant for both, unlike the different-aggregateId version this
204
+ // replaces — Postgres treats every NULL as distinct even in a plain
205
+ // unique index, so this does NOT exercise the partial index's `WHERE
206
+ // ... IS NOT NULL` clause specifically (that's provable only by a
207
+ // duplicate NON-null key, covered above). What this does pin: a fully-
208
+ // omitted key and an explicit `idempotencyKey: undefined` both serialize
209
+ // to a JSON-absent key (JSON.stringify drops undefined) and must behave
210
+ // identically, rather than one silently colliding on `"idempotencyKey":null`.
211
+ const omitted = await append(testDb.db, {
212
+ aggregateId: uuid(),
213
+ aggregateType: "task",
214
+ tenantId: tenantA,
215
+ expectedVersion: 0,
216
+ type: "task.created",
217
+ payload: {},
218
+ metadata: { userId: userA },
219
+ });
220
+ const explicitUndefined = await append(testDb.db, {
221
+ aggregateId: uuid(),
222
+ aggregateType: "task",
223
+ tenantId: tenantA,
224
+ expectedVersion: 0,
225
+ type: "task.created",
226
+ payload: {},
227
+ metadata: { userId: userA, idempotencyKey: undefined },
228
+ });
229
+ expect(omitted.version).toBe(1);
230
+ expect(explicitUndefined.version).toBe(1);
231
+ });
232
+ });
233
+
96
234
  describe("event-store: optimistic concurrency", () => {
97
235
  test("wrong expectedVersion throws VersionConflictError (no write)", async () => {
98
236
  const aggregateId = uuid();
@@ -1,14 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Glob } from "bun";
3
3
 
4
- // getUnscopedAggregateStreamMaxVersion / getUnscopedAggregateStreamTenant have
5
- // no tenant filter — a caller can use them to probe whether a foreign tenant's
6
- // aggregate exists (see event-store.ts SECURITY doc). Restricted to known
7
- // seed/system-internal callers; extend only for genuine new ones.
8
- const RESTRICTED_SYMBOLS = [
9
- "getUnscopedAggregateStreamMaxVersion",
10
- "getUnscopedAggregateStreamTenant",
11
- ];
4
+ // getUnscopedAggregateStreamMaxVersion has no tenant filter — a caller can use
5
+ // it to probe whether a foreign tenant's aggregate exists (see event-store.ts
6
+ // SECURITY doc). Restricted to known seed/system-internal callers; extend
7
+ // only for genuine new ones.
8
+ const RESTRICTED_SYMBOLS = ["getUnscopedAggregateStreamMaxVersion"];
12
9
 
13
10
  const ALLOWED_FILES = new Set([
14
11
  "packages/framework/src/event-store/event-store.ts",
@@ -1,19 +1,18 @@
1
1
  import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
2
2
  import { encryptEventPayloadPii } from "../crypto/event-pii";
3
3
  import type { DbRunner } from "../db";
4
- import { isUniqueViolation } from "../db/pg-error";
4
+ import { constraintOf, isUniqueViolation } from "../db/pg-error";
5
5
  import {
6
6
  insertSubsequentEventRow,
7
7
  notifyPgChannel,
8
8
  selectAggregateMaxVersion,
9
- selectAggregateStreamTenant,
10
9
  selectEventsHighWaterMark,
11
10
  selectStreamMaxVersion,
12
11
  } from "../db/queries/event-store";
13
12
  import { insertOne, selectMany } from "../db/query";
14
13
  import type { TenantId } from "../engine/types";
15
14
  import { isStreamArchived } from "./archive";
16
- import { VersionConflictError } from "./errors";
15
+ import { IdempotentAppendConflictError, VersionConflictError } from "./errors";
17
16
  import { eventsTable } from "./events-schema";
18
17
  import { toStoredEvent } from "./row-to-stored-event";
19
18
 
@@ -91,10 +90,15 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
91
90
  return buildStoredEvent(toStore, newVersion, eventVersion, row);
92
91
  } catch (e) {
93
92
  if (isUniqueViolation(e)) {
94
- // Only constraint left on the events table: events_aggregate_version_uq
95
- // on (tenant_id, aggregate_id, version). A unique violation here always
96
- // means a concurrent writer in the same tenant won the race to the
97
- // next version retry-able conflict.
93
+ // Two unique constraints on this table: events_aggregate_version_uq
94
+ // (tenant_id, aggregate_id, version) a concurrent writer won the
95
+ // race to the next version and events_idempotency_uq (tenant_id,
96
+ // metadata->>'idempotencyKey')the caller reused an idempotency key.
97
+ // constraintOf() tells them apart; unknown/renamed constraint falls
98
+ // back to VersionConflictError, the pre-existing behaviour.
99
+ if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
100
+ throw new IdempotentAppendConflictError(event.tenantId, event.metadata.idempotencyKey);
101
+ }
98
102
  throw new VersionConflictError(event.aggregateId, event.expectedVersion);
99
103
  }
100
104
  throw e;
@@ -255,21 +259,6 @@ export async function getUnscopedAggregateStreamMaxVersion(
255
259
  return selectAggregateMaxVersion(db, aggregateId);
256
260
  }
257
261
 
258
- /** Stream tenant of an aggregate (the tenant_id its events live under), with no
259
- * membership/tenant filter. SECURITY: existence-oracle, same caveat as
260
- * getUnscopedAggregateStreamMaxVersion — seed/system-internal use only. Recovers
261
- * the write target for a systemScope aggregate whose stream tenant isn't one of
262
- * the subject's memberships. Returns null for unknown streams. */
263
- export async function getUnscopedAggregateStreamTenant(
264
- db: DbRunner,
265
- aggregateId: string,
266
- aggregateType: string,
267
- ): Promise<TenantId | null> {
268
- const tenantId = await selectAggregateStreamTenant(db, aggregateId, aggregateType);
269
- // DB-boundary: kumiko_events.tenant_id is a TenantId-shaped uuid column.
270
- return tenantId as TenantId | null;
271
- }
272
-
273
262
  // Global high-water-mark = MAX(events.id). Marten/Wolverine standard for
274
263
  // projection/consumer lag math: lag = HWM - cursor. Single-row aggregate over
275
264
  // the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
@@ -307,7 +296,7 @@ export async function loadEventsAfterVersion(
307
296
  // prevent.
308
297
  export const LOAD_ALL_EVENTS_ROW_LIMIT = 100_000;
309
298
 
310
- /** @deprecated buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) instead. */
299
+ /** Buffers ALL matching events in memory — a memory cliff for large stores. Use `streamAllEventsByType` (yields batchwise) for production reads; this is test-only in practice. */
311
300
  export async function loadAllEventsByType(
312
301
  db: DbRunner,
313
302
  aggregateType: string,
@@ -12,6 +12,7 @@ import {
12
12
  uniqueIndex,
13
13
  uuid,
14
14
  } from "../db/dialect";
15
+ import { ensureIdempotencyKeyIndex } from "../db/queries/event-store";
15
16
  import { unsafePushTables } from "../stack";
16
17
  import { createArchivedStreamsTable } from "./archive";
17
18
  import { createSnapshotsTable } from "./snapshot";
@@ -22,9 +23,11 @@ import type { EventMetadata } from "./types";
22
23
  // INSERT ... SELECT ... WHERE EXISTS isn't ergonomic in the typed builder.
23
24
  //
24
25
  // HTTP-level retry idempotency is handled by pipeline/idempotency.ts
25
- // (Redis-backed check + cached-response replay). The event-store itself
26
- // imposes no idempotency indexa single HTTP request may write N events
27
- // freely, metadata.requestId is purely a trace marker.
26
+ // (Redis-backed check + cached-response replay); metadata.requestId is
27
+ // purely a trace marker (no uniqueness constraintone request may write
28
+ // N events). Callers that need a hard per-event guarantee as a second line
29
+ // of defense set metadata.idempotencyKey, enforced by the tenant-scoped
30
+ // partial unique index ensureIdempotencyKeyIndex() creates below.
28
31
 
29
32
  export const eventsTable = pgTable(
30
33
  "kumiko_events",
@@ -77,6 +80,10 @@ export async function createEventsTable(db: DbConnection): Promise<void> {
77
80
  if (!(await tableExists(db, "public.kumiko_events"))) {
78
81
  await unsafePushTables(db, { kumikoEvents: eventsTable });
79
82
  }
83
+ // Runs unconditionally (both fresh + already-existing table) so installs
84
+ // that predate the idempotency-key index get healed the same way
85
+ // ensureSnapshotVersionColumn heals kumiko_snapshots.
86
+ await ensureIdempotencyKeyIndex(db);
80
87
  await createArchivedStreamsTable(db);
81
88
  await createSnapshotsTable(db);
82
89
  }
@@ -12,7 +12,7 @@ export {
12
12
  isStreamArchived,
13
13
  restoreStream,
14
14
  } from "./archive";
15
- export { ArchivedStreamError, VersionConflictError } from "./errors";
15
+ export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError } from "./errors";
16
16
  export {
17
17
  append,
18
18
  EVENTS_PUBSUB_CHANNEL,
@@ -21,7 +21,6 @@ export {
21
21
  getEventsHighWaterMark,
22
22
  getStreamVersion,
23
23
  getUnscopedAggregateStreamMaxVersion,
24
- getUnscopedAggregateStreamTenant,
25
24
  LOAD_ALL_EVENTS_ROW_LIMIT,
26
25
  loadAggregate,
27
26
  loadAggregateAsOf,
@@ -2,9 +2,12 @@ import { describe, expect, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
4
4
  import type { TenantId } from "../../engine/types/identifiers";
5
+ import { createSecret } from "../../secrets/types";
5
6
  import { createTestUser } from "../../stack";
6
7
  import { createDispatcher } from "../dispatcher";
7
8
 
9
+ const streamCleanupState = { closed: false };
10
+
8
11
  const echoFeature = defineFeature("echo", (r) => {
9
12
  r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
10
13
 
@@ -36,6 +39,29 @@ const echoFeature = defineFeature("echo", (r) => {
36
39
  { access: { roles: ["Admin"] } },
37
40
  );
38
41
 
42
+ r.streamHandler(
43
+ "item:tail-leak",
44
+ z.object({}),
45
+ async function* () {
46
+ yield { apiKey: createSecret("leak-me") };
47
+ },
48
+ { access: { roles: ["Admin"] } },
49
+ );
50
+
51
+ r.streamHandler(
52
+ "item:tail-cleanup",
53
+ z.object({}),
54
+ async function* () {
55
+ try {
56
+ yield { i: 0 };
57
+ yield { i: 1 };
58
+ } finally {
59
+ streamCleanupState.closed = true;
60
+ }
61
+ },
62
+ { access: { roles: ["Admin"] } },
63
+ );
64
+
39
65
  r.hook("validation", "item:create", (data) => {
40
66
  if (data["name"] === "forbidden") return [{ field: "name", error: "forbidden_name" }];
41
67
  return null;
@@ -272,6 +298,32 @@ describe("dispatcher.stream", () => {
272
298
  collect(dispatcher.stream("nonexistent", {}, createTestUser({ roles: ["Admin"] }))),
273
299
  ).rejects.toMatchObject({ code: "not_found", httpStatus: 404 });
274
300
  });
301
+
302
+ test("a chunk containing a Secret<> value aborts the stream instead of leaking it", async () => {
303
+ const dispatcher = createTestDispatcher();
304
+
305
+ await expect(
306
+ collect(
307
+ dispatcher.stream("echo:stream:item:tail-leak", {}, createTestUser({ roles: ["Admin"] })),
308
+ ),
309
+ ).rejects.toMatchObject({ message: expect.stringContaining("Secret<> leaked") });
310
+ });
311
+
312
+ test("consumer breaking out of for-await runs the handler generator's finally block", async () => {
313
+ streamCleanupState.closed = false;
314
+ const dispatcher = createTestDispatcher();
315
+ const gen = dispatcher.stream(
316
+ "echo:stream:item:tail-cleanup",
317
+ {},
318
+ createTestUser({ roles: ["Admin"] }),
319
+ );
320
+
321
+ for await (const _chunk of gen) {
322
+ break;
323
+ }
324
+
325
+ expect(streamCleanupState.closed).toBe(true);
326
+ });
275
327
  });
276
328
 
277
329
  // --- postQuery hooks on standalone (entity-less) queries ---
@@ -0,0 +1,106 @@
1
+ // createJobTriggerEventConsumer — proves r.job's trigger.on can fire on an
2
+ // r.defineEvent QN appended by a multiStreamProjection's unsafeAppendEvent
3
+ // (kumiko-framework#1505). Mirrors document-ingest-foundation's actual
4
+ // request-ingest MSP (upload → fileRef.created → an owned defineEvent),
5
+ // the motivating case for this fix — fileRef.created itself never reaches
6
+ // jobRunner.handleEvent because the upload route appends it via the raw
7
+ // event-store executor, not a write-handler dispatch (see #1505).
8
+ //
9
+ // Not covered here: a job triggered on a write/query-handler QN still
10
+ // firing exactly once (unaffected by the new consumer). The full suite
11
+ // stays green (e.g. the lane-routing sample), but that's not a positive
12
+ // test of the partition guard — no stored event's `type` is ever a
13
+ // handler QN in practice (entity events are "entity.verb"; custom
14
+ // write-handlers like lane-routing's don't append to the store at all),
15
+ // so `getWriteHandler`/`getQueryHandler` in the new consumer's handler is
16
+ // defense-in-depth for an input shape the framework doesn't currently
17
+ // produce, not something exercised end-to-end by any test today.
18
+
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
20
+ import { z } from "zod";
21
+ import { entityEventName } from "../../db";
22
+ import { defineFeature } from "../../engine";
23
+ import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
24
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
25
+ import { waitFor } from "../../testing";
26
+
27
+ const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
28
+ const FILE_REF_CREATED = entityEventName("fileRef", "created");
29
+
30
+ const processedItems: Array<{ readonly fileRefId: string }> = [];
31
+
32
+ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
33
+ r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }));
34
+
35
+ // Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
36
+ // to fileRef.created, appends a NEW event via unsafeAppendEvent — no
37
+ // write-handler behind the appended event itself.
38
+ r.multiStreamProjection({
39
+ name: "request-item",
40
+ apply: {
41
+ [FILE_REF_CREATED]: async (event, _tx, ctx) => {
42
+ await ctx.unsafeAppendEvent({
43
+ aggregateId: event.aggregateId,
44
+ aggregateType: "job-trigger-fixture-request",
45
+ type: ITEM_REQUESTED_EVENT_QN,
46
+ payload: { fileRefId: event.aggregateId },
47
+ });
48
+ },
49
+ },
50
+ });
51
+
52
+ // Under test: only reachable via createJobTriggerEventConsumer, since
53
+ // ITEM_REQUESTED_EVENT_QN is an r.defineEvent QN, not a handler QN.
54
+ r.job(
55
+ "process-item",
56
+ { trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
57
+ async (payload) => {
58
+ processedItems.push({ fileRefId: payload["fileRefId"] as string });
59
+ },
60
+ );
61
+ });
62
+
63
+ let stack: TestStack;
64
+ let provider: InMemoryFileProvider;
65
+
66
+ beforeAll(async () => {
67
+ provider = createInMemoryFileProvider();
68
+ stack = await setupTestStack({
69
+ features: [jobTriggerFixtureFeature],
70
+ files: { storageProvider: provider },
71
+ jobs: { consumerLane: "worker" },
72
+ });
73
+ });
74
+
75
+ afterAll(async () => {
76
+ await stack.cleanup();
77
+ });
78
+
79
+ beforeEach(() => {
80
+ processedItems.length = 0;
81
+ provider.clear();
82
+ });
83
+
84
+ describe("job-trigger event consumer", () => {
85
+ test("a job triggers on an r.defineEvent QN appended by an MSP's unsafeAppendEvent", async () => {
86
+ const token = await stack.jwt.sign(TestUsers.admin);
87
+ const formData = new FormData();
88
+ formData.append("file", new File([Buffer.from("hello")], "note.txt", { type: "text/plain" }));
89
+ const res = await stack.app.request("/api/files", {
90
+ method: "POST",
91
+ headers: { Authorization: `Bearer ${token}` },
92
+ body: formData,
93
+ });
94
+ expect(res.status).toBe(201);
95
+
96
+ await waitFor(async () => {
97
+ // Drives both the MSP (appends item-requested off fileRef.created)
98
+ // and the new job-trigger consumer (reacts to it) — may need more
99
+ // than one pass since the MSP's append happens mid-drain.
100
+ await stack.eventDispatcher?.runOnce();
101
+ expect(processedItems).toHaveLength(1);
102
+ });
103
+
104
+ expect(processedItems[0]?.fileRefId).toBeTruthy();
105
+ });
106
+ });
@@ -707,30 +707,43 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
707
707
  expect(seen).toEqual([[deletectx]]);
708
708
  });
709
709
 
710
- test("one batch hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async () => {
710
+ test.each([
711
+ [
712
+ "postSaveBatch",
713
+ (hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
714
+ ({ postSaveBatch: hooks }) satisfies SystemHooks,
715
+ (pipeline: ReturnType<typeof createLifecycleHooks>) =>
716
+ pipeline.runPostSaveBatch([savectx], {}),
717
+ ],
718
+ [
719
+ "postDeleteBatch",
720
+ (hooks: { name: string; priority: number; fn: () => Promise<void> }[]) =>
721
+ ({ postDeleteBatch: hooks }) satisfies SystemHooks,
722
+ (pipeline: ReturnType<typeof createLifecycleHooks>) =>
723
+ pipeline.runPostDeleteBatch([deletectx], {}),
724
+ ],
725
+ ])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
711
726
  const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
712
727
  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
- },
728
+ const systemHooks = buildHooks([
729
+ {
730
+ name: "failing",
731
+ priority: 1000,
732
+ fn: async () => {
733
+ throw new Error("batch-hook-boom");
721
734
  },
722
- {
723
- name: "ok",
724
- priority: 1001,
725
- fn: async () => {
726
- calls.push("ok-ran");
727
- },
735
+ },
736
+ {
737
+ name: "ok",
738
+ priority: 1001,
739
+ fn: async () => {
740
+ calls.push("ok-ran");
728
741
  },
729
- ],
730
- };
742
+ },
743
+ ]);
731
744
  const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
732
745
  // Must not throw.
733
- await pipeline.runPostSaveBatch([savectx], {});
746
+ await run(pipeline);
734
747
  expect(calls).toEqual(["ok-ran"]);
735
748
  expect(consoleSpy).toHaveBeenCalled();
736
749
  consoleSpy.mockRestore();