@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,142 @@
1
+ // Pipeline-engine observability integration test.
2
+ //
3
+ // Verifies that step.run sees a working ctx.tracer + ctx.metrics —
4
+ // not the noop provider — and that any spans/metrics emitted inside
5
+ // a step land in the same recording provider that the dispatcher
6
+ // uses for its own spans.
7
+ //
8
+ // Why this is a prod-readiness check, not a "nice-to-have": handlers
9
+ // without observability are invisible in prod. The dispatcher already
10
+ // emits a `write.handler` span around every handler invocation; the
11
+ // pipeline-runner runs INSIDE that span. If the pipeline-runner
12
+ // accidentally substituted a noop tracer (or stripped the ctx field),
13
+ // every step inside a pipeline-form handler would be untraceable —
14
+ // and we wouldn't notice until a prod incident lacks the breadcrumbs.
15
+
16
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
17
+ import { z } from "zod";
18
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
19
+ import { createRecordingProvider, type RecordingProvider } from "../../testing";
20
+ import { defineFeature } from "../define-feature";
21
+ import { defineWriteHandler } from "../define-handler";
22
+ import { createEntity, createTextField } from "../factories";
23
+ import { pipeline } from "../pipeline";
24
+
25
+ // Handler whose step bodies emit a span + a metric. The recording
26
+ // provider afterwards lets us assert both landed.
27
+ const observedSchema = z.object({});
28
+
29
+ const observedHandler = defineWriteHandler({
30
+ name: "observed",
31
+ schema: observedSchema,
32
+ access: { roles: ["Admin"] },
33
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
34
+ r.step.compute("traced", (ctx) => {
35
+ // Span emitted from inside a step. End it explicitly because
36
+ // step.run is sync-or-async-but-not-otel-instrumented; the
37
+ // recording provider only persists spans on .end().
38
+ const span = ctx.tracer.startSpan("test:step.compute.traced");
39
+ span.end();
40
+ return null;
41
+ }),
42
+ r.step.compute("metered", (ctx) => {
43
+ // Note: MetricsHandle.inc is (name, labels?, value?) — labels comes
44
+ // BEFORE value. Argument order trips a lot of authors; the engine-
45
+ // wide convention follows the OpenTelemetry meter shape.
46
+ ctx.metrics.inc("test_step_counter_total", { step_name: "metered" }, 1);
47
+ return null;
48
+ }),
49
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
50
+ ]),
51
+ });
52
+
53
+ const obsEntity = createEntity({
54
+ table: "obs_smoke_things",
55
+ fields: { label: createTextField({ required: true }) },
56
+ });
57
+
58
+ const obsFeature = defineFeature("obstest", (r) => {
59
+ r.entity("obs-thing", obsEntity);
60
+ // Pre-register the counter the step body emits — feature-scoped
61
+ // metric names get a `kumiko_<feature>_` prefix at boot.
62
+ r.metric("test_step_counter_total", {
63
+ type: "counter",
64
+ labels: ["step_name"],
65
+ });
66
+ r.writeHandler(observedHandler);
67
+ });
68
+
69
+ let stack: TestStack;
70
+ let provider: RecordingProvider;
71
+
72
+ beforeAll(async () => {
73
+ provider = createRecordingProvider();
74
+ stack = await setupTestStack({
75
+ features: [obsFeature],
76
+ systemHooks: [],
77
+ observability: provider,
78
+ });
79
+ await unsafeCreateEntityTable(stack.db, obsEntity, "obs-thing");
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await stack.cleanup();
84
+ });
85
+
86
+ beforeEach(async () => {
87
+ provider.reset();
88
+ await stack.redis.flushNamespace();
89
+ });
90
+
91
+ describe("pipeline-engine observability inheritance", () => {
92
+ test("ctx.tracer.startSpan inside a step is recorded by the dispatcher's provider", async () => {
93
+ await stack.http.writeOk("obstest:write:observed", {}, TestUsers.admin);
94
+
95
+ const stepSpans = provider.spansByName("test:step.compute.traced");
96
+ expect(stepSpans).toHaveLength(1);
97
+
98
+ // Sanity: the dispatcher's own span exists too — confirms the
99
+ // step-span isn't somehow replacing it.
100
+ const writeHandlerSpans = provider.spans.filter((s) => s.name.includes("handler"));
101
+ expect(writeHandlerSpans.length).toBeGreaterThan(0);
102
+ });
103
+
104
+ test("ctx.metrics.inc inside a step lands in the dispatcher's metric stream", async () => {
105
+ await stack.http.writeOk("obstest:write:observed", {}, TestUsers.admin);
106
+
107
+ // Feature-scoped metric names get a `kumiko_<feature>_` prefix at boot.
108
+ const stepMetrics = provider.metricEvents.filter(
109
+ (m) => m.name === "kumiko_obstest_test_step_counter_total",
110
+ );
111
+ expect(stepMetrics).toHaveLength(1);
112
+ expect(stepMetrics[0]).toMatchObject({
113
+ type: "counter.inc",
114
+ name: "kumiko_obstest_test_step_counter_total",
115
+ value: 1,
116
+ labels: { step_name: "metered" },
117
+ });
118
+ });
119
+
120
+ test("step-emitted spans are explicit children of the dispatcher's handler span", async () => {
121
+ // Trace-correlation: a span emitted from inside step.run must
122
+ // belong to the same trace as the dispatcher's outer handler
123
+ // span AND be a child of it (parentSpanId resolves to a span
124
+ // whose name contains "handler"). The earlier weaker assertion
125
+ // (sameTrace.length > 1) would have passed even if a future
126
+ // regression gave step-spans a fresh traceId — multiple spans
127
+ // in the same trace can be coincidence.
128
+ await stack.http.writeOk("obstest:write:observed", {}, TestUsers.admin);
129
+
130
+ const stepSpan = provider.spansByName("test:step.compute.traced")[0];
131
+ expect(stepSpan).toBeDefined();
132
+ expect(stepSpan!.parentSpanId).toBeDefined();
133
+
134
+ // Resolve the parent span explicitly — it must exist within the
135
+ // same trace AND name a handler-related concept.
136
+ const parent = provider
137
+ .spansByTraceId(stepSpan!.traceId)
138
+ .find((s) => s.spanId === stepSpan!.parentSpanId);
139
+ expect(parent).toBeDefined();
140
+ expect(parent!.name).toMatch(/handler/);
141
+ });
142
+ });
@@ -0,0 +1,152 @@
1
+ // Pipeline-engine performance smoke-test.
2
+ //
3
+ // Compares the M.1 pipeline-form handler ({ perform: pipeline(...) })
4
+ // against the equivalent free-form handler ({ handler: async (...) })
5
+ // over N identical writes against the real Postgres stack. The point
6
+ // is NOT to optimise — the pipeline-form is a thin wrapper, not a
7
+ // hot-path engine. The point is to lock in a baseline so a future
8
+ // regression (e.g. an O(N²) walkAllSteps refactor) shows up as a
9
+ // visible delta rather than as silently-degraded latency.
10
+ //
11
+ // Bar: pipeline-form mean+p95 latency must stay within 3× free-form.
12
+ // Anything beyond that is either a real perf bug or a load-flake worth
13
+ // inspecting. The test logs the actual numbers (bun test stdout) so
14
+ // the baseline is visible in CI without re-running locally.
15
+ //
16
+ // Scope caveat: this measures dispatcher + handler-form combined over
17
+ // a SINGLE-step handler (just r.step.return). The dispatcher dominates
18
+ // the wall-time, which is what makes the bar useful — a real per-step
19
+ // regression would have to be massive to show up. To isolate per-step
20
+ // overhead specifically, you'd want a 5+-step handler so the per-step
21
+ // cost has room to accumulate. Out of scope for the M.1 prod-ready
22
+ // gate; if a future regression suspect needs that breakdown, extend
23
+ // here with a longer-pipeline handler variant.
24
+
25
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
26
+ import { z } from "zod";
27
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
28
+ import { defineFeature } from "../define-feature";
29
+ import { defineWriteHandler } from "../define-handler";
30
+ import { createEntity, createNumberField, createTextField } from "../factories";
31
+ import { pipeline } from "../pipeline";
32
+
33
+ // Same logical operation in both handler-forms: read input, return a
34
+ // trivial transformed payload. No DB-write — the goal is to compare
35
+ // the handler-invocation pipeline overhead, not Postgres write latency
36
+ // (the latter would dominate any real-world handler comparison and
37
+ // hide whatever overhead the step-engine adds).
38
+ const trivialSchema = z.object({ n: z.number() });
39
+
40
+ const trivialPipeline = defineWriteHandler({
41
+ name: "trivial:pipeline",
42
+ schema: trivialSchema,
43
+ access: { roles: ["Admin"] },
44
+ perform: pipeline<{ n: number }, { doubled: number }>(({ event, r }) => [
45
+ r.step.return(() => ({
46
+ isSuccess: true as const,
47
+ data: { doubled: event.payload.n * 2 },
48
+ })),
49
+ ]),
50
+ });
51
+
52
+ const trivialFreeform = defineWriteHandler({
53
+ name: "trivial:freeform",
54
+ schema: trivialSchema,
55
+ access: { roles: ["Admin"] },
56
+ handler: async (event) => ({
57
+ isSuccess: true as const,
58
+ data: { doubled: event.payload.n * 2 },
59
+ }),
60
+ });
61
+
62
+ const productEntity = createEntity({
63
+ table: "perf_smoke_products",
64
+ fields: {
65
+ sku: createTextField({ required: true }),
66
+ qty: createNumberField({ default: 0 }),
67
+ },
68
+ });
69
+
70
+ const perfFeature = defineFeature("perftest", (r) => {
71
+ // Entity-registration is needed so the dispatcher boots cleanly even
72
+ // though the trivial handlers don't touch it; without an entity, the
73
+ // feature is empty enough that some boot-validators short-circuit.
74
+ r.entity("perf-product", productEntity);
75
+ r.writeHandler(trivialPipeline);
76
+ r.writeHandler(trivialFreeform);
77
+ });
78
+
79
+ let stack: TestStack;
80
+
81
+ beforeAll(async () => {
82
+ stack = await setupTestStack({ features: [perfFeature], systemHooks: [] });
83
+ await unsafeCreateEntityTable(stack.db, productEntity, "perf-product");
84
+ });
85
+
86
+ afterAll(async () => {
87
+ await stack.cleanup();
88
+ });
89
+
90
+ beforeEach(async () => {
91
+ await stack.redis.flushNamespace();
92
+ });
93
+
94
+ // Sample size: 100 calls per form keeps the test under ~5s even on a
95
+ // loaded laptop. Higher N gives tighter percentiles but costs wall-time
96
+ // in CI. The signal we need (within-3× ratio) shows up at N=100.
97
+ const N = 100;
98
+
99
+ async function timeMany(call: () => Promise<unknown>): Promise<number[]> {
100
+ const samples: number[] = [];
101
+ for (let i = 0; i < N; i++) {
102
+ const start = performance.now();
103
+ await call();
104
+ samples.push(performance.now() - start);
105
+ }
106
+ return samples;
107
+ }
108
+
109
+ function summarise(samples: number[]): { mean: number; p50: number; p95: number } {
110
+ const sorted = [...samples].sort((a, b) => a - b);
111
+ const mean = sorted.reduce((acc, v) => acc + v, 0) / sorted.length;
112
+ const p50Idx = Math.floor(sorted.length * 0.5);
113
+ const p95Idx = Math.floor(sorted.length * 0.95);
114
+ // Non-null: sorted has length N (≥1) and indices are clamped < N.
115
+ const p50 = sorted[p50Idx]!;
116
+ const p95 = sorted[p95Idx]!;
117
+ return { mean, p50, p95 };
118
+ }
119
+
120
+ describe("pipeline-engine performance smoke-test", () => {
121
+ test(`pipeline-form latency stays within 3× free-form (N=${N})`, async () => {
122
+ // Warmup: 10 calls per form to amortise JIT + Postgres connection-
123
+ // pool warmup, otherwise the first samples dominate the percentiles.
124
+ for (let i = 0; i < 10; i++) {
125
+ await stack.http.writeOk("perftest:write:trivial:pipeline", { n: i }, TestUsers.admin);
126
+ await stack.http.writeOk("perftest:write:trivial:freeform", { n: i }, TestUsers.admin);
127
+ }
128
+
129
+ const pipelineSamples = await timeMany(() =>
130
+ stack.http.writeOk("perftest:write:trivial:pipeline", { n: 1 }, TestUsers.admin),
131
+ );
132
+ const freeformSamples = await timeMany(() =>
133
+ stack.http.writeOk("perftest:write:trivial:freeform", { n: 1 }, TestUsers.admin),
134
+ );
135
+
136
+ const pipelineStats = summarise(pipelineSamples);
137
+ const freeformStats = summarise(freeformSamples);
138
+
139
+ // eslint-disable-next-line no-console -- observable baseline for CI logs
140
+ console.log(
141
+ `[perf] pipeline mean=${pipelineStats.mean.toFixed(2)}ms p50=${pipelineStats.p50.toFixed(2)}ms p95=${pipelineStats.p95.toFixed(2)}ms | ` +
142
+ `freeform mean=${freeformStats.mean.toFixed(2)}ms p50=${freeformStats.p50.toFixed(2)}ms p95=${freeformStats.p95.toFixed(2)}ms | ` +
143
+ `ratio mean=${(pipelineStats.mean / freeformStats.mean).toFixed(2)}× p95=${(pipelineStats.p95 / freeformStats.p95).toFixed(2)}×`,
144
+ );
145
+
146
+ // Assert: the pipeline-form must not be more than 3× slower than
147
+ // free-form on the mean. Higher → suspect a real perf regression.
148
+ // p95-ratio is logged but NOT asserted — tail-latency on a loaded
149
+ // CI runner is too noisy for a hard threshold.
150
+ expect(pipelineStats.mean).toBeLessThan(freeformStats.mean * 3);
151
+ });
152
+ });
@@ -0,0 +1,288 @@
1
+ // Sub-pipeline step-builders — branch + forEach.
2
+ //
3
+ // Both consume static StepInstance arrays as sub-pipelines (Q11) and
4
+ // share the build-time `validateNoReturnSteps` guard (Q12 — extracted
5
+ // to steps/_no-return-guard.ts). Tests cover happy paths, scope
6
+ // hygiene, error-propagation and build-time guards.
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { z } from "zod";
10
+ import { TestUsers } from "../../stack";
11
+ import { defineWriteHandler } from "../define-handler";
12
+ import { pipeline } from "../pipeline";
13
+ import { buildBranchStep } from "../steps/branch";
14
+ import { buildForEachStep } from "../steps/for-each";
15
+ import { buildReturnStep } from "../steps/return";
16
+ import { buildMinimalCtx } from "./_pipeline-test-utils";
17
+
18
+ describe("r.step.branch", () => {
19
+ it("runs the `onTrue` array when the condition is truthy and writes propagate to outer steps", async () => {
20
+ const handlerDef = defineWriteHandler({
21
+ name: "demo:branch-then",
22
+ schema: z.object({}),
23
+ access: { roles: ["User"] },
24
+ perform: pipeline<Record<string, never>, { value: number }>(({ r }) => [
25
+ r.step.branch({
26
+ if: () => true,
27
+ onTrue: [r.step.compute("inThen", () => 42)],
28
+ onFalse: [r.step.compute("inElse", () => -1)],
29
+ }),
30
+ r.step.return(({ steps }) => ({
31
+ isSuccess: true as const,
32
+ data: { value: (steps["inThen"] as number | undefined) ?? 0 },
33
+ })),
34
+ ]),
35
+ });
36
+
37
+ const result = await handlerDef.handler(
38
+ { type: "demo:branch-then", payload: {}, user: TestUsers.admin },
39
+ buildMinimalCtx(),
40
+ );
41
+ expect(result.isSuccess).toBe(true);
42
+ if (result.isSuccess) expect(result.data).toEqual({ value: 42 });
43
+ });
44
+
45
+ it("runs the `onFalse` array when the condition is falsy", async () => {
46
+ const handlerDef = defineWriteHandler({
47
+ name: "demo:branch-else",
48
+ schema: z.object({}),
49
+ access: { roles: ["User"] },
50
+ perform: pipeline<Record<string, never>, { value: number }>(({ r }) => [
51
+ r.step.branch({
52
+ if: () => false,
53
+ onTrue: [r.step.compute("inThen", () => 42)],
54
+ onFalse: [r.step.compute("inElse", () => 99)],
55
+ }),
56
+ r.step.return(({ steps }) => ({
57
+ isSuccess: true as const,
58
+ data: { value: (steps["inElse"] as number | undefined) ?? 0 },
59
+ })),
60
+ ]),
61
+ });
62
+
63
+ const result = await handlerDef.handler(
64
+ { type: "demo:branch-else", payload: {}, user: TestUsers.admin },
65
+ buildMinimalCtx(),
66
+ );
67
+ if (result.isSuccess) expect(result.data).toEqual({ value: 99 });
68
+ });
69
+
70
+ it("is a no-op when the condition is falsy and `onFalse` is omitted", async () => {
71
+ const handlerDef = defineWriteHandler({
72
+ name: "demo:branch-noop",
73
+ schema: z.object({}),
74
+ access: { roles: ["User"] },
75
+ perform: pipeline<Record<string, never>, { ran: boolean }>(({ r }) => [
76
+ r.step.branch({
77
+ if: () => false,
78
+ onTrue: [r.step.compute("ran", () => true)],
79
+ // no onFalse
80
+ }),
81
+ r.step.return(({ steps }) => ({
82
+ isSuccess: true as const,
83
+ data: { ran: (steps["ran"] as boolean | undefined) ?? false },
84
+ })),
85
+ ]),
86
+ });
87
+
88
+ const result = await handlerDef.handler(
89
+ { type: "demo:branch-noop", payload: {}, user: TestUsers.admin },
90
+ buildMinimalCtx(),
91
+ );
92
+ if (result.isSuccess) expect(result.data).toEqual({ ran: false });
93
+ });
94
+
95
+ it("rejects r.step.return inside `onTrue` at build time (Q12 guard)", () => {
96
+ expect(() =>
97
+ buildBranchStep({
98
+ if: () => true,
99
+ onTrue: [buildReturnStep(() => ({ isSuccess: true as const, data: { x: 1 } }))],
100
+ }),
101
+ ).toThrow(/r\.step\.return is not allowed inside r\.step\.branch\.onTrue/);
102
+ });
103
+
104
+ it("rejects r.step.return inside `onFalse` at build time (Q12 guard)", () => {
105
+ expect(() =>
106
+ buildBranchStep({
107
+ if: () => true,
108
+ onTrue: [],
109
+ onFalse: [buildReturnStep(() => ({ isSuccess: true as const, data: { x: 1 } }))],
110
+ }),
111
+ ).toThrow(/r\.step\.return is not allowed inside r\.step\.branch\.onFalse/);
112
+ });
113
+ });
114
+
115
+ describe("r.step.forEach", () => {
116
+ it("iterates the sub-pipeline once per item with scope[as] set", async () => {
117
+ // Sub-pipeline reads scope[as] via a compute step that pushes
118
+ // each iteration into a side-array. The side-array is the test
119
+ // observable — proves the scope-key is set per-iteration.
120
+ const observed: number[] = [];
121
+ const handlerDef = defineWriteHandler({
122
+ name: "demo:foreach",
123
+ schema: z.object({}),
124
+ access: { roles: ["User"] },
125
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
126
+ r.step.forEach({
127
+ over: () => [10, 20, 30],
128
+ as: "n",
129
+ do: [
130
+ r.step.compute("recorded", ({ scope }) => {
131
+ observed.push(scope["n"] as number);
132
+ return scope["n"];
133
+ }),
134
+ ],
135
+ }),
136
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
137
+ ]),
138
+ });
139
+
140
+ await handlerDef.handler(
141
+ { type: "demo:foreach", payload: {}, user: TestUsers.admin },
142
+ buildMinimalCtx(),
143
+ );
144
+ expect(observed).toEqual([10, 20, 30]);
145
+ });
146
+
147
+ it("restores the prior scope-value (or deletes the key) after the loop", async () => {
148
+ // Verifies forEach's scope-key isn't leaking after the loop by
149
+ // inspecting steps from a downstream step. The contract: scope
150
+ // is forEach-local — keys set inside don't survive past it.
151
+ let scopeKeyAfter: unknown = "INITIAL";
152
+ const handlerDef = defineWriteHandler({
153
+ name: "demo:foreach-cleanup",
154
+ schema: z.object({}),
155
+ access: { roles: ["User"] },
156
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
157
+ r.step.forEach({
158
+ over: () => [1],
159
+ as: "tmp",
160
+ do: [r.step.compute("noop", () => null)],
161
+ }),
162
+ r.step.compute("after", ({ scope }) => {
163
+ scopeKeyAfter = scope["tmp"];
164
+ return null;
165
+ }),
166
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
167
+ ]),
168
+ });
169
+
170
+ await handlerDef.handler(
171
+ { type: "demo:foreach-cleanup", payload: {}, user: TestUsers.admin },
172
+ buildMinimalCtx(),
173
+ );
174
+ expect(scopeKeyAfter).toBeUndefined();
175
+ });
176
+
177
+ it("is a no-op for an empty array", async () => {
178
+ let bodyRan = false;
179
+ const handlerDef = defineWriteHandler({
180
+ name: "demo:foreach-empty",
181
+ schema: z.object({}),
182
+ access: { roles: ["User"] },
183
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
184
+ r.step.forEach({
185
+ over: () => [],
186
+ as: "n",
187
+ do: [
188
+ r.step.compute("ranOnce", () => {
189
+ bodyRan = true;
190
+ return null;
191
+ }),
192
+ ],
193
+ }),
194
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
195
+ ]),
196
+ });
197
+
198
+ await handlerDef.handler(
199
+ { type: "demo:foreach-empty", payload: {}, user: TestUsers.admin },
200
+ buildMinimalCtx(),
201
+ );
202
+ expect(bodyRan).toBe(false);
203
+ });
204
+
205
+ it("rejects r.step.return inside `do` at build time (Q12 guard)", () => {
206
+ expect(() =>
207
+ buildForEachStep({
208
+ over: () => [],
209
+ as: "x",
210
+ do: [buildReturnStep(() => ({ isSuccess: true as const, data: {} }))],
211
+ }),
212
+ ).toThrow(/r\.step\.return is not allowed inside r\.step\.forEach\.do/);
213
+ });
214
+
215
+ it("rejects unsupported concurrency at build time (Q16: only 1 in M.1.6)", () => {
216
+ // Cast (not @ts-expect-error) so the test stays valid when Q16
217
+ // expands the type to accept N>1 — at that point this test should
218
+ // delete or move under a Followup #12 build-validator suite, not
219
+ // silently break for unrelated reasons.
220
+ expect(() =>
221
+ buildForEachStep({
222
+ over: () => [],
223
+ as: "x",
224
+ do: [],
225
+ concurrency: 5 as 1,
226
+ }),
227
+ ).toThrow(/concurrency=5 not supported in M\.1\.6/);
228
+ });
229
+
230
+ it("propagates a thrown error from a sub-step (try/finally restores scope)", async () => {
231
+ let postForEachRan = false;
232
+ const handlerDef = defineWriteHandler({
233
+ name: "demo:foreach-throws",
234
+ schema: z.object({}),
235
+ access: { roles: ["User"] },
236
+ perform: pipeline<Record<string, never>, never>(({ r }) => [
237
+ r.step.forEach({
238
+ over: () => [1, 2, 3],
239
+ as: "item",
240
+ do: [
241
+ r.step.compute("check", ({ scope }) => {
242
+ if (scope["item"] === 2) throw new Error("item-2-bang");
243
+ return null;
244
+ }),
245
+ ],
246
+ }),
247
+ r.step.compute("after", () => {
248
+ postForEachRan = true;
249
+ return null;
250
+ }),
251
+ r.step.return({ isSuccess: true as const, data: undefined as never }),
252
+ ]),
253
+ });
254
+
255
+ await expect(
256
+ handlerDef.handler(
257
+ { type: "demo:foreach-throws", payload: {}, user: TestUsers.admin },
258
+ buildMinimalCtx(),
259
+ ),
260
+ ).rejects.toThrow(/item-2-bang/);
261
+ expect(postForEachRan).toBe(false);
262
+ });
263
+
264
+ it("throws when the over-resolver returns a non-array at runtime", async () => {
265
+ const handlerDef = defineWriteHandler({
266
+ name: "demo:foreach-bad-over",
267
+ schema: z.object({}),
268
+ access: { roles: ["User"] },
269
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
270
+ r.step.forEach({
271
+ // Cast: TypeScript would normally catch this; the test exercises
272
+ // the runtime guard for hand-crafted / dynamically built cases.
273
+ over: (() => "not an array") as unknown as () => readonly never[],
274
+ as: "x",
275
+ do: [],
276
+ }),
277
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
278
+ ]),
279
+ });
280
+
281
+ await expect(
282
+ handlerDef.handler(
283
+ { type: "demo:foreach-bad-over", payload: {}, user: TestUsers.admin },
284
+ buildMinimalCtx(),
285
+ ),
286
+ ).rejects.toThrow(/'over' resolver must return an array/);
287
+ });
288
+ });
@@ -56,10 +56,10 @@ describe("r.rawTable — declaration", () => {
56
56
  });
57
57
  });
58
58
  expect(feature.rawTables).toHaveProperty("cache");
59
- expect(feature.rawTables.cache?.reason).toBe(
59
+ expect(feature.rawTables["cache"]?.reason).toBe(
60
60
  "external Stripe webhook cache, write-only by webhook handler",
61
61
  );
62
- expect(feature.rawTables.cache?.table).toBe(probeTable);
62
+ expect(feature.rawTables["cache"]?.table).toBe(probeTable);
63
63
  });
64
64
  });
65
65