@cosmicdrift/kumiko-framework 0.312.0 → 0.314.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 (44) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/upgrade-cli.test.ts +28 -0
  3. package/src/api/__tests__/origin-middleware.integration.test.ts +142 -1
  4. package/src/api/auth-middleware.ts +30 -1
  5. package/src/api/auth-routes.ts +27 -2
  6. package/src/api/origin-middleware.ts +23 -20
  7. package/src/api/server.ts +10 -1
  8. package/src/changes.json +48 -0
  9. package/src/crypto/pg-kms-adapter.ts +2 -1
  10. package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
  11. package/src/db/api.ts +6 -0
  12. package/src/db/bun-provider.ts +8 -3
  13. package/src/db/connection.ts +3 -3
  14. package/src/db/eagerload.ts +2 -9
  15. package/src/db/index.ts +1 -1
  16. package/src/db/postgres-provider.ts +7 -4
  17. package/src/db/queries/event-consumer.ts +4 -3
  18. package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
  19. package/src/engine/boot-validator/transfer-graph.ts +2 -1
  20. package/src/engine/index.ts +3 -0
  21. package/src/engine/parse-ref-target.ts +6 -0
  22. package/src/engine/registry-validate.ts +3 -12
  23. package/src/engine/types/index.ts +1 -0
  24. package/src/files/__tests__/files.integration.test.ts +128 -1
  25. package/src/files/__tests__/storage-tracking.test.ts +46 -0
  26. package/src/files/file-routes.ts +10 -3
  27. package/src/files/index.ts +9 -1
  28. package/src/files/storage-tracking.ts +160 -15
  29. package/src/files/types.ts +57 -2
  30. package/src/jobs/__tests__/job-backoff.integration.test.ts +1 -5
  31. package/src/jobs/job-runner.ts +27 -0
  32. package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
  33. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +34 -6
  34. package/src/pipeline/event-dispatcher-delivery.ts +22 -1
  35. package/src/pipeline/event-dispatcher.ts +25 -2
  36. package/src/pipeline/index.ts +4 -1
  37. package/src/pipeline/system-hooks.ts +6 -2
  38. package/src/stack/__tests__/drain-event-consumers.integration.test.ts +166 -0
  39. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +60 -0
  40. package/src/stack/drain-event-consumers.ts +74 -0
  41. package/src/stack/index.ts +1 -0
  42. package/src/stack/redis.ts +3 -0
  43. package/src/stack/test-stack.ts +13 -4
  44. package/src/upgrade-cli.ts +17 -3
@@ -11,18 +11,28 @@
11
11
  // consumer-cursor row. Apps that want it pass filesStorageTrackingFeature
12
12
  // into createApp / setupTestStack alongside their domain features.
13
13
 
14
- import { entityEventName } from "../db";
14
+ import { type DbRunner, entityEventName, executeRawQueryRead } from "../db";
15
15
  import { bigint, instant, integer, table as pgTable, sql, uuid } from "../db/dialect";
16
16
  import { incrementCounter } from "../db/query";
17
- import { defineFeature } from "../engine";
17
+ import { defineFeature, qn, type Registry, toKebab } from "../engine";
18
+ import {
19
+ type PendingGapEntry,
20
+ SHARED_INSTANCE_SENTINEL,
21
+ selectConsumerCursorForUpdate,
22
+ } from "../pipeline";
23
+ import { parseJsonOrThrow } from "../utils";
24
+
25
+ const FILE_REF_AGGREGATE_TYPE = "fileRef";
26
+ const STORAGE_TRACKING_FEATURE_NAME = "files-storage-tracking";
27
+ const TENANT_STORAGE_USAGE_MSP_NAME = "tenant-storage-usage";
18
28
 
19
29
  // fileRef is a standard ES entity, so usage tracking subscribes to its
20
30
  // auto-verb events. `fileRef.created` payload carries the entity fields
21
31
  // (incl. size); `fileRef.deleted` / `fileRef.restored` carry `{ previous }`
22
32
  // (the pre-event row), so the byte count to apply lives at previous.size.
23
- const FILE_REF_CREATED = entityEventName("fileRef", "created");
24
- const FILE_REF_DELETED = entityEventName("fileRef", "deleted");
25
- const FILE_REF_RESTORED = entityEventName("fileRef", "restored");
33
+ const FILE_REF_CREATED = entityEventName(FILE_REF_AGGREGATE_TYPE, "created");
34
+ const FILE_REF_DELETED = entityEventName(FILE_REF_AGGREGATE_TYPE, "deleted");
35
+ const FILE_REF_RESTORED = entityEventName(FILE_REF_AGGREGATE_TYPE, "restored");
26
36
 
27
37
  function readNumber(value: unknown): number {
28
38
  return typeof value === "number" ? value : 0;
@@ -36,6 +46,28 @@ function sizeFromPrevious(payload: Record<string, unknown>): number {
36
46
  return 0;
37
47
  }
38
48
 
49
+ // Single source of truth for "how much does this fileRef event move the
50
+ // tenant's storage counters" — shared by the MSP's own apply handlers and by
51
+ // transferTenantStorageUsage's replay of already-applied events during a
52
+ // tenant handover.
53
+ export function fileRefStorageDelta(event: {
54
+ readonly type: string;
55
+ readonly payload: Record<string, unknown>;
56
+ }): { readonly bytes: number; readonly files: number } | null {
57
+ switch (event.type) {
58
+ case FILE_REF_CREATED:
59
+ return { bytes: readNumber(event.payload["size"]), files: 1 };
60
+ case FILE_REF_DELETED:
61
+ // `0 - x`, not `-x`: a zero-byte previous size must stay `0`, not `-0`
62
+ // (toEqual/JSON round-trips treat -0 as distinct from 0).
63
+ return { bytes: 0 - sizeFromPrevious(event.payload), files: -1 };
64
+ case FILE_REF_RESTORED:
65
+ return { bytes: sizeFromPrevious(event.payload), files: 1 };
66
+ default:
67
+ return null;
68
+ }
69
+ }
70
+
39
71
  // bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
40
72
  // bytes ≈ 8 petabytes per tenant — large enough for any practical storage
41
73
  // quota). Default "bigint" mode would hand back a bigint value, which
@@ -48,13 +80,15 @@ export const tenantStorageUsageTable = pgTable("read_tenant_storage_usage", {
48
80
  lastUpdatedAt: instant("last_updated_at").default(sql`now()`).notNull(),
49
81
  });
50
82
 
51
- export const filesStorageTrackingFeature = defineFeature("files-storage-tracking", (r) => {
83
+ export const filesStorageTrackingFeature = defineFeature(STORAGE_TRACKING_FEATURE_NAME, (r) => {
52
84
  r.multiStreamProjection({
53
- name: "tenant-storage-usage",
85
+ name: TENANT_STORAGE_USAGE_MSP_NAME,
54
86
  table: tenantStorageUsageTable,
55
87
  apply: {
56
88
  [FILE_REF_CREATED]: async (event, tx) => {
57
- const size = readNumber(event.payload["size"]);
89
+ const delta = fileRefStorageDelta(event);
90
+ // skip: not a tracked fileRef storage change
91
+ if (!delta) return;
58
92
 
59
93
  // UPSERT: INSERT on first upload per tenant, otherwise atomic increment.
60
94
  // The SQL increment guarantees correctness under concurrent dispatcher
@@ -63,13 +97,15 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
63
97
  await incrementCounter(
64
98
  tx,
65
99
  tenantStorageUsageTable,
66
- { tenantId: event.tenantId, totalBytes: size, fileCount: 1 },
67
- { totalBytes: size, fileCount: 1 },
100
+ { tenantId: event.tenantId, totalBytes: delta.bytes, fileCount: delta.files },
101
+ { totalBytes: delta.bytes, fileCount: delta.files },
68
102
  { set: { lastUpdatedAt: sql`now()` } },
69
103
  );
70
104
  },
71
105
  [FILE_REF_DELETED]: async (event, tx) => {
72
- const size = sizeFromPrevious(event.payload);
106
+ const delta = fileRefStorageDelta(event);
107
+ // skip: not a tracked fileRef storage change
108
+ if (!delta) return;
73
109
  // Decrement on delete. INSERT values are 0/0 so a delete that somehow
74
110
  // precedes any upload can't create negative usage; the on-conflict
75
111
  // path applies the real negative delta. Async (dispatcher) —
@@ -78,7 +114,7 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
78
114
  tx,
79
115
  tenantStorageUsageTable,
80
116
  { tenantId: event.tenantId, totalBytes: 0, fileCount: 0 },
81
- { totalBytes: -size, fileCount: -1 },
117
+ { totalBytes: delta.bytes, fileCount: delta.files },
82
118
  { set: { lastUpdatedAt: sql`now()` } },
83
119
  );
84
120
  },
@@ -86,15 +122,124 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
86
122
  // Restore re-increments by the soft-deleted row's size — symmetric
87
123
  // to delete. Without this handler totalBytes/fileCount drift low
88
124
  // after every delete→restore round-trip.
89
- const size = sizeFromPrevious(event.payload);
125
+ const delta = fileRefStorageDelta(event);
126
+ // skip: not a tracked fileRef storage change
127
+ if (!delta) return;
90
128
  await incrementCounter(
91
129
  tx,
92
130
  tenantStorageUsageTable,
93
- { tenantId: event.tenantId, totalBytes: size, fileCount: 1 },
94
- { totalBytes: size, fileCount: 1 },
131
+ { tenantId: event.tenantId, totalBytes: delta.bytes, fileCount: delta.files },
132
+ { totalBytes: delta.bytes, fileCount: delta.files },
95
133
  { set: { lastUpdatedAt: sql`now()` } },
96
134
  );
97
135
  },
98
136
  },
99
137
  });
100
138
  });
139
+
140
+ // Qualified name of the tenant-storage-usage MSP consumer row in
141
+ // kumiko_event_consumers — the same qualification registry-ingest.ts applies
142
+ // to every multiStreamProjection (qualify(featureName, "projection", name)).
143
+ const TENANT_STORAGE_USAGE_MSP_QN = qn(
144
+ toKebab(STORAGE_TRACKING_FEATURE_NAME),
145
+ "projection",
146
+ toKebab(TENANT_STORAGE_USAGE_MSP_NAME),
147
+ );
148
+
149
+ const TRACKED_FILE_REF_EVENT_TYPES = [FILE_REF_CREATED, FILE_REF_DELETED, FILE_REF_RESTORED];
150
+
151
+ function isWithinPendingGap(eventId: bigint, gaps: readonly PendingGapEntry[]): boolean {
152
+ return gaps.some((gap) => eventId >= BigInt(gap.from) && eventId <= BigInt(gap.to));
153
+ }
154
+
155
+ // Moves the already-applied share of the tenant-storage-usage counters from
156
+ // source to destination tenant when a tenant-handover claim moves fileRef
157
+ // rows. Must run BEFORE moveEventHistory rewrites the events' tenant_id —
158
+ // the query below finds "already applied" events by sourceTenantId, and once
159
+ // the rewrite runs there is no way to tell an already-applied event from a
160
+ // not-yet-applied one by tenant_id alone.
161
+ //
162
+ // Events with id above the cursor (or inside a pending gap) are NOT summed
163
+ // here: the dispatcher hasn't applied them yet, they will carry the
164
+ // destination tenant_id once moveEventHistory runs, and the dispatcher will
165
+ // apply them against the destination counter on its own next pass.
166
+ export async function transferTenantStorageUsage(args: {
167
+ readonly db: DbRunner;
168
+ readonly registry: Registry;
169
+ readonly fileRefIds: readonly string[];
170
+ readonly sourceTenantId: string;
171
+ readonly destinationTenantId: string;
172
+ }): Promise<void> {
173
+ const { db, registry, fileRefIds, sourceTenantId, destinationTenantId } = args;
174
+ // skip: no fileRefs moved, nothing to transfer
175
+ if (fileRefIds.length === 0) return;
176
+ // skip: storage tracking is not mounted in this app
177
+ if (!registry.getAllMultiStreamProjections().has(TENANT_STORAGE_USAGE_MSP_QN)) return;
178
+
179
+ // Locks the same consumer row the dispatcher's own FOR UPDATE SKIP LOCKED
180
+ // pass takes before applying a batch — serializing against it here means no
181
+ // fileRef event for these ids can be applied between reading the cursor
182
+ // below and the tenant move that follows in the caller.
183
+ const cursor = await selectConsumerCursorForUpdate(
184
+ db,
185
+ TENANT_STORAGE_USAGE_MSP_QN,
186
+ SHARED_INSTANCE_SENTINEL,
187
+ );
188
+ // skip: no consumer row means the MSP never applied anything yet
189
+ if (!cursor) return;
190
+
191
+ const rows = await executeRawQueryRead<{ id: string; type: string; payload: string }>(
192
+ db,
193
+ `SELECT "id"::text AS id, "type", "payload"::text AS payload FROM kumiko_events
194
+ WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3)
195
+ AND "type" = ANY($4) AND "id" <= $5`,
196
+ [
197
+ sourceTenantId,
198
+ FILE_REF_AGGREGATE_TYPE,
199
+ fileRefIds,
200
+ TRACKED_FILE_REF_EVENT_TYPES,
201
+ cursor.lastProcessedEventId,
202
+ ],
203
+ );
204
+
205
+ let bytes = 0;
206
+ let files = 0;
207
+ for (const row of rows) {
208
+ if (isWithinPendingGap(BigInt(row.id), cursor.pendingGaps)) continue;
209
+ const delta = fileRefStorageDelta({
210
+ type: row.type,
211
+ payload: parseJsonOrThrow<Record<string, unknown>>(
212
+ row.payload,
213
+ "transferTenantStorageUsage: kumiko_events.payload",
214
+ ),
215
+ });
216
+ if (!delta) continue;
217
+ bytes += delta.bytes;
218
+ files += delta.files;
219
+ }
220
+ // skip: the moved fileRefs carry no applied storage share
221
+ if (bytes === 0 && files === 0) return;
222
+
223
+ // Source: insert-values stay 0/0 (same guard as the DELETED handler above)
224
+ // — the row this subtracts from is expected to already exist (the MSP
225
+ // already applied the create against it), and if it somehow doesn't, this
226
+ // must not conjure a negative balance out of thin air.
227
+ await incrementCounter(
228
+ db,
229
+ tenantStorageUsageTable,
230
+ { tenantId: sourceTenantId, totalBytes: 0, fileCount: 0 },
231
+ { totalBytes: -bytes, fileCount: -files },
232
+ { set: { lastUpdatedAt: sql`now()` } },
233
+ );
234
+ // Destination: insert-values ARE the transferred amount (same as CREATED/
235
+ // RESTORED above) — a tenant claiming its first-ever fileRef must start
236
+ // its counter row at the transferred total, not at 0 with an increment
237
+ // that only fires once a row already exists.
238
+ await incrementCounter(
239
+ db,
240
+ tenantStorageUsageTable,
241
+ { tenantId: destinationTenantId, totalBytes: bytes, fileCount: files },
242
+ { totalBytes: bytes, fileCount: files },
243
+ { set: { lastUpdatedAt: sql`now()` } },
244
+ );
245
+ }
@@ -34,6 +34,10 @@ export function parseMaxSize(maxSize: string): number {
34
34
  }
35
35
  }
36
36
 
37
+ function extractExtension(fileName: string): string | undefined {
38
+ return fileName.split(".").pop()?.toLowerCase();
39
+ }
40
+
37
41
  // Extension → acceptable MIME-type whitelist. Guards against a client
38
42
  // uploading e.g. name="x.jpg" with mimeType="application/pdf" to slip an
39
43
  // executable past the extension-only check. Kept small & conservative — add
@@ -54,6 +58,21 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
54
58
  docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
55
59
  } satisfies Record<string, readonly string[]>;
56
60
 
61
+ // Extension → declared MIME → canonical MIME. Windows resolves .csv's upload
62
+ // MIME from Explorer's Excel file association, sending "application/vnd.ms-excel" for plain-text CSV.
63
+ const EXTENSION_MIME_ALIASES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
64
+ csv: { "application/vnd.ms-excel": "text/csv" },
65
+ };
66
+
67
+ // Object.hasOwn at both levels: a plain `in` check on `{}` resolves to
68
+ // Object.prototype members ("constructor", …) for any ext/mimeType.
69
+ function lookupMimeAlias(ext: string, normalizedMimeType: string): string | undefined {
70
+ if (!Object.hasOwn(EXTENSION_MIME_ALIASES, ext)) return undefined;
71
+ const aliasesForExt = EXTENSION_MIME_ALIASES[ext];
72
+ if (!aliasesForExt || !Object.hasOwn(aliasesForExt, normalizedMimeType)) return undefined;
73
+ return aliasesForExt[normalizedMimeType];
74
+ }
75
+
57
76
  // Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
58
77
  // a reliable binary signature. Used at SERVE time — never trust the stored/
59
78
  // client-declared mimeType for Content-Type on its own, sniff the actual
@@ -108,6 +127,14 @@ function startsWithAscii(bytes: Uint8Array, ascii: string, offset = 0): boolean
108
127
  return true;
109
128
  }
110
129
 
130
+ /**
131
+ * Canonical MIME type for a known binary magic-byte signature (png, jpeg,
132
+ * gif, webp, pdf, OLE, ZIP), or null for everything else — text formats
133
+ * (csv, txt, json, svg, md) have no signature and always return null. Every
134
+ * OLE Compound File (.doc/.xls/.ppt) returns "application/msword" and every
135
+ * ZIP (.docx/.xlsx/.pptx/.zip) returns the docx MIME — callers can't tell
136
+ * those apart by bytes alone.
137
+ */
111
138
  export function sniffMimeType(bytes: Uint8Array): string | null {
112
139
  for (const signature of MAGIC_BYTE_SIGNATURES) {
113
140
  if (signature.matches(bytes)) return signature.mimeType;
@@ -165,7 +192,7 @@ export function validateFile(
165
192
  }
166
193
 
167
194
  if (options.accept && options.accept.length > 0) {
168
- const ext = metadata.fileName.split(".").pop()?.toLowerCase();
195
+ const ext = extractExtension(metadata.fileName);
169
196
  if (!ext || !options.accept.includes(ext)) {
170
197
  return `invalid_file_type: ".${ext}" is not in [${options.accept.join(", ")}]`;
171
198
  }
@@ -176,7 +203,8 @@ export function validateFile(
176
203
  const allowedMimes = EXTENSION_MIME_WHITELIST[ext];
177
204
  if (allowedMimes && metadata.mimeType) {
178
205
  const normalized = normalizeMimeType(metadata.mimeType);
179
- if (!allowedMimes.includes(normalized)) {
206
+ const isAliasedMime = lookupMimeAlias(ext, normalized) !== undefined;
207
+ if (!allowedMimes.includes(normalized) && !isAliasedMime) {
180
208
  return `mime_mismatch: extension ".${ext}" does not match mimeType "${metadata.mimeType}"`;
181
209
  }
182
210
  }
@@ -198,6 +226,33 @@ export function validateFileContent(mimeType: string, content: Uint8Array): stri
198
226
  return null;
199
227
  }
200
228
 
229
+ export type UploadMimeTypeResolution =
230
+ | { readonly kind: "ok"; readonly mimeType: string }
231
+ | { readonly kind: "rejected"; readonly error: string };
232
+
233
+ /**
234
+ * Rewrites an aliased declared mimeType to its canonical form and, in the
235
+ * same call, sniffs the bytes — a renamed binary (a real .xls/.xlsx saved
236
+ * as .csv) must fail here rather than through an optional, skippable step.
237
+ */
238
+ export function resolveUploadMimeType(
239
+ fileName: string,
240
+ declaredMimeType: string,
241
+ content: Uint8Array,
242
+ ): UploadMimeTypeResolution {
243
+ const ext = extractExtension(fileName);
244
+ const normalizedDeclared = normalizeMimeType(declaredMimeType);
245
+ const canonical = ext ? lookupMimeAlias(ext, normalizedDeclared) : undefined;
246
+ if (!canonical) return { kind: "ok", mimeType: declaredMimeType };
247
+ if (sniffMimeType(content) !== null) {
248
+ return {
249
+ kind: "rejected",
250
+ error: `content_mismatch: bytes of ".${ext}" upload declared as "${declaredMimeType}" are a binary format`,
251
+ };
252
+ }
253
+ return { kind: "ok", mimeType: canonical };
254
+ }
255
+
201
256
  export function assertSafeStorageKey(key: string): void {
202
257
  if (key.split("/").some((segment) => segment === "." || segment === "..")) {
203
258
  throw new Error(`storage key contains a path-traversal segment: "${key}"`);
@@ -14,7 +14,6 @@ import * as z from "zod";
14
14
  import { defineFeature } from "../../engine";
15
15
  import { setupTestStack, type TestStack, TestUsers } from "../../stack";
16
16
  import { waitFor } from "../../testing";
17
- import { generateId } from "../../utils";
18
17
 
19
18
  const exponentialDefaultStarts: number[] = [];
20
19
  const exponentialCustomStarts: number[] = [];
@@ -92,10 +91,7 @@ let stack: TestStack;
92
91
  beforeAll(async () => {
93
92
  stack = await setupTestStack({
94
93
  features: [backoffFixtureFeature],
95
- // Own queue namespace: the default "kumiko-jobs" queue on the shared test
96
- // Redis is consumed by every parallel test process, and a foreign worker
97
- // grabbing a promoted delayed retry fails it as "Unknown job".
98
- jobs: { consumerLane: "worker", queueNamePrefix: `backoff-test-${generateId()}` },
94
+ jobs: { consumerLane: "worker" },
99
95
  });
100
96
  });
101
97
 
@@ -13,6 +13,7 @@ import {
13
13
  type JobContext,
14
14
  type JobDefinition,
15
15
  type JobRunIn,
16
+ type JobTriggerEvent,
16
17
  type MemberReader,
17
18
  type Registry,
18
19
  type SessionUser,
@@ -42,6 +43,22 @@ import {
42
43
  } from "../pipeline/write-origin";
43
44
  import { bridgeStub } from "../testing/handler-context";
44
45
 
46
+ // Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
47
+ // (a security-relevant escape hatch that must fail the run closed), a
48
+ // malformed `_triggerEvent` only costs an idempotency-key convenience — the
49
+ // run proceeds without it rather than failing over what would be a framework
50
+ // bug, not untrusted input.
51
+ function isJobTriggerEvent(value: unknown): value is JobTriggerEvent {
52
+ if (typeof value !== "object" || value === null) return false;
53
+ const candidate = value as Record<string, unknown>;
54
+ if (typeof candidate["id"] !== "string") return false;
55
+ const headers = candidate["headers"];
56
+ if (typeof headers !== "object" || headers === null) return false;
57
+ return Object.values(headers as Record<string, unknown>).every(
58
+ (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
59
+ );
60
+ }
61
+
45
62
  // A payload's own `_writeOrigin` is never trusted; only the ambient gated origin is stamped.
46
63
  function stampGatedWriteOrigin(data: Record<string, unknown>): void {
47
64
  delete data["_writeOrigin"];
@@ -216,6 +233,7 @@ export type JobRunner = {
216
233
  eventName: string,
217
234
  payload: Record<string, unknown>,
218
235
  user?: SessionUser,
236
+ triggerEvent?: JobTriggerEvent,
219
237
  ): Promise<void>;
220
238
  // Wires JobContext.write/writeAs/queryAs to the real dispatcher — called
221
239
  // once at boot, after the dispatcher exists (job-runner construction happens
@@ -759,6 +777,12 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
759
777
  // don't dig through the raw payload themselves.
760
778
  const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
761
779
 
780
+ // Same carry-through for `_triggerEvent` — handleEvent injects it when
781
+ // the job-trigger event consumer supplied the stored event that fired
782
+ // this run (see createJobTriggerEventConsumer).
783
+ const rawTriggerEvent = rawData["_triggerEvent"];
784
+ const triggerEvent = isJobTriggerEvent(rawTriggerEvent) ? rawTriggerEvent : undefined;
785
+
762
786
  // Absent = legacy or ungated root; present but invalid fails the run closed.
763
787
  const rawWriteOrigin = rawData["_writeOrigin"]; // @cast-boundary dynamic-key
764
788
  let jobOrigin: WriteOrigin | undefined;
@@ -866,6 +890,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
866
890
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
867
891
  log: createJobLogger(logs),
868
892
  ...(triggerName !== undefined && { triggerName }),
893
+ ...(triggerEvent !== undefined && { triggerEvent }),
869
894
  write: (qn: string, payload: unknown) => {
870
895
  if (!dispatchWriteRef) {
871
896
  throw new Error(
@@ -1258,6 +1283,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1258
1283
  eventName: string,
1259
1284
  payload: Record<string, unknown>,
1260
1285
  user?: SessionUser,
1286
+ triggerEvent?: JobTriggerEvent,
1261
1287
  ): Promise<void> {
1262
1288
  const traceCtx = captureTraceContext(tracer);
1263
1289
  // Same correlation propagation as dispatch(): events triggered from
@@ -1285,6 +1311,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1285
1311
  // setzen wir es auch — kostet nichts und vereinfacht Handler-Code
1286
1312
  // (kein "ist es Multi?"-Branch nötig).
1287
1313
  data["_triggerName"] = eventName;
1314
+ if (triggerEvent) data["_triggerEvent"] = triggerEvent;
1288
1315
  if (traceCtx) data[TRACE_CONTEXT_KEY] = traceCtx;
1289
1316
  if (reqCtx?.correlationId) data["_correlationId"] = reqCtx.correlationId;
1290
1317
  // Same maxPerTenant guard as dispatch — events that fan into many
@@ -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,7 +19,8 @@
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,8 +28,13 @@ import { generateId } from "../../utils";
27
28
 
28
29
  const ITEM_REQUESTED_EVENT_QN = "job-trigger-fixture:event:item-requested";
29
30
  const FILE_REF_CREATED = entityEventName("fileRef", "created");
31
+ const PROVIDER_EVENT_ID_HEADER = "providerEventId";
30
32
 
31
- 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
+ }> = [];
32
38
 
33
39
  const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
34
40
  r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }), {
@@ -37,7 +43,8 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
37
43
 
38
44
  // Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
39
45
  // to fileRef.created, appends a NEW event via unsafeAppendEvent — no
40
- // 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.
41
48
  r.multiStreamProjection({
42
49
  name: "request-item",
43
50
  apply: {
@@ -47,6 +54,7 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
47
54
  aggregateType: "job-trigger-fixture-request",
48
55
  type: ITEM_REQUESTED_EVENT_QN,
49
56
  payload: { fileRefId: event.aggregateId },
57
+ headers: { [PROVIDER_EVENT_ID_HEADER]: "provider-evt-42" },
50
58
  });
51
59
  },
52
60
  },
@@ -57,8 +65,12 @@ const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
57
65
  r.job(
58
66
  "process-item",
59
67
  { trigger: { on: ITEM_REQUESTED_EVENT_QN }, runIn: "worker" },
60
- async (payload) => {
61
- processedItems.push({ fileRefId: payload["fileRefId"] as string });
68
+ async (payload, ctx) => {
69
+ processedItems.push({
70
+ fileRefId: payload["fileRefId"] as string,
71
+ triggerEvent: ctx.triggerEvent,
72
+ rawDataKeys: Object.keys(payload),
73
+ });
62
74
  },
63
75
  );
64
76
  });
@@ -169,7 +181,23 @@ describe("job-trigger event consumer", () => {
169
181
  expect(processedItems).toHaveLength(1);
170
182
  });
171
183
 
172
- 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");
173
201
  });
174
202
  });
175
203
 
@@ -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";