@cosmicdrift/kumiko-bundled-features 0.231.0 → 0.232.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 +9 -9
- package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +328 -0
- package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +292 -0
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +277 -0
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +6 -11
- package/src/workflow-runner/db/queries/due-runs.ts +23 -0
- package/src/workflow-runner/event-subscriber.ts +93 -0
- package/src/workflow-runner/event-trigger.ts +14 -4
- package/src/workflow-runner/feature.ts +62 -5
- package/src/workflow-runner/handlers/resume-run.write.ts +277 -0
- package/src/workflow-runner/pending-projection.ts +148 -0
- package/src/workflow-runner/runner.ts +36 -13
- package/src/workflow-runner/tables.ts +95 -0
- package/src/workflow-runner/workflow-registry.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.232.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.232.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.232.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.232.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.232.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.232.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.232.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.232.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.232.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// event-wakeup — the event-based resume path (framework#2513 Phase 3b)
|
|
2
|
+
// against real Postgres.
|
|
3
|
+
//
|
|
4
|
+
// Fires the AWAITED domain event (not the workflow's own trigger event)
|
|
5
|
+
// through a real event-dispatcher pass so event-subscriber.ts's MSP
|
|
6
|
+
// observes it, then drives `resume-due-runs` directly via
|
|
7
|
+
// stack.registry.getJob(...) — same style as resume-loop.integration.test.ts
|
|
8
|
+
// — to exercise the full resume-run handler off the row the subscriber
|
|
9
|
+
// marked due.
|
|
10
|
+
//
|
|
11
|
+
// The workflow under test reads the matched event's payload back via a
|
|
12
|
+
// resolver (`ctx.steps[awaits.replied]`) in the step right after
|
|
13
|
+
// waitForEvent and asserts it inline, throwing on a mismatch. Steps that
|
|
14
|
+
// resume a suspended run always execute inside resume-run's own
|
|
15
|
+
// workflow-runner write-handler context, which only owns workflow-runner's
|
|
16
|
+
// own event types (see resume-run.write.ts's module doc) — a custom
|
|
17
|
+
// business event appended from a resumed step would need the owning
|
|
18
|
+
// feature's write-handler context instead, unrelated to what Phase 3b
|
|
19
|
+
// threads through, so throw-on-mismatch is the direct probe: the run
|
|
20
|
+
// reaching workflow.run-completed instead of workflow.run-failed IS the
|
|
21
|
+
// assertion that ctx.steps[awaits.replied] held the expected payload.
|
|
22
|
+
// r.step.return's own `data` is discarded for workflow-triggered runs (see
|
|
23
|
+
// workflow-runner.integration.test.ts) — this sidesteps that too.
|
|
24
|
+
|
|
25
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
26
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
27
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
28
|
+
import {
|
|
29
|
+
type AwaitedEventType,
|
|
30
|
+
createSystemUser,
|
|
31
|
+
defineFeature,
|
|
32
|
+
defineWorkflow,
|
|
33
|
+
type JobContext,
|
|
34
|
+
stepsPipeline,
|
|
35
|
+
WORKFLOW_RESUMED_TYPE,
|
|
36
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
37
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
38
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
39
|
+
type WorkflowDefinition,
|
|
40
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
41
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
42
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
43
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
44
|
+
import { workflowRunAggregateId } from "../aggregate-id";
|
|
45
|
+
import { registerEventTrigger } from "../event-trigger";
|
|
46
|
+
import { workflowRunnerFeature } from "../feature";
|
|
47
|
+
|
|
48
|
+
let stack: TestStack;
|
|
49
|
+
const admin = TestUsers.admin;
|
|
50
|
+
const T = getTemporal();
|
|
51
|
+
|
|
52
|
+
// "wk-" prefix — the workflow-registry + event-subscriber MSP registration
|
|
53
|
+
// are process-global; other test files in this suite use their own prefix
|
|
54
|
+
// (wr-, rl-) for the same reason (see resume-loop.integration.test.ts).
|
|
55
|
+
const waitForEventWorkflow = defineWorkflow({
|
|
56
|
+
name: "wk-wait-for-event",
|
|
57
|
+
trigger: { kind: "event", eventType: "wk-test.start" },
|
|
58
|
+
awaits: { replied: "wk-test.replied" },
|
|
59
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
60
|
+
steps: stepsPipeline<unknown, unknown, { readonly replied: AwaitedEventType }>(
|
|
61
|
+
({ r, awaits }) => [
|
|
62
|
+
r.step.waitForEvent({
|
|
63
|
+
event: awaits.replied,
|
|
64
|
+
match: {
|
|
65
|
+
version: 1,
|
|
66
|
+
expr: { kind: "atom", path: ["from"], op: { kind: "eq", value: "match@example.com" } },
|
|
67
|
+
},
|
|
68
|
+
timeout: "P7D",
|
|
69
|
+
}),
|
|
70
|
+
r.step.compute("seen", (ctx) => {
|
|
71
|
+
const payload = ctx.steps[awaits.replied] as { from: string; body: string } | undefined;
|
|
72
|
+
if (payload?.from !== "match@example.com" || payload.body !== "hi") {
|
|
73
|
+
throw new Error(`triggerPayload not threaded: got ${JSON.stringify(payload)}`);
|
|
74
|
+
}
|
|
75
|
+
return payload;
|
|
76
|
+
}),
|
|
77
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
78
|
+
],
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const timeoutWorkflow = defineWorkflow({
|
|
83
|
+
name: "wk-wait-for-event-timeout",
|
|
84
|
+
trigger: { kind: "event", eventType: "wk-test.timeout-start" },
|
|
85
|
+
awaits: { replied: "wk-test.timeout-replied" },
|
|
86
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
87
|
+
steps: stepsPipeline<unknown, unknown, { readonly replied: AwaitedEventType }>(
|
|
88
|
+
({ r, awaits }) => [
|
|
89
|
+
r.step.waitForEvent({
|
|
90
|
+
event: awaits.replied,
|
|
91
|
+
// Absolute ISO timestamp (not a "P..."/"PT..." duration) — resolves
|
|
92
|
+
// straight to timeoutAt, already in the past, no wakeAt-tampering
|
|
93
|
+
// needed (same trick rl-wait's `forIso` uses).
|
|
94
|
+
timeout: (ctx) => (ctx.event.payload as { timeoutIso: string }).timeoutIso,
|
|
95
|
+
}),
|
|
96
|
+
r.step.compute("seen", (ctx) => {
|
|
97
|
+
const payload = ctx.steps[awaits.replied];
|
|
98
|
+
// pending.triggerPayload round-trips through jsonb as null, not
|
|
99
|
+
// undefined, when the row was never matched (timeout path).
|
|
100
|
+
if (payload != null) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`expected no triggerPayload on a timeout, got ${JSON.stringify(payload)}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return payload;
|
|
106
|
+
}),
|
|
107
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
108
|
+
],
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// The two casts below only erase TAwaits's literal key type (`{replied: ...}`
|
|
113
|
+
// -> the default `Record<string, string>`) — registerEventTrigger itself
|
|
114
|
+
// stays generic-free (it never reads the pipeline's branded awaits map, only
|
|
115
|
+
// workflow.awaits's plain string values), so this is the narrow-to-wide
|
|
116
|
+
// widening cast the `const TAwaits` inference on defineWorkflow forces at
|
|
117
|
+
// every call site, not a loss of type safety over real behavior.
|
|
118
|
+
const testTriggersFeature = defineFeature("workflow-runner-event-wakeup-test-triggers", (r) => {
|
|
119
|
+
registerEventTrigger(r, waitForEventWorkflow as unknown as WorkflowDefinition);
|
|
120
|
+
registerEventTrigger(r, timeoutWorkflow as unknown as WorkflowDefinition);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const noopLogger: JobContext["log"] = {
|
|
124
|
+
info() {},
|
|
125
|
+
warn() {},
|
|
126
|
+
error() {},
|
|
127
|
+
debug() {},
|
|
128
|
+
child() {
|
|
129
|
+
return noopLogger;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
async function insertEvent(
|
|
134
|
+
type: string,
|
|
135
|
+
payload: Record<string, unknown>,
|
|
136
|
+
user: { readonly id: string; readonly tenantId: string } = admin,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
await insertOne(stack.db, eventsTable, {
|
|
139
|
+
aggregateId: crypto.randomUUID(),
|
|
140
|
+
aggregateType: "wk-test-source",
|
|
141
|
+
tenantId: user.tenantId,
|
|
142
|
+
version: 1,
|
|
143
|
+
type,
|
|
144
|
+
eventVersion: 1,
|
|
145
|
+
payload,
|
|
146
|
+
metadata: { userId: user.id },
|
|
147
|
+
createdBy: user.id,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function fireTrigger(
|
|
152
|
+
type: string,
|
|
153
|
+
payload: Record<string, unknown>,
|
|
154
|
+
user: { readonly id: string; readonly tenantId: string } = admin,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
await insertEvent(type, payload, user);
|
|
157
|
+
// Two passes: event-trigger's MSP writes run-started + the suspension on
|
|
158
|
+
// the first pass; pending-projection only observes that newly-written
|
|
159
|
+
// suspension event on a pass after it exists.
|
|
160
|
+
await stack.eventDispatcher?.runOnce();
|
|
161
|
+
await stack.eventDispatcher?.runOnce();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function fireAwaitedEvent(
|
|
165
|
+
type: string,
|
|
166
|
+
payload: Record<string, unknown>,
|
|
167
|
+
user: { readonly id: string; readonly tenantId: string } = admin,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
await insertEvent(type, payload, user);
|
|
170
|
+
await stack.eventDispatcher?.runOnce();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function loadRunEvents(runId: string) {
|
|
174
|
+
return selectMany(
|
|
175
|
+
stack.db,
|
|
176
|
+
eventsTable,
|
|
177
|
+
{ aggregateId: runId },
|
|
178
|
+
{ orderBy: { col: "version", direction: "asc" } },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function pendingRow(
|
|
183
|
+
runId: string,
|
|
184
|
+
stepIndex: number,
|
|
185
|
+
): Promise<
|
|
186
|
+
{ triggerEventType: string | null; triggerPayload: unknown; wakeAt: string } | undefined
|
|
187
|
+
> {
|
|
188
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
189
|
+
`SELECT trigger_event_type AS "triggerEventType", trigger_payload AS "triggerPayload", wake_at AS "wakeAt" FROM workflow_run_pending WHERE run_id = $1 AND step_index = $2`,
|
|
190
|
+
[runId, stepIndex],
|
|
191
|
+
)) as ReadonlyArray<{ triggerEventType: string | null; triggerPayload: unknown; wakeAt: string }>;
|
|
192
|
+
return rows[0];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function runResumeDueRunsJob(tenantId: string): Promise<void> {
|
|
196
|
+
const job = stack.registry.getJob("workflow-runner:job:resume-due-runs");
|
|
197
|
+
expect(job).toBeDefined();
|
|
198
|
+
if (!job) return;
|
|
199
|
+
|
|
200
|
+
const systemUser = createSystemUser(tenantId);
|
|
201
|
+
const ctx: JobContext = {
|
|
202
|
+
db: stack.db,
|
|
203
|
+
registry: stack.registry,
|
|
204
|
+
systemUser,
|
|
205
|
+
log: noopLogger,
|
|
206
|
+
triggeredBy: null,
|
|
207
|
+
_tenantId: tenantId,
|
|
208
|
+
write: (qn, payload) => stack.dispatcher.write(qn, payload, systemUser),
|
|
209
|
+
} as JobContext;
|
|
210
|
+
await job.handler({}, ctx);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
describe("workflow-runner event-wakeup", () => {
|
|
214
|
+
beforeAll(async () => {
|
|
215
|
+
stack = await setupTestStack({ features: [workflowRunnerFeature, testTriggersFeature] });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
afterAll(async () => {
|
|
219
|
+
await stack.cleanup();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("matching awaited event marks the row due; resume-due-runs completes the run and a later step sees the payload", async () => {
|
|
223
|
+
const runKey = crypto.randomUUID();
|
|
224
|
+
const runId = workflowRunAggregateId(waitForEventWorkflow.name, runKey);
|
|
225
|
+
|
|
226
|
+
await fireTrigger("wk-test.start", { runKey });
|
|
227
|
+
expect((await pendingRow(runId, 0))?.triggerEventType).toBeNull();
|
|
228
|
+
|
|
229
|
+
await fireAwaitedEvent("wk-test.replied", { from: "match@example.com", body: "hi" });
|
|
230
|
+
|
|
231
|
+
const marked = await pendingRow(runId, 0);
|
|
232
|
+
expect(marked?.triggerEventType).toBe("wk-test.replied");
|
|
233
|
+
expect(marked?.triggerPayload).toEqual({ from: "match@example.com", body: "hi" });
|
|
234
|
+
|
|
235
|
+
await runResumeDueRunsJob(admin.tenantId);
|
|
236
|
+
|
|
237
|
+
// The "seen" compute step throws if ctx.steps[awaits.replied] doesn't
|
|
238
|
+
// hold the exact triggerPayload — reaching run-completed (not
|
|
239
|
+
// run-failed) IS the proof resume-run threaded it through correctly.
|
|
240
|
+
const rows = await loadRunEvents(runId);
|
|
241
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
242
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
243
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
244
|
+
WORKFLOW_RESUMED_TYPE,
|
|
245
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("awaited event that fails matchExpr leaves the row untouched, no resume", async () => {
|
|
250
|
+
const runKey = crypto.randomUUID();
|
|
251
|
+
const runId = workflowRunAggregateId(waitForEventWorkflow.name, runKey);
|
|
252
|
+
|
|
253
|
+
await fireTrigger("wk-test.start", { runKey });
|
|
254
|
+
const before = await pendingRow(runId, 0);
|
|
255
|
+
expect(before?.triggerEventType).toBeNull();
|
|
256
|
+
|
|
257
|
+
await fireAwaitedEvent("wk-test.replied", { from: "someone-else@example.com", body: "hi" });
|
|
258
|
+
|
|
259
|
+
const after = await pendingRow(runId, 0);
|
|
260
|
+
expect(after?.triggerEventType).toBeNull();
|
|
261
|
+
expect(after?.wakeAt).toEqual(before?.wakeAt);
|
|
262
|
+
|
|
263
|
+
await runResumeDueRunsJob(admin.tenantId);
|
|
264
|
+
const rows = await loadRunEvents(runId);
|
|
265
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
266
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
267
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
268
|
+
]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("an event no workflow awaits leaves every pending row untouched", async () => {
|
|
272
|
+
const runKey = crypto.randomUUID();
|
|
273
|
+
const runId = workflowRunAggregateId(waitForEventWorkflow.name, runKey);
|
|
274
|
+
|
|
275
|
+
await fireTrigger("wk-test.start", { runKey });
|
|
276
|
+
const before = await pendingRow(runId, 0);
|
|
277
|
+
|
|
278
|
+
await fireAwaitedEvent("wk-test.nobody-awaits-this", { anything: true });
|
|
279
|
+
|
|
280
|
+
const after = await pendingRow(runId, 0);
|
|
281
|
+
expect(after).toEqual(before);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("tenant isolation: another tenant's matching event does not wake this tenant's run", async () => {
|
|
285
|
+
const runKey = crypto.randomUUID();
|
|
286
|
+
const runId = workflowRunAggregateId(waitForEventWorkflow.name, runKey);
|
|
287
|
+
|
|
288
|
+
await fireTrigger("wk-test.start", { runKey });
|
|
289
|
+
expect((await pendingRow(runId, 0))?.triggerEventType).toBeNull();
|
|
290
|
+
|
|
291
|
+
await fireAwaitedEvent(
|
|
292
|
+
"wk-test.replied",
|
|
293
|
+
{ from: "match@example.com", body: "hi" },
|
|
294
|
+
TestUsers.otherTenant,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect((await pendingRow(runId, 0))?.triggerEventType).toBeNull();
|
|
298
|
+
|
|
299
|
+
const rows = await loadRunEvents(runId);
|
|
300
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
301
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
302
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("timeout path: no matching event ever arrives, wakeAt (from timeoutAt) elapses, resume-due-runs still wakes the run", async () => {
|
|
307
|
+
const runKey = crypto.randomUUID();
|
|
308
|
+
const runId = workflowRunAggregateId(timeoutWorkflow.name, runKey);
|
|
309
|
+
const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
|
|
310
|
+
|
|
311
|
+
await fireTrigger("wk-test.timeout-start", { runKey, timeoutIso: pastIso });
|
|
312
|
+
expect((await pendingRow(runId, 0))?.triggerEventType).toBeNull();
|
|
313
|
+
|
|
314
|
+
await runResumeDueRunsJob(admin.tenantId);
|
|
315
|
+
|
|
316
|
+
// The "seen" compute step throws if ctx.steps[awaits.replied] is
|
|
317
|
+
// anything other than undefined on a timeout resume — reaching
|
|
318
|
+
// run-completed proves resume-run left it unset (pending.triggerPayload
|
|
319
|
+
// stayed NULL, never matched).
|
|
320
|
+
const rows = await loadRunEvents(runId);
|
|
321
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
322
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
323
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
324
|
+
WORKFLOW_RESUMED_TYPE,
|
|
325
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
326
|
+
]);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// pending-projection — workflow_run_pending MultiStreamProjection integration test.
|
|
2
|
+
//
|
|
3
|
+
// Exercises registerWorkflowRunPendingProjection end to end through a real
|
|
4
|
+
// event-dispatcher pass: a test-only writeHandler appends the suspension/
|
|
5
|
+
// resume/terminal event onto the run's workflow-run stream via
|
|
6
|
+
// ctx.unsafeAppendEvent (the same primitive runner.ts uses for
|
|
7
|
+
// run-started/run-completed), stack.eventDispatcher.runOnce() delivers it to
|
|
8
|
+
// the MSP, and the resulting workflow_run_pending rows are asserted against
|
|
9
|
+
// real Postgres rows.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import {
|
|
14
|
+
defineFeature,
|
|
15
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
16
|
+
WORKFLOW_RESUMED_TYPE,
|
|
17
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
18
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
19
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
20
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
21
|
+
WORKFLOW_WAITING_TYPE,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { workflowRunAggregateId } from "../aggregate-id";
|
|
26
|
+
import { workflowRunnerFeature } from "../feature";
|
|
27
|
+
import { workflowRunPendingTable } from "../tables";
|
|
28
|
+
|
|
29
|
+
let stack: TestStack;
|
|
30
|
+
const admin = TestUsers.admin;
|
|
31
|
+
|
|
32
|
+
// Generic "emit any workflow-run event" writeHandler — mirrors the
|
|
33
|
+
// generic sendNotification handler in delivery.integration.test.ts. Lets
|
|
34
|
+
// each test pick the suspension/resume/terminal event type + payload it
|
|
35
|
+
// wants to exercise instead of poking eventsTable directly, so the MSP is
|
|
36
|
+
// driven through the same ctx.unsafeAppendEvent + event-dispatcher path
|
|
37
|
+
// runner.ts and event-trigger.ts use in production.
|
|
38
|
+
const testEventsFeature = defineFeature("wr-pending-test", (r) => {
|
|
39
|
+
r.writeHandler(
|
|
40
|
+
"emit",
|
|
41
|
+
z.object({
|
|
42
|
+
runId: z.string(),
|
|
43
|
+
type: z.string(),
|
|
44
|
+
payload: z.unknown(),
|
|
45
|
+
}),
|
|
46
|
+
async (event, ctx) => {
|
|
47
|
+
await ctx.unsafeAppendEvent({
|
|
48
|
+
aggregateId: event.payload.runId,
|
|
49
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
50
|
+
type: event.payload.type,
|
|
51
|
+
payload: event.payload.payload,
|
|
52
|
+
});
|
|
53
|
+
return { isSuccess: true, data: {} };
|
|
54
|
+
},
|
|
55
|
+
{ access: { openToAll: true } },
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
async function emitAndDeliver(
|
|
60
|
+
runId: string,
|
|
61
|
+
type: string,
|
|
62
|
+
payload: Record<string, unknown>,
|
|
63
|
+
user = admin,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
await stack.http.writeOk("wr-pending-test:write:emit", { runId, type, payload }, user);
|
|
66
|
+
await stack.eventDispatcher?.runOnce();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function pendingRowsFor(runId: string) {
|
|
70
|
+
return selectMany(stack.db, workflowRunPendingTable, { runId });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function newRunId(workflowName: string): string {
|
|
74
|
+
return workflowRunAggregateId(workflowName, crypto.randomUUID());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("workflow-runner pending-projection", () => {
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
stack = await setupTestStack({ features: [workflowRunnerFeature, testEventsFeature] });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await stack.cleanup();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("wait-suspension writes a row with wakeAt/stepIndex/workflowName/suspensionEventType, wait-fields NULL", async () => {
|
|
87
|
+
const workflowName = "wr-pending-wait";
|
|
88
|
+
const runId = newRunId(workflowName);
|
|
89
|
+
|
|
90
|
+
await emitAndDeliver(runId, WORKFLOW_WAITING_TYPE, {
|
|
91
|
+
workflowName,
|
|
92
|
+
stepIndex: 2,
|
|
93
|
+
wakeAt: "2026-05-01T10:00:00Z",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const rows = await pendingRowsFor(runId);
|
|
97
|
+
expect(rows).toHaveLength(1);
|
|
98
|
+
const row = rows[0]!;
|
|
99
|
+
expect(row["workflowName"]).toBe(workflowName);
|
|
100
|
+
expect(row["stepIndex"]).toBe(2);
|
|
101
|
+
expect(row["suspensionEventType"]).toBe(WORKFLOW_WAITING_TYPE);
|
|
102
|
+
expect(String(row["wakeAt"])).toBe("2026-05-01T10:00:00Z");
|
|
103
|
+
expect(row["waitEventType"]).toBeNull();
|
|
104
|
+
expect(row["matchExpr"]).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("waitForEvent-suspension derives wakeAt from timeoutAt and round-trips matchExpr unchanged", async () => {
|
|
108
|
+
const workflowName = "wr-pending-wfe";
|
|
109
|
+
const runId = newRunId(workflowName);
|
|
110
|
+
const matchExpr = {
|
|
111
|
+
version: 1,
|
|
112
|
+
expr: { kind: "atom", path: ["email"], op: { kind: "eq", value: "a@b.de" } },
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
await emitAndDeliver(runId, WORKFLOW_WAITING_FOR_EVENT_TYPE, {
|
|
116
|
+
workflowName,
|
|
117
|
+
stepIndex: 1,
|
|
118
|
+
eventType: "user.replied",
|
|
119
|
+
timeoutAt: "2026-06-01T08:30:00Z",
|
|
120
|
+
match: matchExpr,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const rows = await pendingRowsFor(runId);
|
|
124
|
+
expect(rows).toHaveLength(1);
|
|
125
|
+
const row = rows[0]!;
|
|
126
|
+
expect(row["suspensionEventType"]).toBe(WORKFLOW_WAITING_FOR_EVENT_TYPE);
|
|
127
|
+
expect(String(row["wakeAt"])).toBe("2026-06-01T08:30:00Z");
|
|
128
|
+
expect(row["waitEventType"]).toBe("user.replied");
|
|
129
|
+
expect(row["matchExpr"]).toEqual(matchExpr);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("retry-scheduled sets retryAttempt from the payload's attempt", async () => {
|
|
133
|
+
const workflowName = "wr-pending-retry";
|
|
134
|
+
const runId = newRunId(workflowName);
|
|
135
|
+
|
|
136
|
+
await emitAndDeliver(runId, WORKFLOW_RETRY_SCHEDULED_TYPE, {
|
|
137
|
+
workflowName,
|
|
138
|
+
stepIndex: 0,
|
|
139
|
+
attempt: 3,
|
|
140
|
+
wakeAt: "2026-05-02T00:00:00Z",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const rows = await pendingRowsFor(runId);
|
|
144
|
+
expect(rows).toHaveLength(1);
|
|
145
|
+
expect(rows[0]!["retryAttempt"]).toBe(3);
|
|
146
|
+
expect(rows[0]!["suspensionEventType"]).toBe(WORKFLOW_RETRY_SCHEDULED_TYPE);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("WORKFLOW_RESUMED deletes the run's pending row", async () => {
|
|
150
|
+
const workflowName = "wr-pending-resumed";
|
|
151
|
+
const runId = newRunId(workflowName);
|
|
152
|
+
|
|
153
|
+
await emitAndDeliver(runId, WORKFLOW_WAITING_TYPE, {
|
|
154
|
+
workflowName,
|
|
155
|
+
stepIndex: 0,
|
|
156
|
+
wakeAt: "2026-05-03T00:00:00Z",
|
|
157
|
+
});
|
|
158
|
+
expect(await pendingRowsFor(runId)).toHaveLength(1);
|
|
159
|
+
|
|
160
|
+
await emitAndDeliver(runId, WORKFLOW_RESUMED_TYPE, {});
|
|
161
|
+
expect(await pendingRowsFor(runId)).toHaveLength(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("WORKFLOW_RUN_COMPLETED deletes the run's pending row", async () => {
|
|
165
|
+
const workflowName = "wr-pending-completed";
|
|
166
|
+
const runId = newRunId(workflowName);
|
|
167
|
+
|
|
168
|
+
await emitAndDeliver(runId, WORKFLOW_WAITING_TYPE, {
|
|
169
|
+
workflowName,
|
|
170
|
+
stepIndex: 0,
|
|
171
|
+
wakeAt: "2026-05-03T00:00:00Z",
|
|
172
|
+
});
|
|
173
|
+
expect(await pendingRowsFor(runId)).toHaveLength(1);
|
|
174
|
+
|
|
175
|
+
await emitAndDeliver(runId, WORKFLOW_RUN_COMPLETED_TYPE, {});
|
|
176
|
+
expect(await pendingRowsFor(runId)).toHaveLength(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("WORKFLOW_RUN_FAILED deletes the run's pending row", async () => {
|
|
180
|
+
const workflowName = "wr-pending-failed";
|
|
181
|
+
const runId = newRunId(workflowName);
|
|
182
|
+
|
|
183
|
+
await emitAndDeliver(runId, WORKFLOW_WAITING_TYPE, {
|
|
184
|
+
workflowName,
|
|
185
|
+
stepIndex: 0,
|
|
186
|
+
wakeAt: "2026-05-03T00:00:00Z",
|
|
187
|
+
});
|
|
188
|
+
expect(await pendingRowsFor(runId)).toHaveLength(1);
|
|
189
|
+
|
|
190
|
+
await emitAndDeliver(runId, WORKFLOW_RUN_FAILED_TYPE, {});
|
|
191
|
+
expect(await pendingRowsFor(runId)).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("sequential suspend -> resume -> suspend again: never two rows at once, new row carries the new stepIndex", async () => {
|
|
195
|
+
const workflowName = "wr-pending-lifecycle";
|
|
196
|
+
const runId = newRunId(workflowName);
|
|
197
|
+
|
|
198
|
+
await emitAndDeliver(runId, WORKFLOW_WAITING_TYPE, {
|
|
199
|
+
workflowName,
|
|
200
|
+
stepIndex: 0,
|
|
201
|
+
wakeAt: "2026-05-04T00:00:00Z",
|
|
202
|
+
});
|
|
203
|
+
let rows = await pendingRowsFor(runId);
|
|
204
|
+
expect(rows).toHaveLength(1);
|
|
205
|
+
expect(rows[0]!["stepIndex"]).toBe(0);
|
|
206
|
+
|
|
207
|
+
await emitAndDeliver(runId, WORKFLOW_RESUMED_TYPE, {});
|
|
208
|
+
expect(await pendingRowsFor(runId)).toHaveLength(0);
|
|
209
|
+
|
|
210
|
+
await emitAndDeliver(runId, WORKFLOW_WAITING_TYPE, {
|
|
211
|
+
workflowName,
|
|
212
|
+
stepIndex: 1,
|
|
213
|
+
wakeAt: "2026-05-04T01:00:00Z",
|
|
214
|
+
});
|
|
215
|
+
rows = await pendingRowsFor(runId);
|
|
216
|
+
expect(rows).toHaveLength(1);
|
|
217
|
+
expect(rows[0]!["stepIndex"]).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("tenant isolation: another tenant's resume does not delete or leak this tenant's row", async () => {
|
|
221
|
+
const otherTenantRunId = newRunId("wr-pending-tenant-a");
|
|
222
|
+
await emitAndDeliver(
|
|
223
|
+
otherTenantRunId,
|
|
224
|
+
WORKFLOW_WAITING_TYPE,
|
|
225
|
+
{ workflowName: "wr-pending-tenant-a", stepIndex: 0, wakeAt: "2026-05-05T00:00:00Z" },
|
|
226
|
+
TestUsers.otherTenant,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const adminRunId = newRunId("wr-pending-tenant-b");
|
|
230
|
+
await emitAndDeliver(adminRunId, WORKFLOW_WAITING_TYPE, {
|
|
231
|
+
workflowName: "wr-pending-tenant-b",
|
|
232
|
+
stepIndex: 0,
|
|
233
|
+
wakeAt: "2026-05-05T00:00:00Z",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Resolving the admin-tenant run must not touch the other tenant's row.
|
|
237
|
+
await emitAndDeliver(adminRunId, WORKFLOW_RESUMED_TYPE, {});
|
|
238
|
+
|
|
239
|
+
const otherTenantRows = await pendingRowsFor(otherTenantRunId);
|
|
240
|
+
expect(otherTenantRows).toHaveLength(1);
|
|
241
|
+
expect(otherTenantRows[0]!["tenantId"]).toBe(TestUsers.otherTenant.tenantId);
|
|
242
|
+
|
|
243
|
+
expect(await pendingRowsFor(adminRunId)).toHaveLength(0);
|
|
244
|
+
|
|
245
|
+
// Tenant-scoped select proves the row is filed under its own tenant.
|
|
246
|
+
const scoped = await selectMany(stack.db, workflowRunPendingTable, {
|
|
247
|
+
tenantId: TestUsers.otherTenant.tenantId,
|
|
248
|
+
runId: otherTenantRunId,
|
|
249
|
+
});
|
|
250
|
+
expect(scoped).toHaveLength(1);
|
|
251
|
+
const crossTenant = await selectMany(stack.db, workflowRunPendingTable, {
|
|
252
|
+
tenantId: admin.tenantId,
|
|
253
|
+
runId: otherTenantRunId,
|
|
254
|
+
});
|
|
255
|
+
expect(crossTenant).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("same workflow + idempotencyKey across tenants share a run_id but keep separate pending rows", async () => {
|
|
259
|
+
// workflowRunAggregateId() is deliberately tenant-agnostic (aggregate-id.ts):
|
|
260
|
+
// uuidv5(workflowName|idempotencyKey) only. Two tenants triggering the
|
|
261
|
+
// same workflow with the same idempotencyKey (e.g. a scheduled monthly
|
|
262
|
+
// close) land on the SAME run_id. Before tenant_id joined the PK, the
|
|
263
|
+
// second tenant's upsert on (run_id, step_index) silently overwrote the
|
|
264
|
+
// first tenant's row instead of inserting its own.
|
|
265
|
+
const workflowName = "wr-pending-shared-runid";
|
|
266
|
+
const idempotencyKey = "monatsabschluss-2026-09";
|
|
267
|
+
const sharedRunId = workflowRunAggregateId(workflowName, idempotencyKey);
|
|
268
|
+
|
|
269
|
+
await emitAndDeliver(
|
|
270
|
+
sharedRunId,
|
|
271
|
+
WORKFLOW_WAITING_TYPE,
|
|
272
|
+
{ workflowName, stepIndex: 0, wakeAt: "2026-09-01T00:00:00Z" },
|
|
273
|
+
TestUsers.otherTenant,
|
|
274
|
+
);
|
|
275
|
+
await emitAndDeliver(
|
|
276
|
+
sharedRunId,
|
|
277
|
+
WORKFLOW_WAITING_TYPE,
|
|
278
|
+
{ workflowName, stepIndex: 0, wakeAt: "2026-09-02T00:00:00Z" },
|
|
279
|
+
admin,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const rows = await pendingRowsFor(sharedRunId);
|
|
283
|
+
expect(rows).toHaveLength(2);
|
|
284
|
+
|
|
285
|
+
const otherTenantRow = rows.find((r) => r["tenantId"] === TestUsers.otherTenant.tenantId);
|
|
286
|
+
const adminRow = rows.find((r) => r["tenantId"] === admin.tenantId);
|
|
287
|
+
expect(otherTenantRow).toBeDefined();
|
|
288
|
+
expect(adminRow).toBeDefined();
|
|
289
|
+
expect(String(otherTenantRow!["wakeAt"])).toBe("2026-09-01T00:00:00Z");
|
|
290
|
+
expect(String(adminRow!["wakeAt"])).toBe("2026-09-02T00:00:00Z");
|
|
291
|
+
});
|
|
292
|
+
});
|