@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.
- package/package.json +4 -4
- package/src/api/auth-routes.ts +27 -2
- package/src/changes.json +36 -0
- package/src/crypto/pg-kms-adapter.ts +2 -1
- package/src/db/__tests__/connection-close-outage.integration.test.ts +110 -0
- package/src/db/api.ts +6 -0
- package/src/db/bun-provider.ts +8 -3
- package/src/db/connection.ts +3 -3
- package/src/db/eagerload.ts +2 -9
- package/src/db/index.ts +1 -1
- package/src/db/postgres-provider.ts +7 -4
- package/src/db/queries/event-consumer.ts +4 -3
- package/src/derivatives/derivatives-context.ts +1 -8
- package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +12 -0
- package/src/engine/boot-validator/transfer-graph.ts +2 -1
- package/src/engine/feature-config-events-jobs.ts +3 -0
- package/src/engine/index.ts +3 -0
- package/src/engine/parse-ref-target.ts +6 -0
- package/src/engine/registry-validate.ts +3 -12
- package/src/engine/types/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +115 -1
- package/src/files/__tests__/storage-tracking.test.ts +46 -0
- package/src/files/file-routes.ts +13 -2
- package/src/files/index.ts +8 -1
- package/src/files/storage-tracking.ts +160 -15
- package/src/files/types.ts +44 -2
- package/src/jobs/job-runner.ts +39 -0
- package/src/pipeline/__tests__/event-dispatcher-db-outage.integration.test.ts +27 -0
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +133 -6
- package/src/pipeline/event-dispatcher-delivery.ts +22 -1
- package/src/pipeline/event-dispatcher.ts +25 -2
- package/src/pipeline/index.ts +4 -1
- package/src/pipeline/msp-rebuild.ts +1 -0
- package/src/pipeline/multi-stream-apply-context.ts +1 -0
- 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<{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/pipeline/index.ts
CHANGED
|
@@ -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
|
}
|