@cosmicdrift/kumiko-framework 0.2.3 → 0.3.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 (167) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +124 -39
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/compliance/profiles.ts +8 -8
  10. package/src/db/assert-exists-in.ts +2 -2
  11. package/src/db/cursor.ts +3 -3
  12. package/src/db/event-store-executor.ts +19 -13
  13. package/src/db/located-timestamp.ts +1 -1
  14. package/src/db/money.ts +12 -2
  15. package/src/db/pg-error.ts +1 -1
  16. package/src/db/row-helpers.ts +1 -1
  17. package/src/db/table-builder.ts +3 -5
  18. package/src/db/tenant-db.ts +9 -9
  19. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  20. package/src/engine/__tests__/build-target.test.ts +135 -0
  21. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  22. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  23. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  24. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  25. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  26. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  27. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  28. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  29. package/src/engine/__tests__/raw-table.test.ts +2 -2
  30. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  31. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  32. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  33. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  34. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  35. package/src/engine/__tests__/steps-read.test.ts +142 -0
  36. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  37. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  38. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  39. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  40. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  41. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  42. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  43. package/src/engine/boot-validator/api-ext.ts +77 -0
  44. package/src/engine/boot-validator/config-deps.ts +163 -0
  45. package/src/engine/boot-validator/entity-handler.ts +466 -0
  46. package/src/engine/boot-validator/index.ts +159 -0
  47. package/src/engine/boot-validator/ownership.ts +198 -0
  48. package/src/engine/boot-validator/pii-retention.ts +155 -0
  49. package/src/engine/boot-validator/screens-nav.ts +624 -0
  50. package/src/engine/boot-validator.ts +1 -1804
  51. package/src/engine/build-app-schema.ts +1 -1
  52. package/src/engine/build-target.ts +99 -0
  53. package/src/engine/codemod/index.ts +15 -0
  54. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  55. package/src/engine/config-helpers.ts +9 -19
  56. package/src/engine/constants.ts +1 -1
  57. package/src/engine/define-feature.ts +88 -9
  58. package/src/engine/define-handler.ts +89 -3
  59. package/src/engine/define-roles.ts +2 -2
  60. package/src/engine/define-step.ts +28 -0
  61. package/src/engine/define-workflow.ts +110 -0
  62. package/src/engine/entity-handlers.ts +10 -9
  63. package/src/engine/event-helpers.ts +4 -4
  64. package/src/engine/factories.ts +12 -12
  65. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  66. package/src/engine/feature-ast/extractors/index.ts +74 -0
  67. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  68. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  69. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  70. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  71. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  72. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  73. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  74. package/src/engine/feature-ast/parse.ts +7 -0
  75. package/src/engine/feature-ast/patch.ts +9 -1
  76. package/src/engine/feature-ast/patcher.ts +10 -3
  77. package/src/engine/feature-ast/patterns.ts +49 -1
  78. package/src/engine/feature-ast/render.ts +17 -1
  79. package/src/engine/index.ts +45 -2
  80. package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
  81. package/src/engine/pattern-library/library.ts +42 -2
  82. package/src/engine/pipeline.ts +88 -0
  83. package/src/engine/projection-helpers.ts +1 -1
  84. package/src/engine/read-claim.ts +1 -1
  85. package/src/engine/registry.ts +30 -2
  86. package/src/engine/resolve-config-or-param.ts +4 -0
  87. package/src/engine/run-pipeline.ts +162 -0
  88. package/src/engine/schema-builder.ts +2 -4
  89. package/src/engine/state-machine.ts +1 -1
  90. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  91. package/src/engine/steps/_duration-utils.ts +33 -0
  92. package/src/engine/steps/_no-return-guard.ts +21 -0
  93. package/src/engine/steps/_resolver-utils.ts +42 -0
  94. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  95. package/src/engine/steps/aggregate-append-event.ts +56 -0
  96. package/src/engine/steps/aggregate-create.ts +56 -0
  97. package/src/engine/steps/aggregate-update.ts +68 -0
  98. package/src/engine/steps/branch.ts +84 -0
  99. package/src/engine/steps/call-feature.ts +49 -0
  100. package/src/engine/steps/compute.ts +41 -0
  101. package/src/engine/steps/for-each.ts +111 -0
  102. package/src/engine/steps/mail-send.ts +44 -0
  103. package/src/engine/steps/read-find-many.ts +51 -0
  104. package/src/engine/steps/read-find-one.ts +58 -0
  105. package/src/engine/steps/retry.ts +87 -0
  106. package/src/engine/steps/return.ts +34 -0
  107. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  108. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  109. package/src/engine/steps/wait-for-event.ts +71 -0
  110. package/src/engine/steps/wait.ts +69 -0
  111. package/src/engine/steps/webhook-send.ts +71 -0
  112. package/src/engine/system-user.ts +1 -1
  113. package/src/engine/types/feature.ts +92 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/identifiers.ts +1 -0
  116. package/src/engine/types/index.ts +12 -1
  117. package/src/engine/types/step.ts +334 -0
  118. package/src/engine/types/target-ref.ts +21 -0
  119. package/src/engine/types/tree-node.ts +130 -0
  120. package/src/engine/types/workspace.ts +7 -0
  121. package/src/engine/validate-projection-allowlist.ts +161 -0
  122. package/src/event-store/snapshot.ts +1 -1
  123. package/src/event-store/upcaster-dead-letter.ts +1 -1
  124. package/src/event-store/upcaster.ts +1 -1
  125. package/src/files/file-routes.ts +1 -1
  126. package/src/files/types.ts +2 -2
  127. package/src/jobs/job-runner.ts +10 -10
  128. package/src/lifecycle/lifecycle.ts +0 -3
  129. package/src/logging/index.ts +1 -0
  130. package/src/logging/pino-logger.ts +11 -7
  131. package/src/logging/utils.ts +24 -0
  132. package/src/observability/prometheus-meter.ts +7 -5
  133. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  134. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  135. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  136. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  137. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  138. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  139. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  140. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  141. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  142. package/src/pipeline/append-event-core.ts +22 -6
  143. package/src/pipeline/dispatcher-utils.ts +188 -0
  144. package/src/pipeline/dispatcher.ts +63 -283
  145. package/src/pipeline/distributed-lock.ts +1 -1
  146. package/src/pipeline/entity-cache.ts +2 -2
  147. package/src/pipeline/event-consumer-state.ts +0 -13
  148. package/src/pipeline/event-dispatcher.ts +4 -4
  149. package/src/pipeline/index.ts +0 -2
  150. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  151. package/src/pipeline/msp-rebuild.ts +5 -5
  152. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  153. package/src/pipeline/projection-rebuild.ts +2 -2
  154. package/src/pipeline/projection-state.ts +0 -12
  155. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  156. package/src/rate-limit/resolver.ts +1 -1
  157. package/src/search/in-memory-adapter.ts +1 -1
  158. package/src/search/meilisearch-adapter.ts +3 -3
  159. package/src/search/types.ts +1 -1
  160. package/src/secrets/leak-guard.ts +2 -2
  161. package/src/stack/request-helper.ts +9 -5
  162. package/src/stack/test-stack.ts +1 -1
  163. package/src/testing/handler-context.ts +4 -4
  164. package/src/testing/http-cookies.ts +1 -1
  165. package/src/time/tz-context.ts +1 -2
  166. package/src/ui-types/index.ts +4 -0
  167. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -0,0 +1,894 @@
1
+ // Pipeline dispatcher-integration test — exercises the perform-as-pipeline
2
+ // path through the full real stack (Postgres + JWT + HTTP). Covers:
3
+ //
4
+ // M.1.1 (return / boundary):
5
+ // - r.writeHandler(definitionObj) accepts the new output shape
6
+ // - boot-validation doesn't trip on the `perform` field
7
+ // - dispatcher parses the payload (Zod schema) BEFORE invoking handler
8
+ // - dispatcher checks access-rules BEFORE invoking handler
9
+ // - dispatcher hands the handler a real HandlerContext (~30 fields)
10
+ // - the compiled handler runs the pipeline-runner against that ctx
11
+ // - WriteResult lands on the HTTP caller
12
+ // - a step that throws maps to a standard write-failure (500 +
13
+ // internal_error) via the dispatcher's catch
14
+ //
15
+ // M.1.2 (compute):
16
+ // - multi-step pipeline threads compute results through to the
17
+ // return-resolver via steps.<name> against the real ctx
18
+ //
19
+ // M.1.3 (unsafeProjectionUpsert):
20
+ // - writes a row to a declared read-side table via real Postgres
21
+ // - is idempotent on the conflict-key — second write updates,
22
+ // not duplicates
23
+ //
24
+ // M.1.4 (aggregate.create / update / appendEvent):
25
+ // - aggregate.create opens an event-sourced aggregate stream via real
26
+ // event-store
27
+ // - executor failure (e.g. validation) maps to standard write-failure
28
+ // - aggregate.update writes a delta event + projection-row update on
29
+ // an existing stream
30
+ // - aggregate.appendEvent writes an additional domain-event onto an
31
+ // existing aggregate stream (alongside the auto-generated CRUD events)
32
+ //
33
+ // M.1.5 (read.findOne / read.findMany / unsafeProjectionDelete):
34
+ // - read.findOne returns a single row or null
35
+ // - read.findMany returns row[] (with optional limit)
36
+ // - unsafeProjectionDelete deletes via real Postgres
37
+ // - boot-validation rejects unsafeProjectionDelete on undeclared table
38
+ //
39
+ // M.1.6 (branch / forEach):
40
+ // - branch.onTrue runs side-effects against real Postgres conditionally
41
+ // - forEach.do iterates side-effects per item against real Postgres
42
+ //
43
+ // Unit-side tests in pipeline-vertical-slice.test.ts cover the same
44
+ // surface against an empty ctx mock; this file is the real-stack gate.
45
+
46
+ import { eq } from "drizzle-orm";
47
+ import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
48
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
49
+ import { z } from "zod";
50
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
51
+ import { buildDrizzleTable } from "../../db/table-builder";
52
+ import { eventsTable } from "../../event-store";
53
+ import {
54
+ setupTestStack,
55
+ type TestStack,
56
+ TestUsers,
57
+ unsafeCreateEntityTable,
58
+ unsafePushTables,
59
+ } from "../../stack";
60
+ import { defineFeature } from "../define-feature";
61
+ import { defineWriteHandler } from "../define-handler";
62
+ import { createEntity, createTextField } from "../factories";
63
+ import { pipeline } from "../pipeline";
64
+
65
+ const echoSchema = z.object({ greeting: z.string() });
66
+
67
+ const echoHandler = defineWriteHandler({
68
+ // Registry's qualify() prepends "<feature>:write:" — handler def-name
69
+ // is the short form only.
70
+ name: "echo",
71
+ schema: echoSchema,
72
+ access: { roles: ["Admin"] },
73
+ perform: pipeline<z.infer<typeof echoSchema>, { echoed: string; from: string }>(
74
+ ({ event, r }) => [
75
+ r.step.return(() => ({
76
+ isSuccess: true as const,
77
+ data: {
78
+ echoed: event.payload.greeting,
79
+ from: event.user.id,
80
+ },
81
+ })),
82
+ ],
83
+ ),
84
+ });
85
+
86
+ // Second handler whose pipeline throws — proves the dispatcher's catch
87
+ // maps step-thrown errors to the standard write-failure shape.
88
+ const explodeSchema = z.object({});
89
+ const explodeHandler = defineWriteHandler({
90
+ name: "explode",
91
+ schema: explodeSchema,
92
+ access: { roles: ["Admin"] },
93
+ perform: pipeline<z.infer<typeof explodeSchema>, never>(({ r }) => [
94
+ r.step.return(() => {
95
+ throw new Error("boom");
96
+ }),
97
+ ]),
98
+ });
99
+
100
+ // Third handler exercises the multi-step path through the real
101
+ // dispatcher: compute lands a value under steps.<name>, return reads it.
102
+ // Threading verified in the unit-test against an empty ctx; this proves
103
+ // the same wiring holds with the dispatcher's full HandlerContext.
104
+ const compoundSchema = z.object({ base: z.number() });
105
+ const compoundHandler = defineWriteHandler({
106
+ name: "compound",
107
+ schema: compoundSchema,
108
+ access: { roles: ["Admin"] },
109
+ perform: pipeline<z.infer<typeof compoundSchema>, { sum: number; userId: string }>(
110
+ ({ event, r }) => [
111
+ r.step.compute("offset", () => 100),
112
+ r.step.compute("doubledBase", () => event.payload.base * 2),
113
+ // Resolvers capture `event` from the outer build-closure scope rather
114
+ // than reading it via the resolver's PipelineCtx — `PipelineCtx<TPayload>`
115
+ // does not propagate the pipeline's TPayload generic to per-call
116
+ // resolvers (M.1-Followup #4), so `ctx.event.user.id` would type-erase.
117
+ // Outer-capture preserves the typed payload. Same pattern in logHandler
118
+ // and widgetCreateHandler below.
119
+ r.step.return(({ steps }) => ({
120
+ isSuccess: true as const,
121
+ data: {
122
+ sum: (steps["offset"] as number) + (steps["doubledBase"] as number),
123
+ userId: event.user.id,
124
+ },
125
+ })),
126
+ ],
127
+ ),
128
+ });
129
+
130
+ // Read-side projection-table for the unsafeProjectionUpsert handler.
131
+ // Plain pgTable (not r.entity) — it's a read-side log, not an aggregate.
132
+ const pipelineDemoLogTable = pgTable("pipeline_demo_log", {
133
+ id: uuid("id").primaryKey().defaultRandom(),
134
+ tenantId: uuid("tenant_id").notNull(),
135
+ correlationId: text("correlation_id").notNull().unique(),
136
+ message: text("message").notNull(),
137
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
138
+ });
139
+
140
+ // Fourth handler exercises r.step.unsafeProjectionUpsert: writes a row
141
+ // to the demo-log table after the pipeline runs. Idempotent on
142
+ // correlationId — running the same handler twice with the same id
143
+ // updates the existing row, not insert a duplicate.
144
+ const logSchema = z.object({ correlationId: z.string(), message: z.string() });
145
+ const logHandler = defineWriteHandler({
146
+ name: "log",
147
+ schema: logSchema,
148
+ access: { roles: ["Admin"] },
149
+ // Outer-`event`-capture pattern — see compoundHandler above for the why.
150
+ perform: pipeline<z.infer<typeof logSchema>, { correlationId: string }>(({ event, r }) => [
151
+ r.step.unsafeProjectionUpsert({
152
+ table: pipelineDemoLogTable,
153
+ on: ["correlationId"],
154
+ row: () => ({
155
+ tenantId: event.user.tenantId,
156
+ correlationId: event.payload.correlationId,
157
+ message: event.payload.message,
158
+ }),
159
+ }),
160
+ r.step.return(() => ({
161
+ isSuccess: true as const,
162
+ data: { correlationId: event.payload.correlationId },
163
+ })),
164
+ ]),
165
+ });
166
+
167
+ // Aggregate-entity for the M.1.4 aggregate.create test. Registered via
168
+ // r.entity inside the demoPipeline feature so the framework knows the
169
+ // table belongs to an aggregate stream (boot-validator would reject any
170
+ // unsafeProjection.* that targets it).
171
+ const widgetEntity = createEntity({
172
+ table: "pipeline_widget",
173
+ fields: { label: createTextField({ required: true }) },
174
+ });
175
+ const widgetTable = buildDrizzleTable("widget", widgetEntity);
176
+ const widgetExecutor = createEventStoreExecutor(widgetTable, widgetEntity, {
177
+ entityName: "widget",
178
+ });
179
+
180
+ const widgetSchema = z.object({ label: z.string() });
181
+ const widgetCreateHandler = defineWriteHandler({
182
+ name: "widget:create",
183
+ schema: widgetSchema,
184
+ access: { roles: ["Admin"] },
185
+ perform: pipeline<z.infer<typeof widgetSchema>, { id: string }>(({ event, r }) => [
186
+ r.step.aggregate.create("widget", {
187
+ executor: widgetExecutor,
188
+ data: () => ({ label: event.payload.label }),
189
+ }),
190
+ r.step.return(({ steps }) => ({
191
+ isSuccess: true as const,
192
+ data: { id: (steps["widget"] as { id: string }).id },
193
+ })),
194
+ ]),
195
+ });
196
+
197
+ // Annotate-handler for the M.1.4 aggregate.appendEvent test. Creates a
198
+ // widget AND appends a custom "widget.annotated" event onto the same
199
+ // aggregate stream — verifies multi-event-per-handler-call works.
200
+ const annotateSchema = z.object({ label: z.string(), note: z.string() });
201
+ const annotateHandler = defineWriteHandler({
202
+ name: "widget:annotate",
203
+ schema: annotateSchema,
204
+ access: { roles: ["Admin"] },
205
+ perform: pipeline<z.infer<typeof annotateSchema>, { id: string }>(({ event, r }) => [
206
+ r.step.aggregate.create("widget", {
207
+ executor: widgetExecutor,
208
+ data: () => ({ label: event.payload.label }),
209
+ }),
210
+ r.step.aggregate.appendEvent({
211
+ aggregateId: ({ steps }) => (steps["widget"] as { id: string }).id,
212
+ aggregateType: "widget",
213
+ // Type below is registered via r.defineEvent in the feature
214
+ // registration further down — keeps the demo feature self-
215
+ // contained for the test stack.
216
+ type: "demo-pipeline:event:annotated",
217
+ payload: () => ({ note: event.payload.note }),
218
+ }),
219
+ r.step.return(({ steps }) => ({
220
+ isSuccess: true as const,
221
+ data: { id: (steps["widget"] as { id: string }).id },
222
+ })),
223
+ ]),
224
+ });
225
+
226
+ // Update-handler for the M.1.4 aggregate.update test. Takes an
227
+ // existing widget id and rewrites the label.
228
+ const widgetUpdateSchema = z.object({ id: z.uuid(), label: z.string() });
229
+ const widgetUpdateHandler = defineWriteHandler({
230
+ name: "widget:update",
231
+ schema: widgetUpdateSchema,
232
+ access: { roles: ["Admin"] },
233
+ perform: pipeline<z.infer<typeof widgetUpdateSchema>, { id: string }>(({ event, r }) => [
234
+ r.step.aggregate.update("widget", {
235
+ executor: widgetExecutor,
236
+ id: () => event.payload.id,
237
+ changes: () => ({ label: event.payload.label }),
238
+ // skipOptimisticLock — test uses a single user, last-write-wins
239
+ // is fine; full version-check exercise belongs to a dedicated
240
+ // optimistic-lock test elsewhere.
241
+ skipOptimisticLock: true,
242
+ }),
243
+ r.step.return(({ steps }) => ({
244
+ isSuccess: true as const,
245
+ data: { id: (steps["widget"] as { id: string }).id },
246
+ })),
247
+ ]),
248
+ });
249
+
250
+ // Companion handler that triggers an executor-failure path: missing
251
+ // required field → UnprocessableError → re-raised by the step → mapped
252
+ // to write-failure by the dispatcher.
253
+ const widgetBrokenHandler = defineWriteHandler({
254
+ name: "widget:create-broken",
255
+ schema: z.object({}),
256
+ access: { roles: ["Admin"] },
257
+ perform: pipeline<Record<string, never>, { id: string }>(({ r }) => [
258
+ r.step.aggregate.create("widget", {
259
+ executor: widgetExecutor,
260
+ // Intentionally omits the `label` field that the entity declares
261
+ // as required — executor.create returns WriteFailure, the step
262
+ // re-raises as KumikoError.
263
+ data: () => ({}),
264
+ }),
265
+ r.step.return(({ steps }) => ({
266
+ isSuccess: true as const,
267
+ data: { id: (steps["widget"] as { id: string }).id },
268
+ })),
269
+ ]),
270
+ });
271
+
272
+ // Rollback test: aggregate.create succeeds, then a later compute step
273
+ // throws. The dispatcher's TX wraps the whole handler — both the
274
+ // aggregate event AND the projection row from create() must roll back.
275
+ // Without TX-rollback, the handler-form would silently leak partial
276
+ // state on a mid-pipeline failure.
277
+ const widgetCreateThenThrowHandler = defineWriteHandler({
278
+ name: "widget:create-then-throw",
279
+ schema: z.object({ label: z.string() }),
280
+ access: { roles: ["Admin"] },
281
+ perform: pipeline<{ label: string }, never>(({ event, r }) => [
282
+ r.step.aggregate.create("widget", {
283
+ executor: widgetExecutor,
284
+ data: () => ({ label: event.payload.label }),
285
+ }),
286
+ r.step.compute("explode", () => {
287
+ throw new Error("rollback-test: throwing AFTER aggregate.create");
288
+ }),
289
+ r.step.return({ isSuccess: true as const, data: undefined as never }),
290
+ ]),
291
+ });
292
+
293
+ // forEach mid-iteration rollback: items[0] commits an aggregate.create,
294
+ // items[1] throws. The whole TX must roll back — including items[0]'s
295
+ // already-committed aggregate. This is a different code-path from the
296
+ // linear rollback test above because forEach has its own scope save/
297
+ // restore + sub-step-list runner, and a regression there would let
298
+ // iteration-1's writes leak past the iteration-2 throw.
299
+ const forEachThenThrowSchema = z.object({
300
+ labels: z.array(z.string()).min(2),
301
+ });
302
+ const forEachThenThrowHandler = defineWriteHandler({
303
+ name: "widget:foreach-then-throw",
304
+ schema: forEachThenThrowSchema,
305
+ access: { roles: ["Admin"] },
306
+ perform: pipeline<{ labels: string[] }, never>(({ event, r }) => [
307
+ r.step.forEach({
308
+ over: () => event.payload.labels,
309
+ as: "label",
310
+ do: [
311
+ r.step.aggregate.create("widget", {
312
+ executor: widgetExecutor,
313
+ data: ({ scope }) => ({ label: scope["label"] as string }),
314
+ }),
315
+ r.step.compute("checkBoom", ({ scope }) => {
316
+ if (scope["label"] === "BOOM") {
317
+ throw new Error("rollback-test: forEach iteration threw mid-loop");
318
+ }
319
+ return null;
320
+ }),
321
+ ],
322
+ }),
323
+ r.step.return({ isSuccess: true as const, data: undefined as never }),
324
+ ]),
325
+ });
326
+
327
+ // M.1.5 handlers — read + projection-delete. lookup-then-update reads
328
+ // from widgetTable (an aggregate-projection — fine for read.*, only
329
+ // writes are blocked by the boot-validator). delete-log purges old
330
+ // rows from the demo-log read-side table.
331
+ // Single shape (no narrowing union) so the pipeline's TData generic
332
+ // matches the resolver's return type cleanly — sidesteps the
333
+ // M.1-Followup #4 inference limit. The `label: string | null` shape is
334
+ // idiomatic anyway: caller checks `label !== null` to distinguish hit/miss.
335
+ const lookupSchema = z.object({ id: z.uuid() });
336
+ const lookupHandler = defineWriteHandler({
337
+ name: "widget:lookup",
338
+ schema: lookupSchema,
339
+ access: { roles: ["Admin"] },
340
+ perform: pipeline<z.infer<typeof lookupSchema>, { found: boolean; label: string | null }>(
341
+ ({ event, r }) => [
342
+ r.step.read.findOne("widget", {
343
+ table: widgetTable,
344
+ where: () => eq(widgetTable.id, event.payload.id),
345
+ }),
346
+ r.step.return(({ steps }) => {
347
+ const row = steps["widget"] as { label?: string } | null;
348
+ return {
349
+ isSuccess: true as const,
350
+ data: { found: row !== null, label: row?.label ?? null },
351
+ };
352
+ }),
353
+ ],
354
+ ),
355
+ });
356
+
357
+ const listAllHandler = defineWriteHandler({
358
+ name: "widget:list",
359
+ schema: z.object({}),
360
+ access: { roles: ["Admin"] },
361
+ perform: pipeline<Record<string, never>, { count: number }>(({ r }) => [
362
+ r.step.read.findMany("widgets", { table: widgetTable }),
363
+ r.step.return(({ steps }) => ({
364
+ isSuccess: true as const,
365
+ data: { count: (steps["widgets"] as readonly unknown[]).length },
366
+ })),
367
+ ]),
368
+ });
369
+
370
+ // Limit-1 listing exercises BOTH the limit-clause path in findMany AND
371
+ // (transitively) the same query-builder code-path that findOne with no
372
+ // where-clause would walk — closes both ungated branches with one test.
373
+ const listLimitedHandler = defineWriteHandler({
374
+ name: "widget:list-one",
375
+ schema: z.object({}),
376
+ access: { roles: ["Admin"] },
377
+ perform: pipeline<Record<string, never>, { count: number }>(({ r }) => [
378
+ r.step.read.findMany("widgets", { table: widgetTable, limit: 1 }),
379
+ r.step.return(({ steps }) => ({
380
+ isSuccess: true as const,
381
+ data: { count: (steps["widgets"] as readonly unknown[]).length },
382
+ })),
383
+ ]),
384
+ });
385
+
386
+ const purgeLogSchema = z.object({ correlationId: z.string() });
387
+ const purgeLogHandler = defineWriteHandler({
388
+ name: "log:purge",
389
+ schema: purgeLogSchema,
390
+ access: { roles: ["Admin"] },
391
+ perform: pipeline<z.infer<typeof purgeLogSchema>, { ok: true }>(({ event, r }) => [
392
+ r.step.unsafeProjectionDelete({
393
+ table: pipelineDemoLogTable,
394
+ where: () => eq(pipelineDemoLogTable.correlationId, event.payload.correlationId),
395
+ }),
396
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
397
+ ]),
398
+ });
399
+
400
+ // M.1.6 — branch: log only when the message is non-empty.
401
+ const conditionalLogSchema = z.object({ correlationId: z.string(), message: z.string() });
402
+ const conditionalLogHandler = defineWriteHandler({
403
+ name: "log:conditional",
404
+ schema: conditionalLogSchema,
405
+ access: { roles: ["Admin"] },
406
+ perform: pipeline<z.infer<typeof conditionalLogSchema>, { ok: true }>(({ event, r }) => [
407
+ r.step.branch({
408
+ if: () => event.payload.message.length > 0,
409
+ onTrue: [
410
+ r.step.unsafeProjectionUpsert({
411
+ table: pipelineDemoLogTable,
412
+ on: ["correlationId"],
413
+ row: () => ({
414
+ tenantId: event.user.tenantId,
415
+ correlationId: event.payload.correlationId,
416
+ message: event.payload.message,
417
+ }),
418
+ }),
419
+ ],
420
+ }),
421
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
422
+ ]),
423
+ });
424
+
425
+ // M.1.6 — forEach: bulk-log a list of correlationIds in one handler-call.
426
+ const bulkLogSchema = z.object({ correlationIds: z.array(z.string()) });
427
+ const bulkLogHandler = defineWriteHandler({
428
+ name: "log:bulk",
429
+ schema: bulkLogSchema,
430
+ access: { roles: ["Admin"] },
431
+ perform: pipeline<z.infer<typeof bulkLogSchema>, { count: number }>(({ event, r }) => [
432
+ r.step.forEach({
433
+ over: () => event.payload.correlationIds,
434
+ as: "correlationId",
435
+ do: [
436
+ r.step.unsafeProjectionUpsert({
437
+ table: pipelineDemoLogTable,
438
+ on: ["correlationId"],
439
+ row: ({ scope }) => ({
440
+ tenantId: event.user.tenantId,
441
+ correlationId: scope["correlationId"] as string,
442
+ message: "bulk",
443
+ }),
444
+ }),
445
+ ],
446
+ }),
447
+ r.step.return(() => ({
448
+ isSuccess: true as const,
449
+ data: { count: event.payload.correlationIds.length },
450
+ })),
451
+ ]),
452
+ });
453
+
454
+ const demoPipelineFeature = defineFeature("demoPipeline", (r) => {
455
+ r.requires.projection("pipeline_demo_log");
456
+ r.entity("widget", widgetEntity);
457
+ r.defineEvent("annotated", z.object({ note: z.string() }));
458
+ r.writeHandler(echoHandler);
459
+ r.writeHandler(explodeHandler);
460
+ r.writeHandler(compoundHandler);
461
+ r.writeHandler(logHandler);
462
+ r.writeHandler(widgetCreateHandler);
463
+ r.writeHandler(widgetBrokenHandler);
464
+ r.writeHandler(widgetCreateThenThrowHandler);
465
+ r.writeHandler(forEachThenThrowHandler);
466
+ r.writeHandler(widgetUpdateHandler);
467
+ r.writeHandler(annotateHandler);
468
+ r.writeHandler(lookupHandler);
469
+ r.writeHandler(listAllHandler);
470
+ r.writeHandler(listLimitedHandler);
471
+ r.writeHandler(purgeLogHandler);
472
+ r.writeHandler(conditionalLogHandler);
473
+ r.writeHandler(bulkLogHandler);
474
+ });
475
+
476
+ let stack: TestStack;
477
+ const admin = TestUsers.admin;
478
+
479
+ describe("defineWriteHandler({ perform: pipeline(...) }) — real dispatcher path", () => {
480
+ beforeAll(async () => {
481
+ stack = await setupTestStack({ features: [demoPipelineFeature] });
482
+ // Push the read-side-projection table — not registered as an entity,
483
+ // so push-entity-projection-tables doesn't pick it up automatically.
484
+ await unsafePushTables(stack.db, { pipeline_demo_log: pipelineDemoLogTable });
485
+ // Aggregate-projection table for the widget entity. setupTestStack
486
+ // doesn't push entity-tables out of the box for ad-hoc test entities.
487
+ await unsafeCreateEntityTable(stack.db, widgetEntity);
488
+ });
489
+
490
+ afterAll(async () => {
491
+ await stack.cleanup();
492
+ });
493
+
494
+ beforeEach(async () => {
495
+ // Truncate per-test so widget-related tests don't accumulate state
496
+ // across runs — order-independent assertions are the only kind that
497
+ // stay green when test-files grow (Memory `feedback_jsdom_lies_*`
498
+ // analog: order-dependent tests are flaky-in-waiting).
499
+ await stack.db.delete(pipelineDemoLogTable);
500
+ await stack.db.delete(widgetTable);
501
+ });
502
+
503
+ test("HTTP write call goes through dispatcher → pipeline-runner → r.step.return", async () => {
504
+ const res = await stack.http.write(
505
+ "demo-pipeline:write:echo",
506
+ { greeting: "hallo welt" },
507
+ admin,
508
+ );
509
+ expect(res.status).toBe(200);
510
+
511
+ const body = (await res.json()) as { isSuccess: true; data: { echoed: string; from: string } };
512
+ expect(body.isSuccess).toBe(true);
513
+ expect(body.data).toEqual({
514
+ echoed: "hallo welt",
515
+ from: admin.id,
516
+ });
517
+ });
518
+
519
+ test("dispatcher rejects the call when payload fails Zod validation (schema runs BEFORE pipeline)", async () => {
520
+ // Pipeline-runner shouldn't even fire — the dispatcher's parse-stage
521
+ // catches the type mismatch and returns a validation error.
522
+ const res = await stack.http.write(
523
+ "demo-pipeline:write:echo",
524
+ // Intentional type-mismatch — stack.http.write accepts unknown
525
+ // payload, the dispatcher's Zod parse rejects it with 400.
526
+ { greeting: 42 },
527
+ admin,
528
+ );
529
+ expect(res.status).toBe(400);
530
+
531
+ const body = (await res.json()) as { isSuccess: false; error: { code: string } };
532
+ expect(body.isSuccess).toBe(false);
533
+ expect(body.error.code).toBe("validation_error");
534
+ });
535
+
536
+ test("dispatcher rejects the call when the user lacks the handler's role (access runs BEFORE pipeline)", async () => {
537
+ // Access-check is a different boundary than schema-validation —
538
+ // verify it also fires before the pipeline is built/executed.
539
+ // TestUsers.user has role "User", handler requires "Admin".
540
+ const res = await stack.http.write(
541
+ "demo-pipeline:write:echo",
542
+ { greeting: "should not pass" },
543
+ TestUsers.user,
544
+ );
545
+ expect(res.status).toBe(403);
546
+
547
+ const body = (await res.json()) as { isSuccess: false; error: { code: string } };
548
+ expect(body.isSuccess).toBe(false);
549
+ expect(body.error.code).toBe("access_denied");
550
+ });
551
+
552
+ test("compute steps thread results through to the return-step's resolver via the real dispatcher ctx", async () => {
553
+ const res = await stack.http.write("demo-pipeline:write:compound", { base: 7 }, admin);
554
+ expect(res.status).toBe(200);
555
+
556
+ const body = (await res.json()) as {
557
+ isSuccess: true;
558
+ data: { sum: number; userId: string };
559
+ };
560
+ expect(body.isSuccess).toBe(true);
561
+ // 100 (offset) + 14 (base * 2) = 114
562
+ expect(body.data.sum).toBe(114);
563
+ expect(body.data.userId).toBe(admin.id);
564
+ });
565
+
566
+ test("unsafeProjectionUpsert writes a row to a declared read-side table via real Postgres", async () => {
567
+ const res = await stack.http.write(
568
+ "demo-pipeline:write:log",
569
+ { correlationId: "corr-1", message: "first write" },
570
+ admin,
571
+ );
572
+ expect(res.status).toBe(200);
573
+
574
+ const rows = await stack.db.select().from(pipelineDemoLogTable);
575
+ expect(rows).toHaveLength(1);
576
+ expect(rows[0]).toMatchObject({
577
+ correlationId: "corr-1",
578
+ message: "first write",
579
+ tenantId: admin.tenantId,
580
+ });
581
+ });
582
+
583
+ test("unsafeProjectionUpsert is idempotent on the conflict-key — second write updates, not inserts", async () => {
584
+ await stack.http.write(
585
+ "demo-pipeline:write:log",
586
+ { correlationId: "corr-2", message: "v1" },
587
+ admin,
588
+ );
589
+ await stack.http.write(
590
+ "demo-pipeline:write:log",
591
+ { correlationId: "corr-2", message: "v2 — overwritten" },
592
+ admin,
593
+ );
594
+
595
+ const rows = await stack.db
596
+ .select()
597
+ .from(pipelineDemoLogTable)
598
+ .where(eq(pipelineDemoLogTable.correlationId, "corr-2"));
599
+ expect(rows).toHaveLength(1);
600
+ expect(rows[0]?.message).toBe("v2 — overwritten");
601
+ });
602
+
603
+ test("aggregate.create opens a stream via the real event-store and lands the SaveContext under steps.<name>", async () => {
604
+ const res = await stack.http.write(
605
+ "demo-pipeline:write:widget:create",
606
+ { label: "first widget" },
607
+ admin,
608
+ );
609
+ expect(res.status).toBe(200);
610
+
611
+ const body = (await res.json()) as { isSuccess: true; data: { id: string } };
612
+ expect(body.isSuccess).toBe(true);
613
+ expect(body.data.id).toMatch(/^[0-9a-f-]{36}$/i);
614
+
615
+ // Verify the projection-row landed too — the executor's inline
616
+ // projection writes into the widget table in the same TX.
617
+ const rows = await stack.db.select().from(widgetTable);
618
+ expect(rows).toHaveLength(1);
619
+ expect(rows[0]).toMatchObject({ label: "first widget", id: body.data.id });
620
+ });
621
+
622
+ test("aggregate.update writes a delta event + projection-row update on an existing stream", async () => {
623
+ // Seed: create a widget first.
624
+ const created = await stack.http.write(
625
+ "demo-pipeline:write:widget:create",
626
+ { label: "before" },
627
+ admin,
628
+ );
629
+ const createdBody = (await created.json()) as { isSuccess: true; data: { id: string } };
630
+ const widgetId = createdBody.data.id;
631
+
632
+ // Update its label.
633
+ const updated = await stack.http.write(
634
+ "demo-pipeline:write:widget:update",
635
+ { id: widgetId, label: "after" },
636
+ admin,
637
+ );
638
+ expect(updated.status).toBe(200);
639
+ const updatedBody = (await updated.json()) as { isSuccess: true; data: { id: string } };
640
+ expect(updatedBody.data.id).toBe(widgetId);
641
+
642
+ // Projection-row reflects the new label.
643
+ const rows = await stack.db.select().from(widgetTable).where(eq(widgetTable.id, widgetId));
644
+ expect(rows).toHaveLength(1);
645
+ expect(rows[0]).toMatchObject({ id: widgetId, label: "after" });
646
+ });
647
+
648
+ test("aggregate.appendEvent writes an additional domain event onto the same aggregate stream", async () => {
649
+ const res = await stack.http.write(
650
+ "demo-pipeline:write:widget:annotate",
651
+ { label: "annotated widget", note: "first note" },
652
+ admin,
653
+ );
654
+ expect(res.status).toBe(200);
655
+ const body = (await res.json()) as { isSuccess: true; data: { id: string } };
656
+ const widgetId = body.data.id;
657
+
658
+ // The aggregate stream should carry both the auto-generated CRUD
659
+ // event (widget.created) AND the appended annotated event. Direct
660
+ // event-store query is the simplest assertion.
661
+ const events = await stack.db
662
+ .select()
663
+ .from(eventsTable)
664
+ .where(eq(eventsTable.aggregateId, widgetId));
665
+ const types = events.map((e) => e["type"]);
666
+ expect(types).toContain("demo-pipeline:event:annotated");
667
+ expect(types.length).toBeGreaterThanOrEqual(2); // created + annotated
668
+ });
669
+
670
+ test("aggregate.create executor-failure surfaces as a write-failure (non-2xx, isSuccess: false)", async () => {
671
+ // widgetBrokenHandler intentionally drops the required `label` —
672
+ // executor.create runs into a NOT NULL constraint violation. The
673
+ // step re-raises (or the executor returns WriteFailure), and the
674
+ // dispatcher catches + serialises to the standard failure shape.
675
+ // We don't pin the exact status — schema-level validation is a
676
+ // 4xx, DB-constraint mapping in the executor lands as 5xx
677
+ // depending on driver. The point is: failure visible, no leaked row.
678
+
679
+ // Capture row-count before — beforeEach doesn't truncate widget,
680
+ // earlier tests in this run leave rows. We assert "broken did not
681
+ // add a new row" by comparing before/after.
682
+ const before = await stack.db.select().from(widgetTable);
683
+
684
+ const res = await stack.http.write("demo-pipeline:write:widget:create-broken", {}, admin);
685
+ expect(res.status).not.toBe(200);
686
+
687
+ const body = (await res.json()) as { isSuccess: false; error: { code: string } };
688
+ expect(body.isSuccess).toBe(false);
689
+ expect(body.error.code).toBeDefined();
690
+
691
+ const after = await stack.db.select().from(widgetTable);
692
+ expect(after).toHaveLength(before.length);
693
+ });
694
+
695
+ test("read.findOne returns the row when present and null when not", async () => {
696
+ // Seed a widget so we have something to look up.
697
+ const created = await stack.http.write(
698
+ "demo-pipeline:write:widget:create",
699
+ { label: "lookup-target" },
700
+ admin,
701
+ );
702
+ const createdBody = (await created.json()) as { isSuccess: true; data: { id: string } };
703
+ const widgetId = createdBody.data.id;
704
+
705
+ // Hit: row exists.
706
+ const hit = await stack.http.write(
707
+ "demo-pipeline:write:widget:lookup",
708
+ { id: widgetId },
709
+ admin,
710
+ );
711
+ const hitBody = (await hit.json()) as {
712
+ isSuccess: true;
713
+ data: { found: boolean; label: string | null };
714
+ };
715
+ expect(hitBody.data).toEqual({ found: true, label: "lookup-target" });
716
+
717
+ // Miss: random uuid → null → found:false, label:null.
718
+ const miss = await stack.http.write(
719
+ "demo-pipeline:write:widget:lookup",
720
+ { id: "00000000-0000-4000-8000-000000000000" },
721
+ admin,
722
+ );
723
+ const missBody = (await miss.json()) as {
724
+ isSuccess: true;
725
+ data: { found: boolean; label: string | null };
726
+ };
727
+ expect(missBody.data).toEqual({ found: false, label: null });
728
+ });
729
+
730
+ test("read.findMany returns the row array (count matches table state)", async () => {
731
+ const before = await stack.db.select().from(widgetTable);
732
+ const res = await stack.http.write("demo-pipeline:write:widget:list", {}, admin);
733
+ expect(res.status).toBe(200);
734
+ const body = (await res.json()) as { isSuccess: true; data: { count: number } };
735
+ expect(body.data.count).toBe(before.length);
736
+ });
737
+
738
+ test("read.findMany honours the limit argument", async () => {
739
+ // Seed enough widgets so a limit-1 result is verifiably truncated.
740
+ await stack.http.write("demo-pipeline:write:widget:create", { label: "limit-test-1" }, admin);
741
+ await stack.http.write("demo-pipeline:write:widget:create", { label: "limit-test-2" }, admin);
742
+ const total = await stack.db.select().from(widgetTable);
743
+ expect(total.length).toBeGreaterThanOrEqual(2);
744
+
745
+ const res = await stack.http.write("demo-pipeline:write:widget:list-one", {}, admin);
746
+ expect(res.status).toBe(200);
747
+ const body = (await res.json()) as { isSuccess: true; data: { count: number } };
748
+ expect(body.data.count).toBe(1);
749
+ });
750
+
751
+ test("unsafeProjectionDelete is a silent no-op when the where-clause matches no rows", async () => {
752
+ // Precondition: nothing with this correlation-id exists.
753
+ const before = await stack.db
754
+ .select()
755
+ .from(pipelineDemoLogTable)
756
+ .where(eq(pipelineDemoLogTable.correlationId, "no-match-id"));
757
+ expect(before).toHaveLength(0);
758
+
759
+ // Purge anyway — drizzle's DELETE-WHERE with no match commits silently.
760
+ const res = await stack.http.write(
761
+ "demo-pipeline:write:log:purge",
762
+ { correlationId: "no-match-id" },
763
+ admin,
764
+ );
765
+ expect(res.status).toBe(200);
766
+ const body = (await res.json()) as { isSuccess: true };
767
+ expect(body.isSuccess).toBe(true);
768
+ });
769
+
770
+ test("unsafeProjectionDelete removes matching rows from the read-side table", async () => {
771
+ // Seed a row via the existing log-handler, then purge it.
772
+ await stack.http.write(
773
+ "demo-pipeline:write:log",
774
+ { correlationId: "to-purge", message: "delete-me" },
775
+ admin,
776
+ );
777
+ const seeded = await stack.db
778
+ .select()
779
+ .from(pipelineDemoLogTable)
780
+ .where(eq(pipelineDemoLogTable.correlationId, "to-purge"));
781
+ expect(seeded).toHaveLength(1);
782
+
783
+ const res = await stack.http.write(
784
+ "demo-pipeline:write:log:purge",
785
+ { correlationId: "to-purge" },
786
+ admin,
787
+ );
788
+ expect(res.status).toBe(200);
789
+
790
+ const after = await stack.db
791
+ .select()
792
+ .from(pipelineDemoLogTable)
793
+ .where(eq(pipelineDemoLogTable.correlationId, "to-purge"));
794
+ expect(after).toHaveLength(0);
795
+ });
796
+
797
+ test("branch.onTrue runs the side-effect when the condition is truthy", async () => {
798
+ const res = await stack.http.write(
799
+ "demo-pipeline:write:log:conditional",
800
+ { correlationId: "branch-truthy", message: "real" },
801
+ admin,
802
+ );
803
+ expect(res.status).toBe(200);
804
+ const rows = await stack.db
805
+ .select()
806
+ .from(pipelineDemoLogTable)
807
+ .where(eq(pipelineDemoLogTable.correlationId, "branch-truthy"));
808
+ expect(rows).toHaveLength(1);
809
+ expect(rows[0]?.message).toBe("real");
810
+ });
811
+
812
+ test("branch.onTrue is skipped when the condition is falsy (no row written)", async () => {
813
+ const res = await stack.http.write(
814
+ "demo-pipeline:write:log:conditional",
815
+ { correlationId: "branch-falsy", message: "" },
816
+ admin,
817
+ );
818
+ expect(res.status).toBe(200);
819
+ const rows = await stack.db
820
+ .select()
821
+ .from(pipelineDemoLogTable)
822
+ .where(eq(pipelineDemoLogTable.correlationId, "branch-falsy"));
823
+ expect(rows).toHaveLength(0);
824
+ });
825
+
826
+ test("forEach.do iterates side-effects per item against real Postgres", async () => {
827
+ const ids = ["bulk-a", "bulk-b", "bulk-c"];
828
+ const res = await stack.http.write(
829
+ "demo-pipeline:write:log:bulk",
830
+ { correlationIds: ids },
831
+ admin,
832
+ );
833
+ expect(res.status).toBe(200);
834
+ const body = (await res.json()) as { isSuccess: true; data: { count: number } };
835
+ expect(body.data.count).toBe(3);
836
+
837
+ const rows = await stack.db.select().from(pipelineDemoLogTable);
838
+ const correlationIds = rows.map((r) => r["correlationId"]).sort();
839
+ expect(correlationIds).toEqual(ids.slice().sort());
840
+ });
841
+
842
+ test("a step that throws maps to a standard write-failure (dispatcher catch)", async () => {
843
+ // The pipeline-runner doesn't wrap step exceptions in M.1.1 (the
844
+ // "throw" failure-strategy is the only one supported). The dispatcher
845
+ // must catch and surface the error as a normal WriteFailure shape.
846
+ const res = await stack.http.write("demo-pipeline:write:explode", {}, admin);
847
+ expect(res.status).toBe(500);
848
+
849
+ const body = (await res.json()) as { isSuccess: false; error: { code: string } };
850
+ expect(body.isSuccess).toBe(false);
851
+ expect(body.error.code).toBe("internal_error");
852
+ });
853
+
854
+ test("aggregate.create rolls back when a later step throws (no event, no projection-row)", async () => {
855
+ // Critical correctness check: pipeline-form handlers must obey the
856
+ // dispatcher's TX boundary. aggregate.create succeeds, then a
857
+ // compute step throws — both the event-store append AND the
858
+ // projection-row insert that aggregate.create performed must
859
+ // disappear with the rollback. Otherwise pipeline-form handlers
860
+ // silently leak partial state on mid-pipeline failures.
861
+ const beforeWidgets = await stack.db.select().from(widgetTable);
862
+ const beforeEvents = await stack.db.select().from(eventsTable);
863
+
864
+ const res = await stack.http.write(
865
+ "demo-pipeline:write:widget:create-then-throw",
866
+ { label: "should-not-persist" },
867
+ admin,
868
+ );
869
+ expect(res.status).toBe(500);
870
+
871
+ const afterWidgets = await stack.db.select().from(widgetTable);
872
+ const afterEvents = await stack.db.select().from(eventsTable);
873
+ expect(afterWidgets.length).toBe(beforeWidgets.length);
874
+ expect(afterEvents.length).toBe(beforeEvents.length);
875
+ });
876
+
877
+ test("forEach mid-iteration throw rolls back ALL prior iterations", async () => {
878
+ // Different code-path from the linear-rollback test: forEach has
879
+ // its own scope save/restore + sub-step-list runner. A regression
880
+ // there would let iteration-1's aggregate.create commit while
881
+ // iteration-2's throw aborts the rest, leaving partial state.
882
+ const beforeWidgets = await stack.db.select().from(widgetTable);
883
+
884
+ const res = await stack.http.write(
885
+ "demo-pipeline:write:widget:foreach-then-throw",
886
+ { labels: ["first-ok", "BOOM", "third-never-runs"] },
887
+ admin,
888
+ );
889
+ expect(res.status).toBe(500);
890
+
891
+ const afterWidgets = await stack.db.select().from(widgetTable);
892
+ expect(afterWidgets.length).toBe(beforeWidgets.length);
893
+ });
894
+ });