@cosmicdrift/kumiko-framework 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/package.json +124 -39
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/compliance/profiles.ts +8 -8
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +3 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1804
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +88 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/factories.ts +12 -12
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +7 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +49 -1
- package/src/engine/feature-ast/render.ts +17 -1
- package/src/engine/index.ts +44 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
- package/src/engine/pattern-library/library.ts +42 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +2 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +93 -1
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/index.ts +11 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +132 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/file-routes.ts +1 -1
- package/src/files/types.ts +2 -2
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- 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
|
+
});
|