@cosmicdrift/kumiko-bundled-features 0.234.0 → 0.235.1

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.
@@ -110,8 +110,10 @@ routes.
110
110
 
111
111
  ## Boot check
112
112
 
113
- `r.job` with `runOnBoot: true` checks at app start whether the
114
- required blocks exist in SYSTEM_TENANT. **Default** (DACH):
113
+ `r.job` with `bootGate: true` runs inline while the API job runner
114
+ starts and checks whether the required blocks exist in SYSTEM_TENANT.
115
+ A throw rejects the boot, so the process never reports ready.
116
+ **Default** (DACH):
115
117
 
116
118
  | Slug + locale | What happens when missing |
117
119
  |---|---|
@@ -12,7 +12,7 @@ import {
12
12
  } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
13
13
  import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
14
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
- import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createRegistry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
17
  import {
18
18
  setupTestStack,
@@ -226,10 +226,21 @@ describe("markdown render helpers", () => {
226
226
  });
227
227
  });
228
228
 
229
- // Boot-Check direkt (ohne dev-server-Job-Runner-Path) — verifiziert
230
- // dass die Logik fehlende Blocks im SYSTEM_TENANT erkennt. Der eigentliche
231
- // runOnBoot-Trigger lebt im JobRunner und wird in jobs-feature integration-
232
- // tests separately exercised.
229
+ describe("legal-pages :: boot gate wiring", () => {
230
+ // The abort behaviour itself is proven in the framework's job-runner
231
+ // integration tests ("boot gates"); this only pins that legal-pages is
232
+ // wired onto that path and not onto the fire-and-forget runOnBoot one.
233
+ test("boot check is declared as a bootGate, not as runOnBoot", () => {
234
+ const registry = createRegistry([textFeature, legalFeature]);
235
+ const job = registry.getJob("legal-pages:job:legal-pages-boot-check");
236
+ expect(job).toBeDefined();
237
+ expect(job?.bootGate).toBe(true);
238
+ expect(job?.runOnBoot).toBeUndefined();
239
+ });
240
+ });
241
+
242
+ // Boot check called directly (without the dev-server job-runner path) —
243
+ // verifies that the logic detects blocks missing in SYSTEM_TENANT.
233
244
  describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", () => {
234
245
  test("legal-pages serven SYSTEM_TENANT-Texte auch wenn tenantResolver einen anderen Tenant zurückgibt", async () => {
235
246
  // Simuliert publicstatus's Setup: host-basierter tenantResolver der
@@ -208,7 +208,10 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
208
208
  "legal-pages-boot-check",
209
209
  {
210
210
  trigger: { manual: true },
211
- runOnBoot: true,
211
+ // bootGate, not runOnBoot: only an inline gate can actually abort the
212
+ // boot. runOnBoot merely enqueues, so a missing imprint would fail a
213
+ // queue job while the pod goes ready anyway (fw#2590).
214
+ bootGate: true,
212
215
  runIn: "api",
213
216
  },
214
217
  async (_payload, ctx) => runLegalPagesBootCheck(ctx, requiredBlocks),
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createConfigFeature } from "../../config/feature";
4
+ import { createSecretsFeature } from "../feature";
5
+
6
+ // `secrets` is a foundation feature auto-mounted even when `config` is not
7
+ // (dev-server scaffold-app.ts FOUNDATION_FEATURES). A declared r.secret()
8
+ // makes buildConfigFeatureSchema emit a secretsEdit screen + Settings-Hub
9
+ // chrome navs, whose dot-form labels (config.settings.title, etc.) must
10
+ // resolve at boot without the config feature present (fw#2577).
11
+ function stripeSecret() {
12
+ return defineFeature("stripe", (r) => {
13
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
14
+ });
15
+ }
16
+
17
+ describe("Settings-Hub chrome i18n keys — secrets without config", () => {
18
+ test("boots with only the secrets feature mounted (no config feature)", () => {
19
+ expect(() => validateBoot([createSecretsFeature(), stripeSecret()])).not.toThrow();
20
+ });
21
+
22
+ test("boots with both secrets and config mounted", () => {
23
+ expect(() =>
24
+ validateBoot([createSecretsFeature(), createConfigFeature(), stripeSecret()]),
25
+ ).not.toThrow();
26
+ });
27
+ });
@@ -4,6 +4,7 @@ import {
4
4
  type FeatureDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
+ import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
7
8
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
9
  import { z } from "zod";
9
10
  import { DEFAULT_SECRETS_ACCESS } from "./constants";
@@ -129,6 +130,15 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
129
130
  });
130
131
  r.envSchema(secretsEnvSchema);
131
132
 
133
+ // `secrets` is a foundation feature auto-mounted even when `config` is
134
+ // not (see FOUNDATION_FEATURES, dev-server scaffold-app.ts), so it must
135
+ // ship the Settings-Hub chrome keys itself — server-authored
136
+ // r.translations are projected into FeatureSchema.translations and used
137
+ // as a client fallback bundle (create-app.tsx schemaTranslations),
138
+ // covering both boot-time validation and rendering without a web
139
+ // client-plugin for this feature.
140
+ r.translations({ keys: SETTINGS_HUB_I18N });
141
+
132
142
  // ES entity: set/delete go through the executor, `tenantSecret.created/
133
143
  // .updated/.deleted` events land on the aggregate stream. Reads fire a
134
144
  // separate `tenantSecretRead` event per call (see secrets-context.get
@@ -4,10 +4,10 @@ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
4
  import { AuthHandlers } from "../../auth-email-password/constants";
5
5
  import { createConfigFeature } from "../../config/feature";
6
6
  import {
7
- DEFAULT_INVITE_ROLE_OPTIONS,
8
7
  INVITE_CREATE_SCREEN_ID,
9
8
  MEMBER_ROLES_EDIT_SCREEN_ID,
10
9
  MEMBERS_SCREEN_ID,
10
+ OWNER_INVITE_ROLE_OPTIONS,
11
11
  TenantHandlers,
12
12
  TenantQueries,
13
13
  } from "../constants";
@@ -106,7 +106,7 @@ describe("tenant members screen + handler access alignment", () => {
106
106
  expect(section.fields).toEqual([{ field: "userId", readOnly: true }, "roles"]);
107
107
  expect(screen.fields["roles"]).toEqual({
108
108
  type: "multiSelect",
109
- options: DEFAULT_INVITE_ROLE_OPTIONS,
109
+ options: OWNER_INVITE_ROLE_OPTIONS,
110
110
  required: true,
111
111
  });
112
112
  if (screen && "access" in screen && screen.access && "roles" in screen.access) {
@@ -116,6 +116,34 @@ describe("tenant members screen + handler access alignment", () => {
116
116
  }
117
117
  });
118
118
 
119
+ // fw-2452 regression: the roles-edit multiSelect is prefilled with a
120
+ // member's current roles, so it must list every rank updateMemberRoles can
121
+ // actually assign (incl. TenantAdmin) — while the invite picker stays a
122
+ // closed allowlist without TenantAdmin (fw#2414), since inviting always
123
+ // starts a brand-new membership rather than editing an existing one.
124
+ test("member-roles-edit options can represent every assignable rank; invite picker stays TenantAdmin-free", () => {
125
+ const tenant = createTenantFeature({ inviteScreen: true });
126
+ const rolesEditScreen = tenant.screens[MEMBER_ROLES_EDIT_SCREEN_ID];
127
+ if (rolesEditScreen?.type !== "actionForm") {
128
+ throw new Error("expected member-roles-edit screen to be actionForm");
129
+ }
130
+ const rolesField = rolesEditScreen.fields["roles"];
131
+ if (rolesField?.type !== "multiSelect") {
132
+ throw new Error("expected roles field to be multiSelect");
133
+ }
134
+ expect(rolesField.options).toContain("TenantAdmin");
135
+
136
+ const inviteScreen = tenant.screens[INVITE_CREATE_SCREEN_ID];
137
+ if (inviteScreen?.type !== "actionForm") {
138
+ throw new Error("expected invite-create screen to be actionForm");
139
+ }
140
+ const roleField = inviteScreen.fields["role"];
141
+ if (roleField?.type !== "select") {
142
+ throw new Error("expected role field to be select");
143
+ }
144
+ expect(roleField.options).not.toContain("TenantAdmin");
145
+ });
146
+
119
147
  // The generic drawer-open/submit/close/refetch mechanics for kind:"drawer"
120
148
  // toolbar actions are already covered end-to-end with a synthetic screen in
121
149
  // renderer-web's projection-list-actions.test.tsx — this only proves OUR
@@ -23,10 +23,12 @@ export const MEMBER_STATUS_CELL_COMPONENT = "MemberStatusCell" as const;
23
23
  export const MEMBER_ROLES_CELL_COMPONENT = "MemberRolesCell" as const;
24
24
 
25
25
  /** Closed allowlist for invite-role picker — never free text (escalation guard). */
26
- // Admin (rank 2) shares these screens with TenantAdmin — TenantAdmin must not
27
- // appear here or Admin sees an unassignable option (fw#2414). Apps that need
28
- // owner invites compose OWNER_INVITE_ROLE_OPTIONS into a custom screen.
26
+ // Admin (rank 2) shares the invite-create screen with TenantAdmin — TenantAdmin
27
+ // must not appear here or Admin sees an unassignable option (fw#2414).
29
28
  export const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin"] as const;
29
+ // Full assignable rank list, used by memberRolesEditScreen: that screen is
30
+ // prefilled with a member's *current* roles, so it must be able to represent
31
+ // every rank a membership can actually hold, including TenantAdmin (fw#2452).
30
32
  export const OWNER_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
31
33
 
32
34
  // Qualified write handler names (QN format: scope:type:name)
@@ -12,6 +12,7 @@ import {
12
12
  MEMBER_ROLES_EDIT_SCREEN_ID,
13
13
  MEMBER_STATUS_CELL_COMPONENT,
14
14
  MEMBERS_SCREEN_ID,
15
+ OWNER_INVITE_ROLE_OPTIONS,
15
16
  TenantHandlers,
16
17
  TenantQueries,
17
18
  } from "./constants";
@@ -179,7 +180,11 @@ export const memberRolesEditScreen = {
179
180
  cancelTarget: MEMBERS_SCREEN_ID,
180
181
  fields: {
181
182
  userId: { type: "text", required: true },
182
- roles: { type: "multiSelect", options: DEFAULT_INVITE_ROLE_OPTIONS, required: true },
183
+ // Prefilled with the member's current roles, so the option list must be
184
+ // able to represent every assignable rank — DEFAULT_INVITE_ROLE_OPTIONS
185
+ // omits TenantAdmin and would silently strip it on submit. Escalation
186
+ // still stays server-side via findForbiddenRoleAssignment (update-member-roles.write.ts).
187
+ roles: { type: "multiSelect", options: OWNER_INVITE_ROLE_OPTIONS, required: true },
183
188
  },
184
189
  layout: {
185
190
  // Prefill userId from rowAction; readOnly so the operator cannot retarget.
@@ -54,8 +54,10 @@ export const createWrite = defineWriteHandler({
54
54
  "user rows are tenant-agnostic identity records; uniqueness check and create need no tenant filter",
55
55
  );
56
56
 
57
+ // Pre-flight must match the partial bidx unique index, which only covers live rows.
57
58
  const existing = await fetchOne<{ id: string }>(db, userTable, {
58
59
  email: event.payload.email,
60
+ isDeleted: false,
59
61
  });
60
62
 
61
63
  if (existing) {
@@ -29,9 +29,13 @@ export const findForAuthQuery = defineQueryHandler({
29
29
  ),
30
30
  access: { roles: access.system },
31
31
  handler: async (query, ctx) => {
32
- const where: { email: string } | { id: string } =
32
+ // Soft-deleted rows can now share an email with a live row (the partial
33
+ // bidx unique index covers live rows only), so the email arm must resolve
34
+ // the live identity; every email-arm caller already rejects isDeleted
35
+ // rows after the fetch, so this only removes rows that would be discarded anyway.
36
+ const where: { email: string; isDeleted: false } | { id: string } =
33
37
  query.payload.email !== undefined
34
- ? { email: query.payload.email }
38
+ ? { email: query.payload.email, isDeleted: false }
35
39
  : { id: query.payload.id as string }; // @cast-boundary engine-payload
36
40
 
37
41
  if (!ctx.systemDb) {
@@ -83,9 +83,30 @@ const retryWorkflow: WorkflowDefinition = defineWorkflow({
83
83
  ]),
84
84
  });
85
85
 
86
+ // Counts real side-effect executions of the step AFTER the suspension point
87
+ // — distinct from the recorded event log, so a double-resume that somehow
88
+ // wrote a deduplicated event log but still re-ran the pipeline would still
89
+ // be caught (framework#2552/1).
90
+ let sideEffectCount = 0;
91
+
92
+ const idempotentResumeWorkflow: WorkflowDefinition = defineWorkflow({
93
+ name: "rl-idempotent-resume",
94
+ trigger: { kind: "event", eventType: "rl-test.idempotent-resume" },
95
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
96
+ steps: stepsPipeline(({ r }) => [
97
+ r.step.wait({ for: (ctx) => (ctx.event.payload as { forIso: string }).forIso }),
98
+ r.step.compute("sideEffect", () => {
99
+ sideEffectCount += 1;
100
+ return sideEffectCount;
101
+ }),
102
+ r.step.return({ isSuccess: true, data: undefined }),
103
+ ]),
104
+ });
105
+
86
106
  const testTriggersFeature = defineFeature("workflow-runner-resume-loop-test-triggers", (r) => {
87
107
  registerEventTrigger(r, waitWorkflow);
88
108
  registerEventTrigger(r, retryWorkflow);
109
+ registerEventTrigger(r, idempotentResumeWorkflow);
89
110
  });
90
111
 
91
112
  const noopLogger: JobContext["log"] = {
@@ -274,4 +295,45 @@ describe("workflow-runner resume loop", () => {
274
295
  rowsAfterResume.filter((row) => row["type"] === WORKFLOW_RETRY_SCHEDULED_TYPE),
275
296
  ).toHaveLength(1);
276
297
  });
298
+
299
+ test("a second resume-due-runs tick landing before pending-projection deletes the row resumes exactly once (fw#2552/1)", async () => {
300
+ const runKey = crypto.randomUUID();
301
+ const runId = workflowRunAggregateId(idempotentResumeWorkflow.name, runKey);
302
+ const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
303
+ sideEffectCount = 0;
304
+
305
+ await fireTrigger("rl-test.idempotent-resume", { runKey, forIso: pastIso });
306
+ expect(await pendingRowExists(runId, 0)).toBe(true);
307
+
308
+ await runResumeDueRunsJob();
309
+
310
+ const rowsAfterFirstResume = await loadRunEvents(runId);
311
+ expect(rowsAfterFirstResume.map((row) => row["type"])).toEqual([
312
+ WORKFLOW_RUN_STARTED_TYPE,
313
+ WORKFLOW_WAITING_TYPE,
314
+ WORKFLOW_RESUMED_TYPE,
315
+ WORKFLOW_RUN_COMPLETED_TYPE,
316
+ ]);
317
+ expect(sideEffectCount).toBe(1);
318
+
319
+ // pending-projection.ts only deletes this row on a LATER dispatcher pass
320
+ // reacting to WORKFLOW_RESUMED — deliberately not run here, reproducing
321
+ // the framework#2552/1 race window where a second resume-due-runs tick
322
+ // lands inside that async-delete gap and still finds the row.
323
+ expect(await pendingRowExists(runId, 0)).toBe(true);
324
+
325
+ await runResumeDueRunsJob();
326
+
327
+ const rowsAfterSecondDispatch = await loadRunEvents(runId);
328
+ expect(rowsAfterSecondDispatch).toHaveLength(4);
329
+ expect(
330
+ rowsAfterSecondDispatch.filter((row) => row["type"] === WORKFLOW_RESUMED_TYPE),
331
+ ).toHaveLength(1);
332
+ expect(
333
+ rowsAfterSecondDispatch.filter((row) => row["type"] === WORKFLOW_RUN_COMPLETED_TYPE),
334
+ ).toHaveLength(1);
335
+ // Not just the recorded event log — the step's own side effect (which a
336
+ // buggy second pipeline re-entry would double-run) executed exactly once.
337
+ expect(sideEffectCount).toBe(1);
338
+ });
277
339
  });
@@ -9,9 +9,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
9
  import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
10
10
  import {
11
11
  computeDefinitionFingerprint,
12
+ createSystemUser,
12
13
  defineFeature,
13
14
  defineWorkflow,
14
15
  stepsPipeline,
16
+ WORKFLOW_RESUMED_TYPE,
17
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
15
18
  WORKFLOW_RUN_COMPLETED_TYPE,
16
19
  WORKFLOW_RUN_FAILED_TYPE,
17
20
  WORKFLOW_RUN_STARTED_TYPE,
@@ -19,6 +22,7 @@ import {
19
22
  type WorkflowDefinition,
20
23
  } from "@cosmicdrift/kumiko-framework/engine";
21
24
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
25
+ import { getConsumerState } from "@cosmicdrift/kumiko-framework/pipeline";
22
26
  import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
23
27
  import { workflowRunAggregateId } from "../aggregate-id";
24
28
  import { registerEventTrigger } from "../event-trigger";
@@ -59,10 +63,56 @@ const suspendingWorkflow: WorkflowDefinition = defineWorkflow({
59
63
  ]),
60
64
  });
61
65
 
66
+ // fw#2552/1 regression fixture — counts how often the step AFTER the wait
67
+ // actually runs, keyed by runKey, so a double resume-run call can prove the
68
+ // step executed exactly once instead of just inferring it from event counts.
69
+ const doubleResumeStepRuns = new Map<string, number>();
70
+
71
+ const doubleResumeWorkflow: WorkflowDefinition = defineWorkflow({
72
+ name: "wr-integration-double-resume",
73
+ trigger: { kind: "event", eventType: "wr-test.double-resume" },
74
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
75
+ steps: stepsPipeline(({ r }) => [
76
+ r.step.wait({ for: "PT1H" }),
77
+ r.step.compute("afterResume", (ctx) => {
78
+ const runKey = (ctx.event.payload as { runKey: string }).runKey;
79
+ doubleResumeStepRuns.set(runKey, (doubleResumeStepRuns.get(runKey) ?? 0) + 1);
80
+ return doubleResumeStepRuns.get(runKey);
81
+ }),
82
+ r.step.return({ isSuccess: true, data: undefined }),
83
+ ]),
84
+ });
85
+
86
+ // fw#2552/1 regression fixture (Aufgabe 1 bug) — times: 3 forces TWO
87
+ // retry-scheduled suspensions (attempt 1 and 2 both fail, attempt 3
88
+ // succeeds), so resuming both must each thread a distinct retryAttempt
89
+ // instead of the second resume being swallowed as already-resumed.
90
+ const doubleRetryWorkflow: WorkflowDefinition = defineWorkflow({
91
+ name: "wr-integration-double-retry",
92
+ trigger: { kind: "event", eventType: "wr-test.double-retry" },
93
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
94
+ steps: stepsPipeline(({ r }) => [
95
+ r.step.retry({
96
+ times: 3,
97
+ backoff: "linear",
98
+ do: [
99
+ r.step.compute("gate", (ctx) => {
100
+ const attempt = ctx.workflow?.retryAttempt ?? 1;
101
+ if (attempt < 3) throw new Error(`attempt-${attempt}-fails`);
102
+ return attempt;
103
+ }),
104
+ ],
105
+ }),
106
+ r.step.return({ isSuccess: true, data: undefined }),
107
+ ]),
108
+ });
109
+
62
110
  const testTriggersFeature = defineFeature("workflow-runner-integration-test-triggers", (r) => {
63
111
  registerEventTrigger(r, happyWorkflow);
64
112
  registerEventTrigger(r, failingWorkflow);
65
113
  registerEventTrigger(r, suspendingWorkflow);
114
+ registerEventTrigger(r, doubleResumeWorkflow);
115
+ registerEventTrigger(r, doubleRetryWorkflow);
66
116
  });
67
117
 
68
118
  async function fireTrigger(eventType: string, payload: Record<string, unknown>): Promise<void> {
@@ -89,6 +139,17 @@ async function loadRunEvents(runId: string) {
89
139
  );
90
140
  }
91
141
 
142
+ // Dispatches resume-run directly (SYSTEM_ROLE-gated) instead of going
143
+ // through the resume-due-runs job — resume-run never checks wakeAt, so this
144
+ // resumes a suspended step immediately regardless of its backoff/wait length.
145
+ async function resumeRun(runId: string, stepIndex: number) {
146
+ return stack.dispatcher.write(
147
+ "workflow-runner:write:resume-run",
148
+ { runId, stepIndex },
149
+ createSystemUser(admin.tenantId),
150
+ );
151
+ }
152
+
92
153
  describe("workflow-runner event-trigger", () => {
93
154
  beforeAll(async () => {
94
155
  stack = await setupTestStack({ features: [workflowRunnerFeature, testTriggersFeature] });
@@ -123,8 +184,28 @@ describe("workflow-runner event-trigger", () => {
123
184
  test("error path: a throwing step writes run-failed with the error text, no run-completed", async () => {
124
185
  const runKey = crypto.randomUUID();
125
186
  const runId = workflowRunAggregateId(failingWorkflow.name, runKey);
187
+ // The registrar namespaces every MSP as `<feature>:projection:<name>`.
188
+ const consumerName = `workflow-runner-integration-test-triggers:projection:workflow-${failingWorkflow.name}`;
189
+
190
+ await insertOne(stack.db, eventsTable, {
191
+ aggregateId: crypto.randomUUID(),
192
+ aggregateType: "wr-test-source",
193
+ tenantId: admin.tenantId,
194
+ version: 1,
195
+ type: "wr-test.failure",
196
+ eventVersion: 1,
197
+ payload: { runKey },
198
+ metadata: { userId: admin.id },
199
+ createdBy: admin.id,
200
+ });
126
201
 
127
- await fireTrigger("wr-test.failure", { runKey });
202
+ // fw#2482/2: run-failed is durably recorded, so the dispatcher must count
203
+ // the trigger event as delivered — not as a poison event to keep
204
+ // retrying (which would re-run every side-effect step until dead-letter).
205
+ const firstPass = await stack.eventDispatcher?.runOnce();
206
+ expect(firstPass?.byConsumer[consumerName]).toEqual({ processed: 1, failed: 0 });
207
+ const stateAfterFirstPass = await getConsumerState(stack.db, consumerName);
208
+ expect(stateAfterFirstPass?.status).toBe("idle");
128
209
 
129
210
  const rows = await loadRunEvents(runId);
130
211
  expect(rows.map((row) => row["type"])).toEqual([
@@ -133,6 +214,16 @@ describe("workflow-runner event-trigger", () => {
133
214
  ]);
134
215
  expect(rows[1]!["payload"]).toMatchObject({ workflowName: failingWorkflow.name, stepIndex: 0 });
135
216
  expect(String(rows[1]!["payload"]["error"])).toContain("boom-explicit-failure");
217
+
218
+ // Second pass has nothing left to redeliver — the same trigger event
219
+ // never spawns a second run-started for this runId. `processed` counts
220
+ // every event the cursor walks past (the run-started/run-failed rows the
221
+ // first pass appended included), so the redelivery invariant is asserted
222
+ // on the run stream itself plus a still-clean failure count.
223
+ const secondPass = await stack.eventDispatcher?.runOnce();
224
+ expect(secondPass?.byConsumer[consumerName]?.failed).toBe(0);
225
+ expect(await loadRunEvents(runId)).toHaveLength(2);
226
+ expect(await getConsumerState(stack.db, consumerName)).toMatchObject({ status: "idle" });
136
227
  });
137
228
 
138
229
  test("suspension: a wait step suspends the run instead of failing it — resumable by framework#2513 Phase 2, no run-completed yet", async () => {
@@ -147,4 +238,57 @@ describe("workflow-runner event-trigger", () => {
147
238
  WORKFLOW_WAITING_TYPE,
148
239
  ]);
149
240
  });
241
+
242
+ test("fw#2552/1: a second resume-run for the same (runId, stepIndex) before pending-projection deletes the row reports already-resumed and never re-runs the step", async () => {
243
+ const runKey = crypto.randomUUID();
244
+ const runId = workflowRunAggregateId(doubleResumeWorkflow.name, runKey);
245
+
246
+ await fireTrigger("wr-test.double-resume", { runKey });
247
+ // pending-projection observes the suspension on a pass after it exists.
248
+ await stack.eventDispatcher?.runOnce();
249
+
250
+ const first = await resumeRun(runId, 0);
251
+ // No runOnce() in between — pending-projection has not yet deleted the
252
+ // pending row, so the second call still finds it (the race window).
253
+ const second = await resumeRun(runId, 0);
254
+
255
+ expect(first).toMatchObject({ isSuccess: true, data: { outcome: "completed" } });
256
+ expect(second).toMatchObject({ isSuccess: true, data: { outcome: "already-resumed" } });
257
+ expect(doubleResumeStepRuns.get(runKey)).toBe(1);
258
+
259
+ const rows = await loadRunEvents(runId);
260
+ expect(rows.map((row) => row["type"])).toEqual([
261
+ WORKFLOW_RUN_STARTED_TYPE,
262
+ WORKFLOW_WAITING_TYPE,
263
+ WORKFLOW_RESUMED_TYPE,
264
+ WORKFLOW_RUN_COMPLETED_TYPE,
265
+ ]);
266
+ });
267
+
268
+ test("fw#2552/1 regression: a step retried twice gets both retry attempts, the second resume is not swallowed as already-resumed", async () => {
269
+ const runKey = crypto.randomUUID();
270
+ const runId = workflowRunAggregateId(doubleRetryWorkflow.name, runKey);
271
+
272
+ await fireTrigger("wr-test.double-retry", { runKey });
273
+ await stack.eventDispatcher?.runOnce(); // pending-projection materialises attempt 1's row
274
+
275
+ const firstResume = await resumeRun(runId, 0);
276
+ expect(firstResume).toMatchObject({ isSuccess: true, data: { outcome: "suspended" } });
277
+
278
+ await stack.eventDispatcher?.runOnce(); // pending-projection materialises attempt 2's row
279
+
280
+ const secondResume = await resumeRun(runId, 0);
281
+ expect(secondResume).toMatchObject({ isSuccess: true, data: { outcome: "completed" } });
282
+
283
+ const rows = await loadRunEvents(runId);
284
+ expect(rows.map((row) => row["type"])).toEqual([
285
+ WORKFLOW_RUN_STARTED_TYPE,
286
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
287
+ WORKFLOW_RESUMED_TYPE,
288
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
289
+ WORKFLOW_RESUMED_TYPE,
290
+ WORKFLOW_RUN_COMPLETED_TYPE,
291
+ ]);
292
+ expect(rows.filter((row) => row["type"] === WORKFLOW_RESUMED_TYPE)).toHaveLength(2);
293
+ });
150
294
  });
@@ -4,10 +4,10 @@
4
4
  // Registration pattern:
5
5
  // registerEventTrigger(r, myWorkflow)
6
6
  //
7
- // The MSP apply-fn runs in the dispatcher's own tx, so `workflow.run-started`
8
- // plus the synchronous portion of the pipeline land atomically. Any throw
9
- // from startAndRunWorkflow is recorded as `workflow.run-failed` and
10
- // rethrown so the dispatcher's retry/dead-letter handling still applies.
7
+ // The apply-fn does NOT run in the dispatcher's cursor tx (the server's MSP
8
+ // wiring uses the unbound pool), so `workflow.run-started` and the rest of
9
+ // the pipeline are not atomic. A throw from startAndRunWorkflow is recorded
10
+ // as `workflow.run-failed` and swallowed, so the dispatcher moves on.
11
11
 
12
12
  import type {
13
13
  FeatureRegistrar,
@@ -43,6 +43,9 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
43
43
 
44
44
  r.multiStreamProjection({
45
45
  name: `workflow-${workflow.name}`,
46
+ // Mounting an event-triggered workflow into an existing app must not
47
+ // replay the historical log and fire every side-effect step retroactively.
48
+ startFrom: "now",
46
49
  apply: {
47
50
  [eventType]: async (event, _tx, ctx) => {
48
51
  // skip: unreachable — the guard above already established this, but the
@@ -97,7 +100,10 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
97
100
  type: WORKFLOW_RUN_FAILED_TYPE,
98
101
  payload: failedPayload,
99
102
  });
100
- throw error;
103
+ // No rethrow: the failure is now durably recorded as
104
+ // workflow.run-failed. Rethrowing would make the dispatcher
105
+ // redeliver the same trigger event up to maxAttempts, re-running
106
+ // every side-effect step and eventually killing the consumer.
101
107
  }
102
108
  },
103
109
  },