@cosmicdrift/kumiko-bundled-features 0.231.0 → 0.233.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 (29) hide show
  1. package/package.json +9 -9
  2. package/src/cap-overview/__tests__/usage-math.test.ts +12 -0
  3. package/src/cap-overview/changes.json +6 -0
  4. package/src/cap-overview/handlers/caps-usage.query.ts +2 -1
  5. package/src/cap-overview/types.ts +5 -2
  6. package/src/cap-overview/usage-math.ts +6 -4
  7. package/src/cap-overview/web/__tests__/cap-cards-panel.test.tsx +53 -0
  8. package/src/cap-overview/web/__tests__/cap-usage-bar.test.tsx +36 -0
  9. package/src/cap-overview/web/cap-cards-panel.tsx +11 -7
  10. package/src/cap-overview/web/cap-usage-bar.tsx +9 -0
  11. package/src/cap-overview/web/cap-usage-cell.tsx +2 -1
  12. package/src/sessions/__tests__/sessions-screens.boot.test.ts +13 -0
  13. package/src/sessions/feature.ts +4 -70
  14. package/src/sessions/screens.ts +84 -0
  15. package/src/tenant/__tests__/members-screens.boot.test.ts +14 -0
  16. package/src/tenant/screens.ts +10 -2
  17. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +328 -0
  18. package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +292 -0
  19. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +277 -0
  20. package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +6 -11
  21. package/src/workflow-runner/db/queries/due-runs.ts +23 -0
  22. package/src/workflow-runner/event-subscriber.ts +93 -0
  23. package/src/workflow-runner/event-trigger.ts +14 -4
  24. package/src/workflow-runner/feature.ts +62 -5
  25. package/src/workflow-runner/handlers/resume-run.write.ts +277 -0
  26. package/src/workflow-runner/pending-projection.ts +148 -0
  27. package/src/workflow-runner/runner.ts +36 -13
  28. package/src/workflow-runner/tables.ts +95 -0
  29. package/src/workflow-runner/workflow-registry.ts +23 -0
@@ -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
+ });