@cosmicdrift/kumiko-framework 0.311.0 → 0.313.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 (35) hide show
  1. package/package.json +4 -4
  2. package/src/api/auth-routes.ts +27 -2
  3. package/src/changes.json +36 -0
  4. package/src/crypto/pg-kms-adapter.ts +2 -1
  5. package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
  6. package/src/db/api.ts +6 -0
  7. package/src/db/bun-provider.ts +8 -3
  8. package/src/db/connection.ts +3 -3
  9. package/src/db/eagerload.ts +2 -9
  10. package/src/db/index.ts +1 -1
  11. package/src/db/postgres-provider.ts +7 -4
  12. package/src/db/queries/event-consumer.ts +4 -3
  13. package/src/derivatives/derivatives-context.ts +1 -8
  14. package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
  15. package/src/engine/boot-validator/transfer-graph.ts +2 -1
  16. package/src/engine/feature-config-events-jobs.ts +3 -0
  17. package/src/engine/index.ts +3 -0
  18. package/src/engine/parse-ref-target.ts +6 -0
  19. package/src/engine/registry-validate.ts +3 -12
  20. package/src/engine/types/index.ts +1 -0
  21. package/src/files/__tests__/files.integration.test.ts +115 -1
  22. package/src/files/__tests__/storage-tracking.test.ts +46 -0
  23. package/src/files/file-routes.ts +13 -2
  24. package/src/files/index.ts +8 -1
  25. package/src/files/storage-tracking.ts +160 -15
  26. package/src/files/types.ts +44 -2
  27. package/src/jobs/job-runner.ts +39 -0
  28. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
  29. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +133 -6
  30. package/src/pipeline/event-dispatcher-delivery.ts +22 -1
  31. package/src/pipeline/event-dispatcher.ts +25 -2
  32. package/src/pipeline/index.ts +4 -1
  33. package/src/pipeline/msp-rebuild.ts +1 -0
  34. package/src/pipeline/multi-stream-apply-context.ts +1 -0
  35. package/src/pipeline/system-hooks.ts +4 -1
@@ -195,3 +195,30 @@ describe("E: dispatcher survives a DB outage and logs the recovery", () => {
195
195
  }
196
196
  });
197
197
  });
198
+
199
+ describe("F: stop() waits for a pass still mid-flight", () => {
200
+ test("stop() waits for a pass still in its idle pre-check", async () => {
201
+ const consumer: EventConsumer = {
202
+ name: "dboutage:consumer-stop-race",
203
+ handler: async () => {},
204
+ };
205
+ const dispatcher: EventDispatcher = createEventDispatcher({
206
+ db: stack.db,
207
+ consumers: [consumer],
208
+ context: { db: stack.db },
209
+ pollIntervalMs: 60_000,
210
+ });
211
+
212
+ await dispatcher.start();
213
+ let passSettled = false;
214
+ // A pass woken by runOnce() has not registered any turn yet while it is
215
+ // still awaiting the idle pre-check (selectIdleConsumerKeys) — stop()
216
+ // must wait for it too, or it queries the pool stop() is closing.
217
+ const pass = dispatcher.runOnce().finally(() => {
218
+ passSettled = true;
219
+ });
220
+ await dispatcher.stop();
221
+ expect(passSettled).toBe(true);
222
+ await pass;
223
+ });
224
+ });
@@ -19,15 +19,22 @@
19
19
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
20
20
  import * as z from "zod";
21
21
  import { entityEventName } from "../../db";
22
- import { defineFeature } from "../../engine";
22
+ import { defineFeature, type JobContext } from "../../engine";
23
+ import { loadAggregate } from "../../event-store";
23
24
  import { createInMemoryFileProvider, type InMemoryFileProvider } from "../../files";
24
25
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
25
26
  import { waitFor } from "../../testing";
27
+ import { generateId } from "../../utils";
26
28
 
27
29
  const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
28
30
  const FILE_REF_CREATED = entityEventName("fileRef", "created");
31
+ const PROVIDER_EVENT_ID_HEADER = "providerEventId";
29
32
 
30
- const processedItems: Array<{ readonly fileRefId: string }> = [];
33
+ const processedItems: Array<{
34
+ readonly fileRefId: string;
35
+ readonly triggerEvent: JobContext["triggerEvent"];
36
+ readonly rawDataKeys: readonly string[];
37
+ }> = [];
31
38
 
32
39
  const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
33
40
  r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }), {
@@ -36,7 +43,8 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
36
43
 
37
44
  // Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
38
45
  // to fileRef.created, appends a NEW event via unsafeAppendEvent — no
39
- // write-handler behind the appended event itself.
46
+ // write-handler behind the appended event itself. headers carries a
47
+ // provider-style idempotency key so the job side can assert it round-trips.
40
48
  r.multiStreamProjection({
41
49
  name: "request-item",
42
50
  apply: {
@@ -46,6 +54,7 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
46
54
  aggregateType: "job-trigger-fixture-request",
47
55
  type: ITEM_REQUESTED_EVENT_QN,
48
56
  payload: { fileRefId: event.aggregateId },
57
+ headers: { [PROVIDER_EVENT_ID_HEADER]: "provider-evt-42" },
49
58
  });
50
59
  },
51
60
  },
@@ -56,31 +65,100 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
56
65
  r.job(
57
66
  "process-item",
58
67
  { trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
68
+ async (payload, ctx) => {
69
+ processedItems.push({
70
+ fileRefId: payload["fileRefId"] as string,
71
+ triggerEvent: ctx.triggerEvent,
72
+ rawDataKeys: Object.keys(payload),
73
+ });
74
+ },
75
+ );
76
+ });
77
+
78
+ // trigger.where partition fixture — two jobs share one broad QN, each
79
+ // filtered to a disjoint payload.kind, mirroring document-ingest-foundation's
80
+ // N-providers-one-QN shape without pulling in the whole feature.
81
+ const WHERE_ITEM_REQUESTED_EVENT_QN = "job-trigger-where-fixture:event:item-requested";
82
+
83
+ const alphaProcessed: Array<{ readonly fileRefId: string }> = [];
84
+ const betaProcessed: Array<{ readonly fileRefId: string }> = [];
85
+
86
+ const jobTriggerWhereFixtureFeature = defineFeature("job-trigger-where-fixture", (r) => {
87
+ r.defineEvent(
88
+ "item-requested",
89
+ z.object({ fileRefId: z.string().min(1), kind: z.enum(["alpha", "beta"]) }),
90
+ { piiFields: "none" },
91
+ );
92
+
93
+ r.multiStreamProjection({
94
+ name: "request-item",
95
+ apply: {
96
+ [FILE_REF_CREATED]: async (event, _tx, ctx) => {
97
+ const fileName = event.payload["fileName"];
98
+ const kind =
99
+ typeof fileName === "string" && fileName.startsWith("alpha-") ? "alpha" : "beta";
100
+ await ctx.unsafeAppendEvent({
101
+ aggregateId: event.aggregateId,
102
+ aggregateType: "job-trigger-where-fixture-request",
103
+ type: WHERE_ITEM_REQUESTED_EVENT_QN,
104
+ payload: { fileRefId: event.aggregateId, kind },
105
+ });
106
+ },
107
+ },
108
+ });
109
+
110
+ // Under test: both jobs trigger on the SAME QN; only the one whose
111
+ // `where` matches the appended payload's `kind` may run.
112
+ r.job(
113
+ "process-alpha",
114
+ { trigger: { on: WHERE_ITEM_REQUESTED_EVENT_QN, where: { kind: "alpha" } }, runIn: "worker" },
59
115
  async (payload) => {
60
- processedItems.push({ fileRefId: payload["fileRefId"] as string });
116
+ alphaProcessed.push({ fileRefId: payload["fileRefId"] as string });
117
+ },
118
+ );
119
+ r.job(
120
+ "process-beta",
121
+ { trigger: { on: WHERE_ITEM_REQUESTED_EVENT_QN, where: { kind: "beta" } }, runIn: "worker" },
122
+ async (payload) => {
123
+ betaProcessed.push({ fileRefId: payload["fileRefId"] as string });
61
124
  },
62
125
  );
63
126
  });
64
127
 
65
128
  let stack: TestStack;
66
129
  let provider: InMemoryFileProvider;
130
+ let whereStack: TestStack;
131
+ let whereProvider: InMemoryFileProvider;
67
132
 
68
133
  beforeAll(async () => {
69
134
  provider = createInMemoryFileProvider();
70
135
  stack = await setupTestStack({
71
136
  features: [jobTriggerFixtureFeature],
72
137
  files: { storageProvider: provider },
73
- jobs: { consumerLane: "worker" },
138
+ jobs: { consumerLane: "worker", queueNamePrefix: `job-trigger-fixture-${generateId()}` },
139
+ });
140
+ whereProvider = createInMemoryFileProvider();
141
+ whereStack = await setupTestStack({
142
+ features: [jobTriggerWhereFixtureFeature],
143
+ files: { storageProvider: whereProvider },
144
+ // Distinct prefix from `stack` above — createTestRedis's keyPrefix
145
+ // isolation doesn't cover BullMQ's own connections, so without this both
146
+ // JobRunners would race each other on the same default queue name.
147
+ jobs: { consumerLane: "worker", queueNamePrefix: `job-trigger-where-fixture-${generateId()}` },
74
148
  });
75
149
  });
76
150
 
77
151
  afterAll(async () => {
78
152
  await stack.cleanup();
153
+ await whereStack.cleanup();
79
154
  });
80
155
 
81
156
  beforeEach(() => {
82
157
  processedItems.length = 0;
83
158
  provider.clear();
159
+ alphaProcessed.length = 0;
160
+ betaProcessed.length = 0;
161
+ whereProvider.clear();
84
162
  });
85
163
 
86
164
  describe("job-trigger event consumer", () => {
@@ -103,6 +181,55 @@ describe("job-trigger event consumer", () => {
103
181
  expect(processedItems).toHaveLength(1);
104
182
  });
105
183
 
106
- expect(processedItems[0]?.fileRefId).toBeTruthy();
184
+ const processed = processedItems[0];
185
+ expect(processed?.fileRefId).toBeTruthy();
186
+
187
+ // aggregateId of the appended item-requested event is the fileRef's id
188
+ // (the MSP apply carries event.aggregateId straight through).
189
+ const fileRefId = processed?.fileRefId as string;
190
+ const storedEvents = await loadAggregate(stack.db, fileRefId, TestUsers.admin.tenantId);
191
+ const itemRequested = storedEvents.find((e) => e.type === ITEM_REQUESTED_EVENT_QN);
192
+ expect(itemRequested).toBeDefined();
193
+
194
+ // triggerEvent carries the stored event's own id and headers through to
195
+ // the job — the idempotency-key surface at-least-once delivery needs.
196
+ expect(processed?.triggerEvent?.id).toBe(itemRequested?.id);
197
+ expect(processed?.triggerEvent?.headers[PROVIDER_EVENT_ID_HEADER]).toBe("provider-evt-42");
198
+
199
+ // Reserved keys never leak into the payload the handler receives.
200
+ expect(processed?.rawDataKeys).not.toContain("_triggerEvent");
201
+ });
202
+ });
203
+
204
+ describe("job-trigger event consumer — trigger.where partitions one QN across jobs", () => {
205
+ test("only the job whose where matches the payload runs — both partitions checked, not just the absence of the other", async () => {
206
+ const token = await whereStack.jwt.sign(TestUsers.admin);
207
+ async function uploadKind(fileName: string): Promise<string> {
208
+ const formData = new FormData();
209
+ formData.append("file", new File([Buffer.from("hello")], fileName, { type: "text/plain" }));
210
+ const res = await whereStack.app.request("/api/files", {
211
+ method: "POST",
212
+ headers: { Authorization: `Bearer ${token}` },
213
+ body: formData,
214
+ });
215
+ expect(res.status).toBe(201);
216
+ const body = (await res.json()) as { id: string };
217
+ return body.id;
218
+ }
219
+
220
+ const alphaFileRefId = await uploadKind("alpha-note.txt");
221
+ const betaFileRefId = await uploadKind("beta-note.txt");
222
+
223
+ await waitFor(async () => {
224
+ // Drives both jobs' consumers in the same pass — asserting both lists
225
+ // are non-empty before checking membership rules out "beta simply
226
+ // hasn't run yet" as an explanation for an empty betaProcessed.
227
+ await whereStack.eventDispatcher?.runOnce();
228
+ expect(alphaProcessed).toHaveLength(1);
229
+ expect(betaProcessed).toHaveLength(1);
230
+ });
231
+
232
+ expect(alphaProcessed).toEqual([{ fileRefId: alphaFileRefId }]);
233
+ expect(betaProcessed).toEqual([{ fileRefId: betaFileRefId }]);
107
234
  });
108
235
  });
@@ -3,12 +3,13 @@
3
3
  // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
4
4
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
5
5
  import { type RequestContextData, requestContext } from "../api/request-context";
6
- import type { DbConnection, DbTx } from "../db/connection";
6
+ import type { DbConnection, DbRunner, DbTx } from "../db/connection";
7
7
  import {
8
8
  insertConsumerIfAbsent,
9
9
  markConsumerProcessing,
10
10
  rearmDeadConsumer,
11
11
  recordConsumerPassFailure,
12
+ selectConsumerForUpdate,
12
13
  selectConsumerForUpdateSkipLocked,
13
14
  selectProvablyIdleConsumerPairs,
14
15
  updateConsumerDeliveryOutcome,
@@ -160,6 +161,26 @@ export async function acquireConsumerState(
160
161
  return { state, skip: null };
161
162
  }
162
163
 
164
+ export type ConsumerCursor = {
165
+ readonly lastProcessedEventId: bigint;
166
+ readonly pendingGaps: readonly PendingGapEntry[];
167
+ };
168
+
169
+ // Blocking FOR UPDATE (not SKIP LOCKED): callers of this one need to actually
170
+ // wait for the dispatcher's own turn to finish, not skip past it, so their
171
+ // read of the already-applied cursor is guaranteed to be up to date rather
172
+ // than possibly stale.
173
+ export async function selectConsumerCursorForUpdate(
174
+ db: DbRunner,
175
+ name: string,
176
+ instanceId: string,
177
+ ): Promise<ConsumerCursor | undefined> {
178
+ const rawState = await selectConsumerForUpdate(db, name, instanceId);
179
+ if (!rawState) return undefined;
180
+ const state = coerceRow(rawState, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow;
181
+ return { lastProcessedEventId: state.lastProcessedEventId, pendingGaps: state.pendingGaps };
182
+ }
183
+
163
184
  // Read-only pre-check run once per doPass, before any consumer's turn opens
164
185
  // a transaction (event-dispatcher.ts's doPass/runConsumerTurn). Keyed like
165
186
  // consumerBackoff/inFlightTurns: `${name}:${instanceId}`. Consumers not
@@ -385,13 +385,34 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
385
385
  );
386
386
  }
387
387
 
388
+ // A pass still in doPass's idle pre-check (selectIdleConsumerKeys) hasn't
389
+ // registered any consumer turn in inFlightTurns yet — stop() must wait for
390
+ // it too, or it keeps querying the pool stop() is about to close.
391
+ const inFlightPasses = new Set<Promise<DispatcherPassResult>>();
392
+
393
+ async function drainInFlightPasses(): Promise<void> {
394
+ await Promise.all(
395
+ [...inFlightPasses].map((pass) =>
396
+ pass.catch(() => {
397
+ // skip: errors already recorded per-consumer inside the pass
398
+ }),
399
+ ),
400
+ );
401
+ }
402
+
388
403
  async function runOnce(): Promise<DispatcherPassResult> {
389
404
  if (!preRegistered) {
390
405
  throw new Error(
391
406
  "EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).",
392
407
  );
393
408
  }
394
- return doPass();
409
+ const pass = doPass();
410
+ inFlightPasses.add(pass);
411
+ try {
412
+ return await pass;
413
+ } finally {
414
+ inFlightPasses.delete(pass);
415
+ }
395
416
  }
396
417
 
397
418
  async function doPass(): Promise<DispatcherPassResult> {
@@ -764,7 +785,9 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
764
785
  emitEventDispatcherListenConnected(meter, false);
765
786
  }
766
787
 
767
- // Drain any in-flight pass so shutdown observes consistent state.
788
+ // Drain any in-flight pass so shutdown observes consistent state —
789
+ // including one still stuck in the idle pre-check above inFlightTurns.
790
+ await drainInFlightPasses();
768
791
  await drainInFlightTurns();
769
792
  // preRegistered stays true — the rows survive stop(). runOnce()
770
793
  // after a stop() still works (tests stop the timer and then drain
@@ -15,11 +15,12 @@ export type { DistributedLock } from "./distributed-lock";
15
15
  export { createDistributedLock } from "./distributed-lock";
16
16
  export type { EntityCache, EntityCacheOptions } from "./entity-cache";
17
17
  export { createEntityCache } from "./entity-cache";
18
- export type { ConsumerStatus } from "./event-consumer-state";
18
+ export type { ConsumerStatus, PendingGapEntry } from "./event-consumer-state";
19
19
  export {
20
20
  ConsumerStatuses,
21
21
  createEventConsumerStateTable,
22
22
  eventConsumerStateTable,
23
+ SHARED_INSTANCE_SENTINEL,
23
24
  } from "./event-consumer-state";
24
25
  export type { EventDedup } from "./event-dedup";
25
26
  export { createEventDedup } from "./event-dedup";
@@ -43,6 +44,8 @@ export {
43
44
  restartConsumer,
44
45
  skipPoisonEvent,
45
46
  } from "./event-dispatcher";
47
+ export type { ConsumerCursor } from "./event-dispatcher-delivery";
48
+ export { selectConsumerCursorForUpdate } from "./event-dispatcher-delivery";
46
49
  export type { PruneEventsOptions, PruneEventsResult } from "./event-retention";
47
50
  export { ConsumerLagError, pruneEvents } from "./event-retention";
48
51
  export type { IdempotencyGuard } from "./idempotency";
@@ -115,6 +115,7 @@ function createRebuildCtx(
115
115
  });
116
116
  };
117
117
  return {
118
+ registry,
118
119
  appendEvent: refuseAppend as MultiStreamApplyContext["appendEvent"], // @cast-boundary engine-bridge
119
120
  unsafeAppendEvent: refuseAppend,
120
121
  loadAggregate: async (aggregateId, options) => {
@@ -38,6 +38,7 @@ export function createMultiStreamApplyContext(
38
38
  deps: MultiStreamApplyContextDeps,
39
39
  ): MultiStreamApplyContext {
40
40
  return {
41
+ registry: deps.registry,
41
42
  ...(deps.files ? { files: deps.files } : {}),
42
43
  ...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
43
44
  appendEvent: (async (args: AppendEventArgs) => {
@@ -542,7 +542,10 @@ export function createJobTriggerEventConsumer(
542
542
  tenantId: event.tenantId,
543
543
  roles: [],
544
544
  };
545
- await jobRunner.handleEvent(event.type, event.payload, user);
545
+ await jobRunner.handleEvent(event.type, event.payload, user, {
546
+ id: event.id,
547
+ headers: event.metadata.headers ?? {},
548
+ });
546
549
  },
547
550
  };
548
551
  }