@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.
- package/CHANGELOG.md +52 -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 +45 -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 +92 -1
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +12 -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 +130 -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,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
|
|
59
|
+
expect(feature.rawTables["cache"]?.reason).toBe(
|
|
60
60
|
"external Stripe webhook cache, write-only by webhook handler",
|
|
61
61
|
);
|
|
62
|
-
expect(feature.rawTables
|
|
62
|
+
expect(feature.rawTables["cache"]?.table).toBe(probeTable);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|