@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -0,0 +1,1731 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/workflow-engine. Do not edit. -->
2
+
3
+ # workflow-engine
4
+
5
+ M.4 Tier-3 step-vocabulary showcase: defineWorkflow with wait/waitForEvent/retry steps
6
+
7
+ ## README
8
+
9
+ # Workflow Engine
10
+
11
+ Tier-3 `defineWorkflow` vocabulary: wait, branch, mail, webhook.
12
+
13
+ ## What it shows
14
+
15
+ - Real runnable pipelines (no empty `build: () => []` stubs)
16
+ - Workflow-run lifecycle across wait / waitForEvent
17
+ - `webhook.send` is delivered once by the step-dispatcher; a failed delivery ends as `step.dispatch-failed`. Wrapping it in `r.step.retry` would not repeat it, because the step only enqueues the request and never throws on a delivery error
18
+
19
+ ## Source
20
+
21
+ Feature entry point: `src/feature.ts`.
22
+
23
+ ## Tests
24
+
25
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
26
+
27
+ ```bash
28
+ cd samples/recipes/workflow-engine
29
+ bun test
30
+ ```
31
+
32
+ ## Source files
33
+
34
+ ### src/__tests__/workflow-integration.integration.test.ts
35
+
36
+ ```ts
37
+ // Workflow-engine event-store integration test.
38
+ //
39
+ // Verifies the Postgres-backed fetcher against real rows:
40
+ // - finds expired WAITING events
41
+ // - hydrates SuspendableRun with the trigger-event snapshot + Q7
42
+ // definitionFingerprint from the event payload
43
+ // - returns a no-op for fingerprint-less legacy rows
44
+ //
45
+ // End-to-end MSP-trigger → workflow execution is a separate followup
46
+ // (requires wiring a dispatcher-fire-able event-shape into the test
47
+ // stack); the unit-test file covers the Pipeline/resume-loop logic
48
+ // against the in-memory fetcher.
49
+
50
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
51
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
52
+ import type { WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
53
+ import {
54
+ computeDefinitionFingerprint,
55
+ defineWorkflow,
56
+ stepsPipeline,
57
+ WORKFLOW_AGGREGATE_TYPE,
58
+ WORKFLOW_RESUMED_TYPE,
59
+ WORKFLOW_RUN_STARTED_TYPE,
60
+ WORKFLOW_WAITING_TYPE,
61
+ } from "@cosmicdrift/kumiko-framework/engine";
62
+ import { append, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
63
+ import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
64
+ import { createSuspendedRunFetcher } from "../postgres-resume-loop";
65
+
66
+ let stack: TestStack;
67
+ const admin = TestUsers.admin;
68
+
69
+ function buildTestWorkflow(name: string): WorkflowDefinition {
70
+ return defineWorkflow({
71
+ name,
72
+ trigger: { kind: "event", eventType: "demo.fired" },
73
+ steps: stepsPipeline(({ r }) => [
74
+ r.step.wait({ for: "PT1H" }),
75
+ r.step.return({ isSuccess: true, data: undefined }),
76
+ ]),
77
+ });
78
+ }
79
+
80
+ describe("workflow-engine event-store roundtrip", () => {
81
+ beforeAll(async () => {
82
+ stack = await setupTestStack({ features: [] });
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await stack.cleanup();
87
+ });
88
+
89
+ test("write and read WORKFLOW_WAITING_TYPE via event store", async () => {
90
+ const runId = crypto.randomUUID();
91
+
92
+ await insertOne(stack.db, eventsTable, {
93
+ aggregateId: runId,
94
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
95
+ tenantId: admin.tenantId,
96
+ version: 1,
97
+ type: WORKFLOW_RUN_STARTED_TYPE,
98
+ eventVersion: 1,
99
+ payload: { workflowName: "test-workflow", triggerEventType: "demo.fired" },
100
+ metadata: { userId: admin.id },
101
+ createdBy: admin.id,
102
+ });
103
+
104
+ await insertOne(stack.db, eventsTable, {
105
+ aggregateId: runId,
106
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
107
+ tenantId: admin.tenantId,
108
+ version: 2,
109
+ type: WORKFLOW_WAITING_TYPE,
110
+ eventVersion: 1,
111
+ payload: {
112
+ workflowName: "test-workflow",
113
+ stepIndex: 2,
114
+ wakeAt: new Date(Date.now() - 5000).toISOString(),
115
+ },
116
+ metadata: { userId: admin.id },
117
+ createdBy: admin.id,
118
+ });
119
+
120
+ const rows = await selectMany(
121
+ stack.db,
122
+ eventsTable,
123
+ { aggregateId: runId },
124
+ { orderBy: { col: "version", direction: "asc" } },
125
+ );
126
+
127
+ expect(rows).toHaveLength(2);
128
+ expect(rows[0]!["type"]).toBe(WORKFLOW_RUN_STARTED_TYPE);
129
+ expect(rows[1]!["type"]).toBe(WORKFLOW_WAITING_TYPE);
130
+ expect(rows[1]!["payload"]["workflowName"]).toBe("test-workflow");
131
+ expect(rows[1]!["payload"]["stepIndex"]).toBe(2);
132
+ });
133
+
134
+ test("fetcher hydrates SuspendableRun with workflow + trigger snapshot + Q7 fingerprint", async () => {
135
+ const runId = crypto.randomUUID();
136
+ const workflow = buildTestWorkflow("test-workflow-hydration");
137
+ const fingerprint = computeDefinitionFingerprint(workflow);
138
+
139
+ const sourceAggregateId = crypto.randomUUID();
140
+ const source = await append(stack.db, {
141
+ aggregateId: sourceAggregateId,
142
+ aggregateType: "demo-source",
143
+ tenantId: admin.tenantId,
144
+ expectedVersion: 0,
145
+ type: "demo.fired",
146
+ payload: { signal: "abc" },
147
+ metadata: { userId: admin.id },
148
+ });
149
+ await append(stack.db, {
150
+ aggregateId: runId,
151
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
152
+ tenantId: admin.tenantId,
153
+ expectedVersion: 0,
154
+ type: WORKFLOW_RUN_STARTED_TYPE,
155
+ payload: {
156
+ workflowName: "test-workflow-hydration",
157
+ triggerEventType: "demo.fired",
158
+ triggerEventRef: { eventId: source.id, aggregateId: sourceAggregateId, version: 1 },
159
+ definitionFingerprint: fingerprint,
160
+ },
161
+ metadata: { userId: admin.id },
162
+ });
163
+ await append(stack.db, {
164
+ aggregateId: runId,
165
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
166
+ tenantId: admin.tenantId,
167
+ expectedVersion: 1,
168
+ type: WORKFLOW_WAITING_TYPE,
169
+ payload: {
170
+ workflowName: "test-workflow-hydration",
171
+ stepIndex: 1,
172
+ wakeAt: new Date(Date.now() - 5000).toISOString(),
173
+ definitionFingerprint: fingerprint,
174
+ },
175
+ metadata: { userId: admin.id },
176
+ });
177
+
178
+ const registry = new Map([["test-workflow-hydration", workflow]]);
179
+ const fetchRuns = createSuspendedRunFetcher(stack.db, registry);
180
+ const suspended = await fetchRuns();
181
+
182
+ const match = suspended.find((r) => r.runId === runId);
183
+ expect(match).toBeDefined();
184
+ expect(match!.workflowName).toBe("test-workflow-hydration");
185
+ expect(match!.stepIndex).toBe(1);
186
+ expect(match!.workflow).toBe(workflow);
187
+ expect(match!.definitionFingerprint).toBe(fingerprint);
188
+ expect(match!.triggerEvent).toMatchObject({
189
+ type: "demo.fired",
190
+ aggregateId: sourceAggregateId,
191
+ payload: { signal: "abc" },
192
+ });
193
+ });
194
+
195
+ test("fetcher skips workflows that are not in the registry", async () => {
196
+ const runId = crypto.randomUUID();
197
+
198
+ await insertOne(stack.db, eventsTable, {
199
+ aggregateId: runId,
200
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
201
+ tenantId: admin.tenantId,
202
+ version: 1,
203
+ type: WORKFLOW_WAITING_TYPE,
204
+ eventVersion: 1,
205
+ payload: {
206
+ workflowName: "unregistered-workflow",
207
+ stepIndex: 0,
208
+ wakeAt: new Date(Date.now() - 5000).toISOString(),
209
+ },
210
+ metadata: { userId: admin.id },
211
+ createdBy: admin.id,
212
+ });
213
+
214
+ const fetchRuns = createSuspendedRunFetcher(stack.db, new Map());
215
+ const suspended = await fetchRuns();
216
+
217
+ expect(suspended.find((r) => r.runId === runId)).toBeUndefined();
218
+ });
219
+
220
+ test("WORKFLOW_RESUMED_TYPE appends after WAITING on the same stream", async () => {
221
+ const runId = crypto.randomUUID();
222
+
223
+ await insertOne(stack.db, eventsTable, {
224
+ aggregateId: runId,
225
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
226
+ tenantId: admin.tenantId,
227
+ version: 1,
228
+ type: WORKFLOW_WAITING_TYPE,
229
+ eventVersion: 1,
230
+ payload: {
231
+ workflowName: "test-workflow",
232
+ stepIndex: 2,
233
+ wakeAt: new Date(Date.now() - 5000).toISOString(),
234
+ },
235
+ metadata: { userId: admin.id },
236
+ createdBy: admin.id,
237
+ });
238
+
239
+ await insertOne(stack.db, eventsTable, {
240
+ aggregateId: runId,
241
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
242
+ tenantId: admin.tenantId,
243
+ version: 2,
244
+ type: WORKFLOW_RESUMED_TYPE,
245
+ eventVersion: 1,
246
+ payload: { stepIndex: 2 },
247
+ metadata: { userId: admin.id },
248
+ createdBy: admin.id,
249
+ });
250
+
251
+ const rows = await selectMany(
252
+ stack.db,
253
+ eventsTable,
254
+ { aggregateId: runId },
255
+ { orderBy: { col: "version", direction: "asc" } },
256
+ );
257
+
258
+ expect(rows).toHaveLength(2);
259
+ expect(rows[0]!["type"]).toBe(WORKFLOW_WAITING_TYPE);
260
+ expect(rows[1]!["type"]).toBe(WORKFLOW_RESUMED_TYPE);
261
+ });
262
+ });
263
+ ```
264
+
265
+ ### src/__tests__/workflow-integration.test.ts
266
+
267
+ ```ts
268
+ import { describe, expect, it, mock } from "bun:test";
269
+ import {
270
+ computeDefinitionFingerprint,
271
+ defineWorkflow,
272
+ STEP_DISPATCH_REQUESTED_TYPE,
273
+ stepsPipeline,
274
+ type WorkflowDefinition,
275
+ } from "@cosmicdrift/kumiko-framework/engine";
276
+ import { VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
277
+ import { type CronWorkflow, nextCronDate, runDueCronWorkflows } from "../cron-scheduler";
278
+ import { registerEventTrigger } from "../event-trigger";
279
+ import {
280
+ dailyReportWorkflow,
281
+ userOnboardingSteps,
282
+ userOnboardingWorkflow,
283
+ webhookDeliveryWorkflow,
284
+ } from "../feature";
285
+ import { createInMemorySuspendedRunFetcher } from "../postgres-resume-loop";
286
+ import { runResumeLoop, type SuspendableRun } from "../resume-loop";
287
+ import { startAndRunWorkflow } from "../workflow-runner";
288
+
289
+ function makeWaitWorkflow(name: string) {
290
+ return defineWorkflow({
291
+ name,
292
+ trigger: { kind: "event", eventType: "demo.fired" },
293
+ steps: stepsPipeline(({ r }) => [
294
+ r.step.wait({ for: "PT1H" }),
295
+ r.step.return({ isSuccess: true, data: { result: "resumed" } }),
296
+ ]),
297
+ });
298
+ }
299
+
300
+ function makeReturnOnlyWorkflow(name: string) {
301
+ return defineWorkflow({
302
+ name,
303
+ trigger: { kind: "event", eventType: "demo.fired" },
304
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
305
+ });
306
+ }
307
+
308
+ describe("workflow-engine", () => {
309
+ it("defineWorkflow returns a valid WorkflowDefinition", () => {
310
+ expect(userOnboardingWorkflow.__kind).toBe("workflow");
311
+ expect(userOnboardingWorkflow.name).toBe("user-onboarding");
312
+ const trigger = userOnboardingWorkflow.trigger;
313
+ if (trigger.kind !== "event") throw new Error("expected event trigger");
314
+ expect(trigger.eventType).toBe("user.signed-up");
315
+ expect(typeof userOnboardingWorkflow.idempotencyKey).toBe("function");
316
+ });
317
+
318
+ it("defines a webhook-delivery workflow with event trigger", () => {
319
+ expect(webhookDeliveryWorkflow.__kind).toBe("workflow");
320
+ expect(webhookDeliveryWorkflow.name).toBe("webhook-delivery");
321
+ const trigger = webhookDeliveryWorkflow.trigger;
322
+ if (trigger.kind !== "event") throw new Error("expected event trigger");
323
+ expect(trigger.eventType).toBe("data.processed");
324
+ });
325
+
326
+ it("webhook-delivery completes with exactly one dispatch request", async () => {
327
+ const ctx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
328
+
329
+ const result = await startAndRunWorkflow({
330
+ runId: "wf-webhook-delivery-1",
331
+ workflow: webhookDeliveryWorkflow as unknown as WorkflowDefinition,
332
+ triggerEvent: {
333
+ aggregateId: "agg_1",
334
+ type: "data.processed",
335
+ payload: { data: { id: 7 }, webhookUrl: "https://hooks.example.com/in" },
336
+ } as never,
337
+ handlerCtx: ctx as never,
338
+ });
339
+
340
+ expect(result.outcome).toBe("completed");
341
+ const dispatchRequests = ctx.unsafeAppendEvent.mock.calls
342
+ .map(([event]) => event as { type: string; payload: Record<string, unknown> })
343
+ .filter((event) => event.type === STEP_DISPATCH_REQUESTED_TYPE);
344
+ expect(dispatchRequests).toHaveLength(1);
345
+ expect(dispatchRequests[0]?.payload["url"]).toBe("https://hooks.example.com/in");
346
+ expect(dispatchRequests[0]?.payload["bodyJson"]).toBe(JSON.stringify({ id: 7 }));
347
+ });
348
+
349
+ it("defines a daily-report workflow with cron trigger", () => {
350
+ expect(dailyReportWorkflow.__kind).toBe("workflow");
351
+ expect(dailyReportWorkflow.name).toBe("daily-report");
352
+ const trigger = dailyReportWorkflow.trigger;
353
+ if (trigger.kind !== "cron") throw new Error("expected cron trigger");
354
+ expect(trigger.schedule).toBe("0 9 * * *");
355
+ });
356
+
357
+ it("workflow stores an idempotency key resolver for event-triggered workflows", () => {
358
+ const key = (
359
+ userOnboardingWorkflow.idempotencyKey as (e: { payload: { userId: string } }) => string
360
+ )({
361
+ payload: { userId: "user_123" },
362
+ });
363
+ expect(key).toBe("onboarding:user_123");
364
+ });
365
+ });
366
+
367
+ describe("workflow-runner", () => {
368
+ function makeAppendOnlyCtx() {
369
+ return { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
370
+ }
371
+
372
+ it("startAndRunWorkflow writes run.started with Q7 snapshot fingerprint, then runs the pipeline to completion when no suspension", async () => {
373
+ const workflow = defineWorkflow({
374
+ name: "demo-sync",
375
+ trigger: { kind: "event", eventType: "demo.fired" },
376
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
377
+ });
378
+ const ctx = makeAppendOnlyCtx();
379
+
380
+ const result = await startAndRunWorkflow({
381
+ runId: "wf-demo-sync-1",
382
+ workflow,
383
+ triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: {} } as never,
384
+ handlerCtx: ctx as never,
385
+ });
386
+
387
+ expect(result.outcome).toBe("completed");
388
+ expect(ctx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
389
+ const started = ctx.unsafeAppendEvent.mock.calls[0]![0] as {
390
+ type: string;
391
+ payload: Record<string, unknown>;
392
+ };
393
+ expect(started.type).toBe("kumiko:system:workflow.run-started");
394
+ expect(started.payload["definitionFingerprint"]).toBe(computeDefinitionFingerprint(workflow));
395
+ expect(started.payload["triggerEventType"]).toBe("demo.fired");
396
+ const completed = ctx.unsafeAppendEvent.mock.calls[1]![0] as { type: string };
397
+ expect(completed.type).toBe("kumiko:system:workflow.run-completed");
398
+ });
399
+
400
+ it("startAndRunWorkflow returns outcome=suspended and stamps fingerprint into the waiting event", async () => {
401
+ const workflow = defineWorkflow({
402
+ name: "demo-wait",
403
+ trigger: { kind: "event", eventType: "demo.fired" },
404
+ steps: stepsPipeline(({ r }) => [
405
+ r.step.wait({ for: "PT1H" }),
406
+ r.step.return({ isSuccess: true, data: undefined }),
407
+ ]),
408
+ });
409
+ const ctx = makeAppendOnlyCtx();
410
+
411
+ const result = await startAndRunWorkflow({
412
+ runId: "wf-demo-wait-1",
413
+ workflow,
414
+ triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: {} } as never,
415
+ handlerCtx: ctx as never,
416
+ });
417
+
418
+ expect(result.outcome).toBe("suspended");
419
+ // run.started + workflow.step.waiting — no run-completed yet
420
+ expect(ctx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
421
+ const waiting = ctx.unsafeAppendEvent.mock.calls[1]![0] as {
422
+ type: string;
423
+ payload: Record<string, unknown>;
424
+ };
425
+ expect(waiting.type).toBe("kumiko:system:workflow.step.waiting");
426
+ expect(waiting.payload["definitionFingerprint"]).toBe(computeDefinitionFingerprint(workflow));
427
+ });
428
+
429
+ it("idempotencyKey ends up in the run.started payload when provided", async () => {
430
+ const workflow = defineWorkflow({
431
+ name: "demo-idem",
432
+ trigger: { kind: "event", eventType: "demo.fired" },
433
+ idempotencyKey: ({ payload }) => `id:${(payload as { x: string }).x}`,
434
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
435
+ });
436
+ const ctx = makeAppendOnlyCtx();
437
+
438
+ await startAndRunWorkflow({
439
+ runId: "wf-demo-idem-1",
440
+ workflow,
441
+ triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: { x: "abc" } } as never,
442
+ idempotencyKey: "id:abc",
443
+ handlerCtx: ctx as never,
444
+ });
445
+
446
+ const started = ctx.unsafeAppendEvent.mock.calls[0]![0] as { payload: Record<string, unknown> };
447
+ expect(started.payload["idempotencyKey"]).toBe("id:abc");
448
+ });
449
+ });
450
+
451
+ describe("userOnboardingWorkflow (end-to-end, in-memory)", () => {
452
+ it("suspends on the first wait, then resume-loop runs it through to completion", async () => {
453
+ const ctx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
454
+ const trigger = {
455
+ aggregateId: "agg_user_42",
456
+ type: "user.signed-up",
457
+ payload: { email: "x@example.com", userId: "active-42" },
458
+ };
459
+
460
+ const result = await startAndRunWorkflow({
461
+ runId: "wf-onboarding-active-42",
462
+ workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
463
+ triggerEvent: trigger as never,
464
+ triggerEventRef: { eventId: "1", aggregateId: trigger.aggregateId, version: 1 },
465
+ idempotencyKey: "onboarding:active-42",
466
+ handlerCtx: ctx as never,
467
+ });
468
+
469
+ expect(result.outcome).toBe("suspended");
470
+ // Calls: run.started, mail.send dispatch-requested, workflow.step.waiting
471
+ expect(ctx.unsafeAppendEvent).toHaveBeenCalledTimes(3);
472
+ const startedCall = ctx.unsafeAppendEvent.mock.calls[0]![0] as {
473
+ type: string;
474
+ payload: Record<string, unknown>;
475
+ };
476
+ const mailCall = ctx.unsafeAppendEvent.mock.calls[1]![0] as {
477
+ type: string;
478
+ payload: Record<string, unknown>;
479
+ };
480
+ const waitingCall = ctx.unsafeAppendEvent.mock.calls[2]![0] as {
481
+ type: string;
482
+ payload: Record<string, unknown>;
483
+ };
484
+ expect(startedCall.type).toBe("kumiko:system:workflow.run-started");
485
+ expect(mailCall.type).toBe("kumiko:system:step.dispatch-requested");
486
+ expect((mailCall.payload as { stepKind: string }).stepKind).toBe("mail.send");
487
+ expect(waitingCall.type).toBe("kumiko:system:workflow.step.waiting");
488
+ expect(waitingCall.payload["definitionFingerprint"]).toBe(
489
+ computeDefinitionFingerprint(userOnboardingWorkflow as unknown as WorkflowDefinition),
490
+ );
491
+
492
+ // The run stream holds a reference, never a copy of the trigger payload.
493
+ expect(startedCall.payload["triggerEventRef"]).toEqual({
494
+ eventId: "1",
495
+ aggregateId: "agg_user_42",
496
+ version: 1,
497
+ });
498
+ expect(startedCall.payload).not.toHaveProperty("triggerPayload");
499
+ expect(waitingCall.payload).not.toHaveProperty("triggerPayload");
500
+
501
+ // Re-feed the run through the resume-loop using the fingerprint the
502
+ // waiting event carries — the loop runs the rest of the pipeline. The
503
+ // trigger event is what the event store returns for the reference.
504
+ const triggerSnapshot = trigger;
505
+ const suspendedRun: SuspendableRun = {
506
+ runId: "wf-onboarding-active-42",
507
+ workflowName: "user-onboarding",
508
+ stepIndex: waitingCall.payload["stepIndex"] as number,
509
+ wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
510
+ suspensionEventType: "kumiko:system:workflow.step.waiting",
511
+ workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
512
+ triggerEvent: triggerSnapshot as never,
513
+ definitionFingerprint: waitingCall.payload["definitionFingerprint"] as string,
514
+ };
515
+
516
+ const resumeCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
517
+ const count = await runResumeLoop(
518
+ createInMemorySuspendedRunFetcher([suspendedRun]),
519
+ resumeCtx as never,
520
+ );
521
+
522
+ expect(count).toBe(1);
523
+ // Resume sequence: RESUMED, branch fires the tips-mail (engaged=true
524
+ // because userId starts with "active-"), second WAITING for P7D.
525
+ // The run is suspended again before the webhook, so no RUN_COMPLETED
526
+ // yet — this is one resume-cycle, not the whole workflow.
527
+ const resumeTypes = resumeCtx.unsafeAppendEvent.mock.calls.map(
528
+ (c) => (c[0] as { type: string }).type,
529
+ );
530
+ expect(resumeTypes).toEqual([
531
+ "kumiko:system:workflow.step.resumed",
532
+ "kumiko:system:step.dispatch-requested",
533
+ "kumiko:system:workflow.step.waiting",
534
+ ]);
535
+ const tipsMail = resumeCtx.unsafeAppendEvent.mock.calls[1]![0] as {
536
+ payload: { subject: string };
537
+ };
538
+ expect(tipsMail.payload.subject).toBe("Engagement tips");
539
+ });
540
+
541
+ it("not-engaged path picks the reminder branch", async () => {
542
+ const ctx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
543
+ const trigger = {
544
+ aggregateId: "agg_user_cold",
545
+ type: "user.signed-up",
546
+ payload: { email: "cold@example.com", userId: "cold-1" },
547
+ };
548
+ await startAndRunWorkflow({
549
+ runId: "wf-onboarding-cold-1",
550
+ workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
551
+ triggerEvent: trigger as never,
552
+ triggerEventRef: { eventId: "2", aggregateId: trigger.aggregateId, version: 1 },
553
+ handlerCtx: ctx as never,
554
+ });
555
+ const waiting = ctx.unsafeAppendEvent.mock.calls[2]![0] as { payload: Record<string, unknown> };
556
+ const suspendedRun: SuspendableRun = {
557
+ runId: "wf-onboarding-cold-1",
558
+ workflowName: "user-onboarding",
559
+ stepIndex: waiting.payload["stepIndex"] as number,
560
+ wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
561
+ suspensionEventType: "kumiko:system:workflow.step.waiting",
562
+ workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
563
+ triggerEvent: trigger as never,
564
+ definitionFingerprint: waiting.payload["definitionFingerprint"] as string,
565
+ };
566
+ const resumeCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
567
+ await runResumeLoop(createInMemorySuspendedRunFetcher([suspendedRun]), resumeCtx as never);
568
+
569
+ const reminderMail = resumeCtx.unsafeAppendEvent.mock.calls[1]![0] as {
570
+ payload: { subject: string };
571
+ };
572
+ expect(reminderMail.payload.subject).toBe("Getting started");
573
+ });
574
+ });
575
+
576
+ describe("computeDefinitionFingerprint", () => {
577
+ it("returns the same fingerprint for the same definition", () => {
578
+ const a = defineWorkflow({
579
+ name: "wf",
580
+ trigger: { kind: "event", eventType: "x" },
581
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
582
+ });
583
+ const b = defineWorkflow({
584
+ name: "wf",
585
+ trigger: { kind: "event", eventType: "x" },
586
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
587
+ });
588
+ expect(computeDefinitionFingerprint(a)).toBe(computeDefinitionFingerprint(b));
589
+ });
590
+
591
+ it("returns a different fingerprint when the pipeline source changes", () => {
592
+ const a = defineWorkflow({
593
+ name: "wf",
594
+ trigger: { kind: "event", eventType: "x" },
595
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
596
+ });
597
+ const b = defineWorkflow({
598
+ name: "wf",
599
+ trigger: { kind: "event", eventType: "x" },
600
+ steps: stepsPipeline(({ r }) => [
601
+ r.step.compute("noop", () => 1),
602
+ r.step.return({ isSuccess: true, data: undefined }),
603
+ ]),
604
+ });
605
+ expect(computeDefinitionFingerprint(a)).not.toBe(computeDefinitionFingerprint(b));
606
+ });
607
+
608
+ it("returns a different fingerprint when the trigger changes", () => {
609
+ const a = defineWorkflow({
610
+ name: "wf",
611
+ trigger: { kind: "event", eventType: "x" },
612
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
613
+ });
614
+ const b = defineWorkflow({
615
+ name: "wf",
616
+ trigger: { kind: "event", eventType: "y" },
617
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
618
+ });
619
+ expect(computeDefinitionFingerprint(a)).not.toBe(computeDefinitionFingerprint(b));
620
+ });
621
+ });
622
+
623
+ describe("event-trigger", () => {
624
+ it("registerEventTrigger creates MSP for event-kind workflows", () => {
625
+ const registrations: unknown[] = [];
626
+ const mockRegistrar = {
627
+ multiStreamProjection: (def: unknown) => {
628
+ registrations.push(def);
629
+ },
630
+ };
631
+
632
+ registerEventTrigger(
633
+ mockRegistrar as never,
634
+ userOnboardingWorkflow as unknown as WorkflowDefinition,
635
+ );
636
+
637
+ expect(registrations).toHaveLength(1);
638
+ const msp = registrations[0] as Record<string, unknown>;
639
+ expect(msp["name"]).toBe("workflow-user-onboarding");
640
+ expect(typeof (msp["apply"] as Record<string, unknown>)["user.signed-up"]).toBe("function");
641
+ });
642
+
643
+ it("registerEventTrigger skips non-event workflows", () => {
644
+ const registrations: unknown[] = [];
645
+ const mockRegistrar = {
646
+ multiStreamProjection: (def: unknown) => {
647
+ registrations.push(def);
648
+ },
649
+ };
650
+
651
+ registerEventTrigger(
652
+ mockRegistrar as never,
653
+ dailyReportWorkflow as unknown as WorkflowDefinition,
654
+ );
655
+
656
+ expect(registrations).toHaveLength(0);
657
+ });
658
+
659
+ it("userOnboardingSteps is a valid pipeline", () => {
660
+ expect(userOnboardingSteps.__kind).toBe("pipeline");
661
+ expect(typeof userOnboardingSteps.build).toBe("function");
662
+ });
663
+ });
664
+
665
+ describe("cron-scheduler", () => {
666
+ it("nextCronDate computes the next run time", () => {
667
+ const base = Temporal.Instant.from("2025-06-01T00:00:00Z");
668
+ const next = nextCronDate("30 9 * * *", base);
669
+ expect(next).not.toBeNull();
670
+ const zdt = next!.toZonedDateTimeISO("UTC");
671
+ expect(zdt.hour).toBe(9);
672
+ expect(zdt.minute).toBe(30);
673
+ expect(zdt.day).toBe(1);
674
+ });
675
+
676
+ it("nextCronDate returns null for invalid cron expressions", () => {
677
+ expect(nextCronDate("invalid", Temporal.Now.instant())).toBeNull();
678
+ expect(nextCronDate("", Temporal.Now.instant())).toBeNull();
679
+ });
680
+
681
+ it("nextCronDate advances to next day if time already passed", () => {
682
+ const base = Temporal.Instant.from("2025-06-01T10:00:00Z");
683
+ const next = nextCronDate("30 9 * * *", base);
684
+ const zdt = next!.toZonedDateTimeISO("UTC");
685
+ // 09:30 already passed on June 1 → next is June 2 at 09:30
686
+ expect(zdt.day).toBe(2);
687
+ expect(zdt.hour).toBe(9);
688
+ expect(zdt.minute).toBe(30);
689
+ });
690
+
691
+ it("runDueCronWorkflows skips not-yet-due workflows", async () => {
692
+ const wf = defineWorkflow({
693
+ name: "test-daily",
694
+ trigger: { kind: "cron", schedule: "0 9 * * *" },
695
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
696
+ });
697
+
698
+ const handlerCtx = { unsafeAppendEvent: mock() };
699
+ const lastRuns = new Map<string, Temporal.Instant>();
700
+ const now = Temporal.Instant.from("2025-06-01T08:00:00Z");
701
+
702
+ const count = await runDueCronWorkflows(
703
+ [wf as CronWorkflow],
704
+ lastRuns,
705
+ now,
706
+ handlerCtx as never,
707
+ );
708
+
709
+ expect(count).toBe(0);
710
+ expect(handlerCtx.unsafeAppendEvent).not.toHaveBeenCalled();
711
+ });
712
+
713
+ it("runDueCronWorkflows runs due workflows through the full start path", async () => {
714
+ const wf = defineWorkflow({
715
+ name: "test-daily",
716
+ trigger: { kind: "cron", schedule: "0 9 * * *" },
717
+ steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
718
+ });
719
+
720
+ const handlerCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
721
+ const lastRuns = new Map<string, Temporal.Instant>();
722
+ const lastRun = Temporal.Instant.from("2025-05-31T08:00:00Z");
723
+ lastRuns.set("test-daily", lastRun);
724
+ const now = Temporal.Instant.from("2025-06-01T09:01:00Z");
725
+
726
+ const count = await runDueCronWorkflows(
727
+ [wf as CronWorkflow],
728
+ lastRuns,
729
+ now,
730
+ handlerCtx as never,
731
+ );
732
+
733
+ expect(count).toBe(1);
734
+ // run.started + run-completed (no suspension, pipeline returns immediately).
735
+ expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
736
+ const started = handlerCtx.unsafeAppendEvent.mock.calls[0]![0] as Record<string, unknown>;
737
+ expect(started["type"]).toBe("kumiko:system:workflow.run-started");
738
+ const completed = handlerCtx.unsafeAppendEvent.mock.calls[1]![0] as Record<string, unknown>;
739
+ expect(completed["type"]).toBe("kumiko:system:workflow.run-completed");
740
+ });
741
+ });
742
+
743
+ describe("resume-loop", () => {
744
+ it("returns 0 when no suspended runs exist", async () => {
745
+ const fetchRuns = createInMemorySuspendedRunFetcher([]);
746
+ const handlerCtx = { unsafeAppendEvent: mock() };
747
+
748
+ const count = await runResumeLoop(fetchRuns, handlerCtx as never);
749
+
750
+ expect(count).toBe(0);
751
+ expect(handlerCtx.unsafeAppendEvent).not.toHaveBeenCalled();
752
+ });
753
+
754
+ it("resumes a suspended run", async () => {
755
+ const workflow = makeWaitWorkflow("test-workflow");
756
+ const suspendedRun: SuspendableRun = {
757
+ runId: "wf-test-run_123",
758
+ workflowName: "test-workflow",
759
+ stepIndex: 0,
760
+ wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
761
+ suspensionEventType: "kumiko:system:workflow.step.waiting",
762
+ workflow,
763
+ triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: {} } as never,
764
+ definitionFingerprint: computeDefinitionFingerprint(workflow),
765
+ };
766
+
767
+ const fetchRuns = createInMemorySuspendedRunFetcher([suspendedRun]);
768
+ const handlerCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
769
+
770
+ const count = await runResumeLoop(fetchRuns, handlerCtx as never);
771
+
772
+ expect(count).toBe(1);
773
+ expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
774
+ expect(handlerCtx.unsafeAppendEvent).toHaveBeenNthCalledWith(1, {
775
+ aggregateId: "wf-test-run_123",
776
+ aggregateType: "workflow-run",
777
+ type: "kumiko:system:workflow.step.resumed",
778
+ payload: { stepIndex: 0, retryAttempt: undefined },
779
+ });
780
+ expect(handlerCtx.unsafeAppendEvent).toHaveBeenNthCalledWith(2, {
781
+ aggregateId: "wf-test-run_123",
782
+ aggregateType: "workflow-run",
783
+ type: "kumiko:system:workflow.run-completed",
784
+ payload: { stepIndex: 0 },
785
+ });
786
+ });
787
+
788
+ it("fails loud (RUN_FAILED + reason workflow_definition_changed) when the fingerprint diverges", async () => {
789
+ const workflow = makeWaitWorkflow("test-workflow-drift");
790
+ const suspendedRun: SuspendableRun = {
791
+ runId: "wf-drift-run",
792
+ workflowName: "test-workflow-drift",
793
+ stepIndex: 0,
794
+ wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
795
+ suspensionEventType: "kumiko:system:workflow.step.waiting",
796
+ workflow,
797
+ triggerEvent: { aggregateId: "agg_y", type: "demo.fired", payload: {} } as never,
798
+ definitionFingerprint: "stale-fingerprint-from-an-older-deploy",
799
+ };
800
+
801
+ const fetchRuns = createInMemorySuspendedRunFetcher([suspendedRun]);
802
+ const handlerCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
803
+
804
+ const count = await runResumeLoop(fetchRuns, handlerCtx as never);
805
+
806
+ expect(count).toBe(0);
807
+ expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(1);
808
+ const failed = handlerCtx.unsafeAppendEvent.mock.calls[0]![0] as {
809
+ type: string;
810
+ payload: Record<string, unknown>;
811
+ };
812
+ expect(failed.type).toBe("kumiko:system:workflow.run-failed");
813
+ expect(failed.payload["reason"]).toBe("workflow_definition_changed");
814
+ expect(failed.payload["error"]).toContain("definition changed");
815
+ });
816
+
817
+ it("skips a run silently when the resume-claim hits VersionConflictError", async () => {
818
+ const workflow = makeWaitWorkflow("test-workflow-conflict");
819
+ const suspendedRun: SuspendableRun = {
820
+ runId: "wf-conflict-run",
821
+ workflowName: "test-workflow-conflict",
822
+ stepIndex: 0,
823
+ wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
824
+ suspensionEventType: "kumiko:system:workflow.step.waiting",
825
+ workflow,
826
+ triggerEvent: { aggregateId: "agg_x", type: "demo.fired", payload: {} } as never,
827
+ definitionFingerprint: computeDefinitionFingerprint(workflow),
828
+ };
829
+
830
+ const fetchRuns = createInMemorySuspendedRunFetcher([suspendedRun]);
831
+ const handlerCtx = {
832
+ unsafeAppendEvent: mock()
833
+ .mockRejectedValueOnce(new VersionConflictError("wf-conflict-run", 1))
834
+ .mockResolvedValue(undefined),
835
+ };
836
+
837
+ const count = await runResumeLoop(fetchRuns, handlerCtx as never);
838
+
839
+ expect(count).toBe(0);
840
+ expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(1);
841
+ expect((handlerCtx.unsafeAppendEvent.mock.calls[0]![0] as { type: string }).type).toBe(
842
+ "kumiko:system:workflow.step.resumed",
843
+ );
844
+ });
845
+
846
+ it("createInMemorySuspendedRunFetcher returns runs with workflow intact", async () => {
847
+ const workflow = makeReturnOnlyWorkflow("test-fetcher");
848
+ const run: SuspendableRun = {
849
+ runId: "wf-test-pipeline",
850
+ workflowName: "test-fetcher",
851
+ stepIndex: 0,
852
+ wakeAt: "2025-06-01T00:00:00Z",
853
+ workflow,
854
+ triggerEvent: { aggregateId: "a", type: "demo.fired", payload: {} } as never,
855
+ suspensionEventType: "kumiko:system:workflow.step.waiting",
856
+ };
857
+
858
+ const fetchRuns = createInMemorySuspendedRunFetcher([run]);
859
+ const result = await fetchRuns();
860
+
861
+ expect(result).toHaveLength(1);
862
+ expect(typeof result[0]!.workflow.pipelineDef.build).toBe("function");
863
+ });
864
+ });
865
+ ```
866
+
867
+ ### src/cron-scheduler.ts
868
+
869
+ ```ts
870
+ // cron-scheduler — schedules and triggers cron-based workflows.
871
+ //
872
+ // Each tick (default 60s) checks whether a cron workflow is due and runs it
873
+ // through the same `startAndRunWorkflow` path the event-trigger uses.
874
+ // Production setups should swap the tick-loop for a persistent scheduler
875
+ // (pg_cron, BullMQ) — the sample provides Bun.Timer + a manual entrypoint.
876
+
877
+ import type { HandlerContext, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
878
+ import { v4 as uuid } from "uuid";
879
+ import { startAndRunWorkflow } from "./workflow-runner";
880
+
881
+ export type CronWorkflow = WorkflowDefinition & {
882
+ readonly trigger: { readonly kind: "cron"; readonly schedule: string };
883
+ };
884
+
885
+ // Parse a cron expression in the format "minute hour * * *" and return
886
+ // the next scheduled Instant (UTC), or null if invalid. Wildcards for
887
+ // day/month/weekday are accepted; numeric minute+hour required.
888
+ export function nextCronDate(schedule: string, since: Temporal.Instant): Temporal.Instant | null {
889
+ const parts = schedule.trim().split(/\s+/);
890
+ if (parts.length < 5) return null;
891
+ const [minStr, hourStr] = parts;
892
+ if (minStr === undefined || hourStr === undefined) return null;
893
+
894
+ const cronMin = parseInt(minStr, 10);
895
+ const cronHour = parseInt(hourStr, 10);
896
+ if (Number.isNaN(cronMin) || Number.isNaN(cronHour)) return null;
897
+
898
+ // Move to the same-day candidate at cronHour:cronMin (UTC), then bump
899
+ // by one day until strictly after `since`.
900
+ let candidate = since.toZonedDateTimeISO("UTC").with({
901
+ hour: cronHour,
902
+ minute: cronMin,
903
+ second: 0,
904
+ millisecond: 0,
905
+ microsecond: 0,
906
+ nanosecond: 0,
907
+ });
908
+ while (Temporal.Instant.compare(candidate.toInstant(), since) <= 0) {
909
+ candidate = candidate.add({ days: 1 });
910
+ }
911
+ return candidate.toInstant();
912
+ }
913
+
914
+ /**
915
+ * Check if a cron workflow is due to run and execute it via the shared
916
+ * start-and-run path. Returns the number of workflow runs started.
917
+ */
918
+ export async function runDueCronWorkflows(
919
+ workflows: readonly CronWorkflow[],
920
+ lastRuns: Map<string, Temporal.Instant>,
921
+ now: Temporal.Instant,
922
+ handlerCtx: HandlerContext,
923
+ ): Promise<number> {
924
+ let count = 0;
925
+
926
+ for (const wf of workflows) {
927
+ const lastRun = lastRuns.get(wf.name) ?? now;
928
+ const next = nextCronDate(wf.trigger.schedule, lastRun);
929
+
930
+ if (next && Temporal.Instant.compare(next, now) <= 0) {
931
+ const runId = `wf-${wf.name}-cron-${uuid()}`;
932
+ await startAndRunWorkflow({
933
+ runId,
934
+ workflow: wf,
935
+ triggerEvent: { aggregateId: runId, type: "cron", payload: {} } as never,
936
+ handlerCtx,
937
+ });
938
+ lastRuns.set(wf.name, now);
939
+ count++;
940
+ }
941
+ }
942
+
943
+ return count;
944
+ }
945
+
946
+ /**
947
+ * Create a Bun.Timer-based cron scheduler that periodically checks for
948
+ * due workflows and runs them.
949
+ */
950
+ export function startCronScheduler(
951
+ workflows: readonly CronWorkflow[],
952
+ handlerCtx: HandlerContext,
953
+ intervalMs = 60_000,
954
+ ): { stop: () => void } {
955
+ const lastRuns = new Map<string, Temporal.Instant>();
956
+ let running = false;
957
+ let timer: ReturnType<typeof setInterval> | null = null;
958
+
959
+ async function tick() {
960
+ if (running) return;
961
+ running = true;
962
+ try {
963
+ await runDueCronWorkflows(workflows, lastRuns, Temporal.Now.instant(), handlerCtx);
964
+ } catch {
965
+ // Logged by the caller
966
+ } finally {
967
+ running = false;
968
+ }
969
+ }
970
+
971
+ timer = setInterval(tick, intervalMs);
972
+ void tick();
973
+
974
+ return {
975
+ stop: () => {
976
+ if (timer) clearInterval(timer);
977
+ },
978
+ };
979
+ }
980
+ ```
981
+
982
+ ### src/db/queries/suspended-runs.ts
983
+
984
+ ```ts
985
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
986
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
987
+
988
+ export async function selectExpiredSuspensionEvents(
989
+ db: DbRunner,
990
+ eventTypes: readonly string[],
991
+ ): Promise<readonly Record<string, unknown>[]> {
992
+ return (await asRawClient(db).unsafe(
993
+ `SELECT * FROM "kumiko_events"
994
+ WHERE "type" = ANY($1::text[])
995
+ AND (("payload"->>'wakeAt')::timestamptz < now() OR ("payload"->>'timeoutAt')::timestamptz < now())`,
996
+ [eventTypes as unknown as string[]],
997
+ )) as Record<string, unknown>[];
998
+ }
999
+ ```
1000
+
1001
+ ### src/event-trigger.ts
1002
+
1003
+ ```ts
1004
+ // event-trigger — MultiStreamProjection that listens for domain events and
1005
+ // starts + runs a workflow when the event matches a trigger.
1006
+ //
1007
+ // Registration pattern:
1008
+ // registerEventTrigger(r, userOnboardingWorkflow)
1009
+ //
1010
+ // The MSP apply-fn runs in the dispatcher's tx, so the run.started event
1011
+ // plus the synchronous portion of the pipeline land atomically. If the
1012
+ // pipeline hits a Tier-3 suspension (wait/waitForEvent/retry), the apply
1013
+ // commits with the WAITING event written; the resume-loop wakes it later.
1014
+
1015
+ import type {
1016
+ FeatureRegistrar,
1017
+ MultiStreamProjectionDefinition,
1018
+ WorkflowDefinition,
1019
+ WriteEvent,
1020
+ } from "@cosmicdrift/kumiko-framework/engine";
1021
+ import {
1022
+ describeWorkflowStepError,
1023
+ WORKFLOW_AGGREGATE_TYPE,
1024
+ WORKFLOW_RUN_FAILED_TYPE,
1025
+ } from "@cosmicdrift/kumiko-framework/engine";
1026
+ import { v4 as uuid } from "uuid";
1027
+ import { startAndRunWorkflow } from "./workflow-runner";
1028
+
1029
+ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
1030
+ if (workflow.trigger.kind !== "event") return;
1031
+
1032
+ const eventType = workflow.trigger.eventType;
1033
+
1034
+ r.multiStreamProjection({
1035
+ name: `workflow-${workflow.name}`,
1036
+ apply: {
1037
+ [eventType]: async (event, _tx, ctx) => {
1038
+ if (workflow.trigger.kind !== "event") return;
1039
+ if (workflow.trigger.filter) {
1040
+ const matches = workflow.trigger.filter(event as never);
1041
+ if (!matches) return;
1042
+ }
1043
+
1044
+ // @cast-boundary msp-to-write-event — MSP receives StoredEvent
1045
+ // (event-store shape), workflow runner expects WriteEvent (handler
1046
+ // shape). The fields workflow steps read (type, payload) overlap
1047
+ // exactly; the missing `.user` field on StoredEvent is acceptable
1048
+ // because workflow triggers run system-level, not user-scoped.
1049
+ const triggerEvent = event as unknown as WriteEvent;
1050
+ let idempotencyKey: string | undefined;
1051
+ if (typeof workflow.idempotencyKey === "function") {
1052
+ idempotencyKey = workflow.idempotencyKey(triggerEvent);
1053
+ } else if (typeof workflow.idempotencyKey === "string") {
1054
+ idempotencyKey = workflow.idempotencyKey;
1055
+ }
1056
+
1057
+ const runId = idempotencyKey
1058
+ ? `wf-${workflow.name}-${idempotencyKey}`
1059
+ : `wf-${workflow.name}-${uuid()}`;
1060
+
1061
+ try {
1062
+ await startAndRunWorkflow({
1063
+ runId,
1064
+ workflow,
1065
+ triggerEvent,
1066
+ triggerEventRef: {
1067
+ eventId: event.id,
1068
+ aggregateId: event.aggregateId,
1069
+ version: event.version,
1070
+ },
1071
+ ...(idempotencyKey && { idempotencyKey }),
1072
+ handlerCtx: ctx as never,
1073
+ });
1074
+ } catch (error) {
1075
+ await (
1076
+ ctx as never as { unsafeAppendEvent: (a: unknown) => Promise<void> }
1077
+ ).unsafeAppendEvent({
1078
+ aggregateId: runId,
1079
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
1080
+ type: WORKFLOW_RUN_FAILED_TYPE,
1081
+ payload: {
1082
+ workflowName: workflow.name,
1083
+ stepIndex: 0,
1084
+ error: describeWorkflowStepError(error),
1085
+ },
1086
+ });
1087
+ throw error;
1088
+ }
1089
+ },
1090
+ },
1091
+ } satisfies MultiStreamProjectionDefinition);
1092
+ }
1093
+ ```
1094
+
1095
+ ### src/feature.ts
1096
+
1097
+ ```ts
1098
+ // workflow-engine Sample — M.4 Tier-3 step-vocabulary showcase.
1099
+ //
1100
+ // Demonstrates defineWorkflow with wait, branch, mail.send, webhook.send
1101
+ // and the workflow-run lifecycle. Each workflow below is a real,
1102
+ // runnable pipeline — no empty `build: () => []` stubs. The
1103
+ // integration-tests in __tests__/ exercise the suspension/resume cycle
1104
+ // against the in-memory fetcher and (separately) the postgres event-store.
1105
+ //
1106
+ // Mounting:
1107
+ // - Event-triggered workflows register an MSP via `registerEventTrigger`
1108
+ // (see feature() below). The MSP starts + runs the pipeline up to the
1109
+ // first suspension; the resume-loop wakes it later.
1110
+ // - Cron-triggered workflows are started by the `cron-scheduler` tick.
1111
+ //
1112
+ // M.4 Limitations (Followups, will land with M.5 / future slices):
1113
+ // - The MSP apply-ctx exposes `unsafeAppendEvent` + `db` but not the
1114
+ // full HandlerContext surface. `r.step.read.findOne` works (db is
1115
+ // present), `r.step.callFeature` does NOT yet (no `write`/`writeAs`
1116
+ // on apply-ctx). Pipelines should stick to compute / branch / wait /
1117
+ // mail.send / webhook.send for now.
1118
+ // - The fetcher reads every suspension row whose wakeAt has expired
1119
+ // (no `workflow_run_pending` read-side projection yet). Concurrency
1120
+ // is safe via the event-store version-conflict path; performance is
1121
+ // adequate for the demo, not for high-volume production.
1122
+
1123
+ import type { PipelineCtx, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
1124
+ import { defineFeature, defineWorkflow, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
1125
+ import { registerEventTrigger } from "./event-trigger";
1126
+
1127
+ /**
1128
+ * User-onboarding workflow: welcome-mail → wait 1 day → branch on a
1129
+ * computed engagement flag → either tips or reminder → wait 7 days →
1130
+ * webhook to the CRM. Triggered by `user.signed-up`, deduped per userId.
1131
+ *
1132
+ * The engagement-flag is computed inline (no read.findOne in M.4 yet —
1133
+ * see Limitations above). A real app would resolve the flag from the
1134
+ * user-store via a Tier-1 read step once the apply-ctx → HandlerContext
1135
+ * bridge lands.
1136
+ */
1137
+ export const userOnboardingWorkflow: WorkflowDefinition<{ email: string; userId: string }, void> =
1138
+ defineWorkflow({
1139
+ name: "user-onboarding",
1140
+ trigger: { kind: "event", eventType: "user.signed-up" },
1141
+ idempotencyKey: ({ payload }) => `onboarding:${payload.userId}`,
1142
+
1143
+ steps: stepsPipeline<{ email: string; userId: string }, void>(({ r }) => [
1144
+ r.step.mail.send({
1145
+ to: (ctx) => (ctx.event.payload as { email: string }).email,
1146
+ subject: "Welcome!",
1147
+ body: "Thanks for signing up — we'll check in soon.",
1148
+ mode: "deferred",
1149
+ }),
1150
+
1151
+ r.step.wait({ for: "P1D" }),
1152
+
1153
+ // Engagement gate: demo-only inline flag. In a production sample this
1154
+ // would be `r.step.read.findOne("user", { ... })` followed by a
1155
+ // branch on `lastSeenAt`; deferred to the M.5 read-bridge.
1156
+ r.step.compute("engaged", (ctx) => {
1157
+ const userId = (ctx.event.payload as { userId: string }).userId;
1158
+ return userId.startsWith("active-");
1159
+ }),
1160
+
1161
+ r.step.branch({
1162
+ if: (ctx) => (ctx.steps["engaged"] as boolean) === true,
1163
+ onTrue: [
1164
+ r.step.mail.send({
1165
+ to: (ctx) => (ctx.event.payload as { email: string }).email,
1166
+ subject: "Engagement tips",
1167
+ body: "You're off to a great start — try these next.",
1168
+ mode: "deferred",
1169
+ }),
1170
+ ],
1171
+ onFalse: [
1172
+ r.step.mail.send({
1173
+ to: (ctx) => (ctx.event.payload as { email: string }).email,
1174
+ subject: "Getting started",
1175
+ body: "Need a hand? Here's how to begin.",
1176
+ mode: "deferred",
1177
+ }),
1178
+ ],
1179
+ }),
1180
+
1181
+ r.step.wait({ for: "P7D" }),
1182
+
1183
+ r.step.webhook.send({
1184
+ url: "https://hooks.example.com/crm-onboarding",
1185
+ method: "POST",
1186
+ body: (ctx: PipelineCtx) => ({
1187
+ userId: (ctx.event.payload as { userId: string }).userId,
1188
+ stage: "day8",
1189
+ }),
1190
+ mode: "deferred",
1191
+ }),
1192
+
1193
+ r.step.return({ isSuccess: true, data: undefined }),
1194
+ ]),
1195
+ });
1196
+
1197
+ /**
1198
+ * Webhook-delivery workflow: one deferred webhook per `data.processed` event.
1199
+ * `webhook.send` only enqueues a dispatch request; the step-dispatcher
1200
+ * delivers it once and a failed delivery ends as `step.dispatch-failed`. A
1201
+ * `retry` around it would never fire, because the step itself cannot throw
1202
+ * on a delivery error.
1203
+ */
1204
+ export const webhookDeliveryWorkflow: WorkflowDefinition<
1205
+ { data: unknown; webhookUrl: string },
1206
+ void
1207
+ > = defineWorkflow({
1208
+ name: "webhook-delivery",
1209
+ trigger: { kind: "event", eventType: "data.processed" },
1210
+
1211
+ steps: stepsPipeline<{ data: unknown; webhookUrl: string }, void>(({ r }) => [
1212
+ r.step.webhook.send({
1213
+ url: (ctx: PipelineCtx) => (ctx.event.payload as { webhookUrl: string }).webhookUrl,
1214
+ body: (ctx: PipelineCtx) => (ctx.event.payload as { data: unknown }).data,
1215
+ mode: "deferred",
1216
+ }),
1217
+ r.step.return({ isSuccess: true, data: undefined }),
1218
+ ]),
1219
+ });
1220
+
1221
+ /**
1222
+ * Daily-report workflow: cron-triggered, single deferred webhook.
1223
+ */
1224
+ export const dailyReportWorkflow: WorkflowDefinition<void, void> = defineWorkflow({
1225
+ name: "daily-report",
1226
+ trigger: { kind: "cron", schedule: "0 9 * * *" },
1227
+
1228
+ steps: stepsPipeline<void, void>(({ r }) => [
1229
+ r.step.webhook.send({
1230
+ url: "https://hooks.example.com/daily-report",
1231
+ body: () => ({ ts: Temporal.Now.instant().toString() }),
1232
+ mode: "deferred",
1233
+ }),
1234
+ r.step.return({ isSuccess: true, data: undefined }),
1235
+ ]),
1236
+ });
1237
+
1238
+ /**
1239
+ * Workflow-engine feature registers MSP subscriptions for every event-
1240
+ * triggered workflow. Cron-triggered workflows are picked up by the
1241
+ * cron-scheduler.tick() in production wiring; the feature itself doesn't
1242
+ * own them.
1243
+ */
1244
+ export const workflowEngineFeature = defineFeature("workflowEngine", (r) => {
1245
+ // @cast-boundary workflow-payload-variance — registerEventTrigger's
1246
+ // WorkflowDefinition<unknown> can't directly accept WorkflowDefinition<{...}>
1247
+ // because the embedded pipeline closure is contravariant in TPayload.
1248
+ // The runtime only touches trigger/name/idempotencyKey + executes
1249
+ // the closure with the real event payload — payload-agnostic.
1250
+ registerEventTrigger(r, userOnboardingWorkflow as unknown as WorkflowDefinition);
1251
+ registerEventTrigger(r, webhookDeliveryWorkflow as unknown as WorkflowDefinition);
1252
+ // dailyReportWorkflow is cron-triggered — skip MSP registration
1253
+ });
1254
+
1255
+ /**
1256
+ * Compat-Export — the unit-test file references this. It's just the
1257
+ * onboarding workflow's pipelineDef pulled out so the test can assert
1258
+ * its shape independently from the workflow definition.
1259
+ */
1260
+ export const userOnboardingSteps = userOnboardingWorkflow.pipelineDef;
1261
+ ```
1262
+
1263
+ ### src/postgres-resume-loop.ts
1264
+
1265
+ ```ts
1266
+ // postgres-resume-loop — fetchSuspendedRuns implementations.
1267
+ //
1268
+ // Two flavours:
1269
+ // 1. createSuspendedRunFetcher(db, workflowRegistry) — Postgres-backed
1270
+ // 2. createInMemorySuspendedRunFetcher(runs) — for unit tests
1271
+ //
1272
+ // The fetcher reads the Q7 fingerprint from the suspension event payload and
1273
+ // re-loads the trigger event from the event store through the
1274
+ // triggerEventRef on the run's own run.started event (no payload copy lives
1275
+ // on the run stream).
1276
+
1277
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
1278
+ import type { TenantId, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
1279
+ import {
1280
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
1281
+ WORKFLOW_RUN_STARTED_TYPE,
1282
+ WORKFLOW_WAITING_FOR_EVENT_TYPE,
1283
+ WORKFLOW_WAITING_TYPE,
1284
+ } from "@cosmicdrift/kumiko-framework/engine";
1285
+ import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
1286
+ import { selectExpiredSuspensionEvents } from "./db/queries/suspended-runs";
1287
+ import type { SuspendableRun } from "./resume-loop";
1288
+ import type { TriggerEventRef } from "./workflow-runner";
1289
+
1290
+ export type WorkflowRegistry = ReadonlyMap<string, WorkflowDefinition>;
1291
+
1292
+ const SUSPEND_EVENT_TYPES = [
1293
+ WORKFLOW_WAITING_TYPE,
1294
+ WORKFLOW_WAITING_FOR_EVENT_TYPE,
1295
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
1296
+ ] as const;
1297
+
1298
+ function isTriggerEventRef(value: unknown): value is TriggerEventRef {
1299
+ return (
1300
+ typeof value === "object" &&
1301
+ value !== null &&
1302
+ "eventId" in value &&
1303
+ typeof value.eventId === "string" &&
1304
+ "aggregateId" in value &&
1305
+ typeof value.aggregateId === "string" &&
1306
+ "version" in value &&
1307
+ typeof value.version === "number"
1308
+ );
1309
+ }
1310
+
1311
+ // undefined when the run has no usable reference or the trigger event can no
1312
+ // longer be loaded (archived stream): the run is skipped, never resumed on
1313
+ // made-up data.
1314
+ async function loadTriggerEvent(
1315
+ db: DbRunner,
1316
+ tenantId: TenantId,
1317
+ runId: string,
1318
+ ): Promise<{ aggregateId: string; type: string; payload: unknown } | undefined> {
1319
+ const runEvents = await loadAggregate(db, runId, tenantId);
1320
+ const started = runEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
1321
+ if (!started) return undefined;
1322
+ const ref = started.payload["triggerEventRef"];
1323
+ if (ref === undefined) {
1324
+ // Cron run: the synthetic trigger event was never stored.
1325
+ return { aggregateId: runId, type: String(started.payload["triggerEventType"]), payload: {} };
1326
+ }
1327
+ if (!isTriggerEventRef(ref)) return undefined;
1328
+ const source = (await loadAggregate(db, ref.aggregateId, tenantId)).find(
1329
+ (e) => e.id === ref.eventId,
1330
+ );
1331
+ return source && { aggregateId: source.aggregateId, type: source.type, payload: source.payload };
1332
+ }
1333
+
1334
+ /**
1335
+ * Postgres-backed fetcher. Queries kumiko_events for suspension events with
1336
+ * expired wakeAt/timeoutAt timestamps. The resume-loop applies the Q7
1337
+ * fingerprint check + concurrency-claim per row.
1338
+ *
1339
+ * Known limitation (M.4 followup): the WHERE clause currently picks up
1340
+ * every WAITING-row whose wakeAt has expired, including those that have
1341
+ * already been resumed. The resume-loop silently skips them via the
1342
+ * VersionConflictError path on the RESUMED-claim. A future optimisation
1343
+ * is the workflow_run_pending read-side projection (Plan-Doc Sample 2,
1344
+ * "Resume-Loop" section).
1345
+ */
1346
+ export function createSuspendedRunFetcher(
1347
+ db: DbRunner,
1348
+ workflowRegistry: WorkflowRegistry,
1349
+ ): () => Promise<SuspendableRun[]> {
1350
+ return async () => {
1351
+ const rows = await selectExpiredSuspensionEvents(db, SUSPEND_EVENT_TYPES);
1352
+
1353
+ const results: SuspendableRun[] = [];
1354
+
1355
+ for (const row of rows) {
1356
+ const aggregateId = (row["aggregateId"] ?? row["aggregate_id"]) as string;
1357
+ const type = row["type"] as string;
1358
+ const payload = row["payload"] as Record<string, unknown>;
1359
+
1360
+ const workflowName = payload["workflowName"] as string | undefined;
1361
+ if (!workflowName) continue;
1362
+
1363
+ const workflow = workflowRegistry.get(workflowName);
1364
+ if (!workflow) continue;
1365
+
1366
+ const stepIndex = payload["stepIndex"] as number | undefined;
1367
+ if (stepIndex === undefined) continue;
1368
+
1369
+ const wakeAt = (payload["wakeAt"] ?? payload["timeoutAt"]) as string | undefined;
1370
+ if (!wakeAt) continue;
1371
+
1372
+ const triggerEvent = await loadTriggerEvent(
1373
+ db,
1374
+ (row["tenantId"] ?? row["tenant_id"]) as TenantId, // @cast-boundary raw-sql-row
1375
+ aggregateId,
1376
+ );
1377
+ if (!triggerEvent) continue;
1378
+
1379
+ results.push({
1380
+ runId: aggregateId,
1381
+ workflowName,
1382
+ stepIndex,
1383
+ wakeAt,
1384
+ retryAttempt: payload["attempt"] as number | undefined,
1385
+ suspensionEventType: type,
1386
+ workflow,
1387
+ triggerEvent,
1388
+ definitionFingerprint: payload["definitionFingerprint"] as string | undefined,
1389
+ });
1390
+ }
1391
+
1392
+ return results;
1393
+ };
1394
+ }
1395
+
1396
+ /**
1397
+ * In-memory fetchSuspendedRuns for unit testing. Returns a fresh copy of
1398
+ * runs on each call.
1399
+ */
1400
+ export function createInMemorySuspendedRunFetcher(
1401
+ runs: readonly SuspendableRun[],
1402
+ ): () => Promise<SuspendableRun[]> {
1403
+ return async () => runs.map((r) => ({ ...r }));
1404
+ }
1405
+ ```
1406
+
1407
+ ### src/resume-loop.ts
1408
+
1409
+ ```ts
1410
+ // resume-loop — polls for suspended workflow runs with expired wake times
1411
+ // and re-enters the pipeline at the appropriate step.
1412
+ //
1413
+ // The Resume-Loop is the runtime that makes Tier-3 steps work:
1414
+ // 1. Reads the event store for WORKFLOW_WAITING_TYPE events
1415
+ // 2. Filters on `wakeAt < now()` — runs whose wait period has expired
1416
+ // 3. Writes a WORKFLOW_RESUMED_TYPE event to the run's stream
1417
+ // 4. Re-executes the pipeline from the suspended step + 1
1418
+ //
1419
+ // For WORKFLOW_RETRY_SCHEDULED_TYPE events:
1420
+ // 1. Matches step with expired backoff
1421
+ // 2. Writes a resumed event with retryAttempt = previous + 1
1422
+ // 3. Re-executes the pipeline at the same step index
1423
+ //
1424
+ // The loop is designed to run as a Bun worker / cron job (e.g. every 30s).
1425
+
1426
+ import type { HandlerContext, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
1427
+ import {
1428
+ buildPipelineSteps,
1429
+ computeDefinitionFingerprint,
1430
+ describeWorkflowStepError,
1431
+ runStepList,
1432
+ WORKFLOW_AGGREGATE_TYPE,
1433
+ WORKFLOW_RESUMED_TYPE,
1434
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
1435
+ WORKFLOW_RUN_COMPLETED_TYPE,
1436
+ WORKFLOW_RUN_FAILED_TYPE,
1437
+ } from "@cosmicdrift/kumiko-framework/engine";
1438
+ import { VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
1439
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
1440
+
1441
+ const log = createFallbackLogger("workflow-runner");
1442
+
1443
+ export type SuspendableRun = {
1444
+ runId: string;
1445
+ workflowName: string;
1446
+ stepIndex: number;
1447
+ wakeAt: string;
1448
+ /** Previous retry attempt count — set by the retry step's suspension event. */
1449
+ retryAttempt?: number;
1450
+ /** The event type that caused the suspension — used by the resume-loop
1451
+ * to decide whether to skip or re-execute the suspended step. */
1452
+ suspensionEventType: string;
1453
+ /** The workflow the suspension belongs to — resolved by the fetcher from
1454
+ * the registry. The resume-loop validates its current fingerprint
1455
+ * against `definitionFingerprint` below before resuming. */
1456
+ workflow: WorkflowDefinition;
1457
+ /** The original trigger event payload captured at run.started time
1458
+ * (Q7 Snapshot-at-Start). Re-fed into the pipeline on resume so
1459
+ * resolvers that read `event.payload` see what they saw originally. */
1460
+ triggerEvent: unknown;
1461
+ /** Fingerprint stamped on the suspension event when the run started.
1462
+ * Resume-loop compares this against the current workflow definition's
1463
+ * fingerprint; a mismatch surfaces as RUN_FAILED with the reason
1464
+ * `workflow_definition_changed`. */
1465
+ definitionFingerprint?: string;
1466
+ };
1467
+
1468
+ export class WorkflowDefinitionChangedError extends Error {
1469
+ constructor(
1470
+ readonly runId: string,
1471
+ readonly workflowName: string,
1472
+ readonly expectedFingerprint: string,
1473
+ readonly currentFingerprint: string,
1474
+ ) {
1475
+ super(
1476
+ `Workflow "${workflowName}" definition changed since run ${runId} started ` +
1477
+ `(expected ${expectedFingerprint.slice(0, 12)}…, current ${currentFingerprint.slice(0, 12)}…). ` +
1478
+ `Cancel the run or wait for it to complete before deploying definition changes.`,
1479
+ );
1480
+ this.name = "WorkflowDefinitionChangedError";
1481
+ }
1482
+ }
1483
+
1484
+ /**
1485
+ * Poll for suspended runs with expired wake times and resume them.
1486
+ * Called periodically (e.g. every 30s) by a scheduler or Bun.Timer.
1487
+ *
1488
+ * @param fetchSuspendedRuns - async function that reads the event store
1489
+ * for WORKFLOW_WAITING_TYPE events where wakeAt < now()
1490
+ * @param handlerCtx - the HandlerContext to pass to the pipeline
1491
+ * @returns the number of runs resumed
1492
+ */
1493
+ export async function runResumeLoop(
1494
+ fetchSuspendedRuns: () => Promise<SuspendableRun[]>,
1495
+ handlerCtx: HandlerContext,
1496
+ ): Promise<number> {
1497
+ const suspended = await fetchSuspendedRuns();
1498
+ let resumed = 0;
1499
+
1500
+ for (const run of suspended) {
1501
+ // Q7 fingerprint check FIRST — before we claim the run, before any
1502
+ // expensive work, before we burn a stream-version. If the workflow
1503
+ // definition changed since the run started, fail loud (do NOT silent-
1504
+ // skip — a stale stuck run is worse than an explicit failure).
1505
+ if (run.definitionFingerprint !== undefined) {
1506
+ const currentFingerprint = computeDefinitionFingerprint(run.workflow);
1507
+ if (currentFingerprint !== run.definitionFingerprint) {
1508
+ await handlerCtx.unsafeAppendEvent({
1509
+ aggregateId: run.runId,
1510
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
1511
+ type: WORKFLOW_RUN_FAILED_TYPE,
1512
+ payload: {
1513
+ workflowName: run.workflowName,
1514
+ stepIndex: run.stepIndex,
1515
+ error: new WorkflowDefinitionChangedError(
1516
+ run.runId,
1517
+ run.workflowName,
1518
+ run.definitionFingerprint,
1519
+ currentFingerprint,
1520
+ ).message,
1521
+ reason: "workflow_definition_changed",
1522
+ },
1523
+ });
1524
+ continue;
1525
+ }
1526
+ }
1527
+
1528
+ // Resume-claim: append WORKFLOW_RESUMED first. The event-store appends
1529
+ // against expectedVersion = current stream-version; a competing
1530
+ // resume-loop instance racing the same run loses the UNIQUE
1531
+ // (tenant_id, aggregate_id, version) check and gets a
1532
+ // VersionConflictError. That's the concurrency-guard — silent skip,
1533
+ // no FAILED-event. Stale WAITING-rows in the fetcher result also
1534
+ // hit this branch on the second poll-tick.
1535
+ try {
1536
+ await handlerCtx.unsafeAppendEvent({
1537
+ aggregateId: run.runId,
1538
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
1539
+ type: WORKFLOW_RESUMED_TYPE,
1540
+ payload: {
1541
+ stepIndex: run.stepIndex,
1542
+ retryAttempt: run.retryAttempt,
1543
+ },
1544
+ });
1545
+ } catch (error) {
1546
+ if (error instanceof VersionConflictError) {
1547
+ continue;
1548
+ }
1549
+ throw error;
1550
+ }
1551
+
1552
+ try {
1553
+ const workflowCtx = {
1554
+ runId: run.runId,
1555
+ workflowName: run.workflowName,
1556
+ stepIndex: run.stepIndex,
1557
+ ...(run.retryAttempt ? { retryAttempt: run.retryAttempt + 1 } : {}),
1558
+ ...(run.definitionFingerprint && {
1559
+ definitionFingerprint: run.definitionFingerprint,
1560
+ }),
1561
+ };
1562
+
1563
+ // runStepList skip semantics: indices `i < resumeFrom` are skipped,
1564
+ // `i >= resumeFrom` execute. Wait/waitForEvent already wrote their
1565
+ // suspension event in the original run → resume past them (stepIndex + 1).
1566
+ // Retry re-enters at the same index because the retry step itself
1567
+ // wraps the sub-pipeline that must run again.
1568
+ const resumeFrom =
1569
+ run.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE
1570
+ ? run.stepIndex
1571
+ : run.stepIndex + 1;
1572
+
1573
+ const steps = buildPipelineSteps(run.workflow.pipelineDef, run.triggerEvent as never);
1574
+ const outcome = await runStepList(
1575
+ steps,
1576
+ run.triggerEvent as never,
1577
+ handlerCtx,
1578
+ {},
1579
+ {},
1580
+ workflowCtx,
1581
+ resumeFrom,
1582
+ );
1583
+
1584
+ // Only write run-completed when the pipeline actually completed or
1585
+ // returned. A second suspension (e.g. `wait` further down the
1586
+ // pipeline) leaves the run pending for the next resume-loop tick.
1587
+ if (outcome.kind !== "suspended") {
1588
+ await handlerCtx.unsafeAppendEvent({
1589
+ aggregateId: run.runId,
1590
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
1591
+ type: WORKFLOW_RUN_COMPLETED_TYPE,
1592
+ payload: { stepIndex: run.stepIndex },
1593
+ });
1594
+ }
1595
+
1596
+ resumed++;
1597
+ } catch (error) {
1598
+ // The failed event keeps only a short description; log the raw error so
1599
+ // the cause stays diagnosable.
1600
+ log.warn("workflow run failed", {
1601
+ runId: run.runId,
1602
+ stepIndex: run.stepIndex,
1603
+ error: String(error),
1604
+ });
1605
+ await handlerCtx.unsafeAppendEvent({
1606
+ aggregateId: run.runId,
1607
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
1608
+ type: WORKFLOW_RUN_FAILED_TYPE,
1609
+ payload: {
1610
+ stepIndex: run.stepIndex,
1611
+ error: describeWorkflowStepError(error),
1612
+ },
1613
+ });
1614
+ }
1615
+ }
1616
+
1617
+ return resumed;
1618
+ }
1619
+ ```
1620
+
1621
+ ### src/workflow-runner.ts
1622
+
1623
+ ```ts
1624
+ // workflow-runner — starts a workflow-run by writing run.started and
1625
+ // executing the pipeline until first suspension or completion.
1626
+ //
1627
+ // Q7 (Snapshot-at-Start): the run.started payload carries
1628
+ // `definitionFingerprint` plus the trigger fields the resume-loop needs
1629
+ // to re-hydrate state. The fingerprint travels on into every suspension
1630
+ // event (wait/waitForEvent/retry payloads) so the resume-loop validates
1631
+ // it without a separate per-run lookup.
1632
+ //
1633
+ // On first suspension: just return — the resume-loop wakes the run later.
1634
+ // On full completion (pipeline ran to r.step.return): we write
1635
+ // WORKFLOW_RUN_COMPLETED here. Throws bubble up to the caller (event-
1636
+ // trigger / cron-scheduler) which translates them into RUN_FAILED.
1637
+
1638
+ import type {
1639
+ HandlerContext,
1640
+ WorkflowDefinition,
1641
+ WriteEvent,
1642
+ } from "@cosmicdrift/kumiko-framework/engine";
1643
+ import {
1644
+ buildPipelineSteps,
1645
+ computeDefinitionFingerprint,
1646
+ runStepList,
1647
+ WORKFLOW_AGGREGATE_TYPE,
1648
+ WORKFLOW_RUN_COMPLETED_TYPE,
1649
+ WORKFLOW_RUN_STARTED_TYPE,
1650
+ } from "@cosmicdrift/kumiko-framework/engine";
1651
+
1652
+ // Reference into the event store; the run stream never copies a foreign
1653
+ // payload, so erasing the source event also covers the workflow.
1654
+ export type TriggerEventRef = {
1655
+ readonly eventId: string;
1656
+ readonly aggregateId: string;
1657
+ readonly version: number;
1658
+ };
1659
+
1660
+ export type WorkflowRunStartedPayload = {
1661
+ readonly workflowName: string;
1662
+ readonly triggerEventType: string;
1663
+ // Absent for cron runs: their synthetic trigger event has no stored source.
1664
+ readonly triggerEventRef?: TriggerEventRef;
1665
+ readonly definitionFingerprint: string;
1666
+ readonly idempotencyKey?: string;
1667
+ };
1668
+
1669
+ /**
1670
+ * Start + execute a workflow run.
1671
+ *
1672
+ * 1. Append `workflow.run.started` with the snapshot fingerprint.
1673
+ * 2. Run the pipeline. Tier-3 steps may suspend it via SUSPEND_SENTINEL.
1674
+ * 3. If the pipeline completes without suspension → write
1675
+ * `workflow.run-completed`. If it suspends → leave the resume-loop
1676
+ * to wake it. If it throws → bubble up; the caller writes FAILED.
1677
+ */
1678
+ export async function startAndRunWorkflow(args: {
1679
+ readonly runId: string;
1680
+ readonly workflow: WorkflowDefinition;
1681
+ readonly triggerEvent: WriteEvent;
1682
+ readonly triggerEventRef?: TriggerEventRef;
1683
+ readonly idempotencyKey?: string;
1684
+ readonly handlerCtx: HandlerContext;
1685
+ }): Promise<{ readonly outcome: "completed" | "suspended" }> {
1686
+ const fingerprint = computeDefinitionFingerprint(args.workflow);
1687
+
1688
+ const startedPayload: WorkflowRunStartedPayload = {
1689
+ workflowName: args.workflow.name,
1690
+ triggerEventType: args.triggerEvent.type,
1691
+ ...(args.triggerEventRef && { triggerEventRef: args.triggerEventRef }),
1692
+ definitionFingerprint: fingerprint,
1693
+ ...(args.idempotencyKey && { idempotencyKey: args.idempotencyKey }),
1694
+ };
1695
+
1696
+ await args.handlerCtx.unsafeAppendEvent({
1697
+ aggregateId: args.runId,
1698
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
1699
+ type: WORKFLOW_RUN_STARTED_TYPE,
1700
+ payload: startedPayload as unknown as Record<string, unknown>,
1701
+ });
1702
+
1703
+ const steps = buildPipelineSteps(args.workflow.pipelineDef, args.triggerEvent);
1704
+
1705
+ const outcome = await runStepList(
1706
+ steps,
1707
+ args.triggerEvent,
1708
+ args.handlerCtx,
1709
+ {},
1710
+ {},
1711
+ {
1712
+ runId: args.runId,
1713
+ workflowName: args.workflow.name,
1714
+ stepIndex: 0,
1715
+ definitionFingerprint: fingerprint,
1716
+ },
1717
+ );
1718
+
1719
+ if (outcome.kind === "suspended") {
1720
+ return { outcome: "suspended" };
1721
+ }
1722
+
1723
+ await args.handlerCtx.unsafeAppendEvent({
1724
+ aggregateId: args.runId,
1725
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
1726
+ type: WORKFLOW_RUN_COMPLETED_TYPE,
1727
+ payload: { workflowName: args.workflow.name },
1728
+ });
1729
+ return { outcome: "completed" };
1730
+ }
1731
+ ```