@cosmicdrift/kumiko-framework 0.2.2 → 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 +54 -0
- package/package.json +124 -38
- 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/auth/__tests__/roles.test.ts +24 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/roles.ts +42 -0
- package/src/compliance/__tests__/duration-spec.test.ts +72 -0
- package/src/compliance/__tests__/profiles.test.ts +308 -0
- package/src/compliance/__tests__/sub-processors.test.ts +139 -0
- package/src/compliance/duration-spec.ts +44 -0
- package/src/compliance/index.ts +31 -0
- package/src/compliance/override-schema.ts +136 -0
- package/src/compliance/profiles.ts +427 -0
- package/src/compliance/sub-processors.ts +152 -0
- package/src/db/__tests__/big-int-field.test.ts +131 -0
- 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 +20 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -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 -1528
- 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 +127 -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/extension-names.ts +105 -0
- package/src/engine/extensions/user-data.ts +106 -0
- package/src/engine/factories.ts +26 -16
- 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 +13 -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 +71 -1
- package/src/engine/feature-ast/render.ts +31 -1
- package/src/engine/index.ts +66 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +11 -0
- package/src/engine/pattern-library/library.ts +78 -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 +10 -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 +143 -1
- package/src/engine/types/fields.ts +134 -10
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +15 -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/__tests__/read-stream.test.ts +105 -0
- package/src/files/__tests__/write-stream.test.ts +233 -0
- package/src/files/__tests__/zip-stream.test.ts +357 -0
- package/src/files/file-routes.ts +1 -1
- package/src/files/in-memory-provider.ts +38 -0
- package/src/files/index.ts +3 -0
- package/src/files/local-provider.ts +58 -1
- package/src/files/types.ts +36 -8
- package/src/files/zip-stream.ts +251 -0
- 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 -2562
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Drizzle-boundary cast helper — drizzle's `db.insert()/select()/delete()`
|
|
2
|
+
// expect a PgTable<...> shape with `enableRLS` (driver-added). The
|
|
3
|
+
// abstract `Table` we accept on step args is missing that method, so
|
|
4
|
+
// TS rejects direct assignment. Runtime is identical — drizzle's
|
|
5
|
+
// builder methods only read the table-name + column-defs, both of
|
|
6
|
+
// which `Table` carries. Cast at the boundary, document it once.
|
|
7
|
+
//
|
|
8
|
+
// Used by read-find-one, read-find-many, unsafe-projection-upsert,
|
|
9
|
+
// unsafe-projection-delete. Followup #13 (closed at the M.1.6
|
|
10
|
+
// cleanup-pass).
|
|
11
|
+
|
|
12
|
+
import type { Table } from "drizzle-orm";
|
|
13
|
+
|
|
14
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle type-boundary
|
|
15
|
+
type DrizzleQueryTarget = any;
|
|
16
|
+
|
|
17
|
+
export function asQueryTarget(t: Table): DrizzleQueryTarget {
|
|
18
|
+
return t;
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ISO-8601 duration arithmetic — shared by wait and waitForEvent steps.
|
|
2
|
+
// Accepts "P1D", "PT1H", "P1Y2M3DT4H5M6S" etc.
|
|
3
|
+
// Uses approximate calendar math (365d/year, 30d/month). See #23.
|
|
4
|
+
|
|
5
|
+
export function addDuration(baseIso: string, duration: string): string {
|
|
6
|
+
const pattern = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
|
7
|
+
const match = duration.match(pattern);
|
|
8
|
+
if (!match) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Invalid ISO-8601 duration "${duration}" — expected format like "PT1H", "P1D", "P7D"`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const parts = match.slice(1).map((n) => Number(n) || 0);
|
|
15
|
+
const years = parts[0] ?? 0;
|
|
16
|
+
const months = parts[1] ?? 0;
|
|
17
|
+
const days = parts[2] ?? 0;
|
|
18
|
+
const hours = parts[3] ?? 0;
|
|
19
|
+
const minutes = parts[4] ?? 0;
|
|
20
|
+
const seconds = parts[5] ?? 0;
|
|
21
|
+
|
|
22
|
+
// Compute in ms (Temporal.Instant.add accepts only smaller units below
|
|
23
|
+
// hours for calendar-agnostic shifts; we approximate years/months as
|
|
24
|
+
// fixed-length days, see file header).
|
|
25
|
+
let ms = years * 365 * 24 * 60 * 60 * 1000;
|
|
26
|
+
ms += months * 30 * 24 * 60 * 60 * 1000;
|
|
27
|
+
ms += days * 24 * 60 * 60 * 1000;
|
|
28
|
+
ms += hours * 60 * 60 * 1000;
|
|
29
|
+
ms += minutes * 60 * 1000;
|
|
30
|
+
ms += seconds * 1000;
|
|
31
|
+
|
|
32
|
+
return Temporal.Instant.from(baseIso).add({ milliseconds: ms }).toString();
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Build-time guard for Q12 — sub-pipelines (branch.onTrue/onFalse,
|
|
2
|
+
// forEach.do) may not contain r.step.return. Centralised here so the
|
|
3
|
+
// error message is wordlaut-identical across both sub-step-builders;
|
|
4
|
+
// inline duplication would drift the moment someone edits one site.
|
|
5
|
+
//
|
|
6
|
+
// Extracted at the second sub-step-builder rather than the third because
|
|
7
|
+
// the drift-risk on the Q12 wording is real (advisor M.1.6 cleanup), not
|
|
8
|
+
// because the line-count alone justifies it.
|
|
9
|
+
|
|
10
|
+
import type { StepInstance } from "../types/step";
|
|
11
|
+
|
|
12
|
+
export function validateNoReturnSteps(steps: readonly StepInstance[], where: string): void {
|
|
13
|
+
for (const step of steps) {
|
|
14
|
+
if (step.kind === "return") {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`r.step.return is not allowed inside ${where} — branch/forEach are side-effect containers (Q12). ` +
|
|
17
|
+
`Restructure the pipeline so the return happens at the top level.`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Resolver-call helpers — eliminate the typeof-narrow boilerplate that
|
|
2
|
+
// was repeated across 11 step files (return, compute, branch, forEach,
|
|
3
|
+
// read.findOne/findMany, aggregate.create/update/appendEvent,
|
|
4
|
+
// unsafeProjectionUpsert/Delete).
|
|
5
|
+
//
|
|
6
|
+
// Pattern before:
|
|
7
|
+
// const value = typeof args.x === "function" ? args.x(ctx) : args.x;
|
|
8
|
+
//
|
|
9
|
+
// Pattern after:
|
|
10
|
+
// const value = resolveRequired(args.x, ctx);
|
|
11
|
+
//
|
|
12
|
+
// The local-alias version (`const r = args.x; typeof r === "function" ? ...`)
|
|
13
|
+
// was needed to satisfy TS narrowing on a property-access; passing the
|
|
14
|
+
// arg through a function-call achieves the same narrowing trivially via
|
|
15
|
+
// the function-parameter binding.
|
|
16
|
+
//
|
|
17
|
+
// Followup #10 (closed at the M.1.6 cleanup-pass).
|
|
18
|
+
|
|
19
|
+
import type { PipelineCtx, StepResolver } from "../types/step";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a required StepResolver — either a static value or a function.
|
|
23
|
+
* Throws if `arg` is undefined; caller must guarantee presence.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveRequired<T>(arg: StepResolver<T>, ctx: PipelineCtx): T {
|
|
26
|
+
if (typeof arg === "function") {
|
|
27
|
+
return (arg as (c: PipelineCtx) => T)(ctx);
|
|
28
|
+
}
|
|
29
|
+
return arg;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve an optional StepResolver — returns undefined when arg is
|
|
34
|
+
* undefined, otherwise the resolved value.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveOptional<T>(
|
|
37
|
+
arg: StepResolver<T> | undefined,
|
|
38
|
+
ctx: PipelineCtx,
|
|
39
|
+
): T | undefined {
|
|
40
|
+
if (arg === undefined) return undefined;
|
|
41
|
+
return resolveRequired(arg, ctx);
|
|
42
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Shared constants for the deferred-step dispatcher pipeline. Extracted
|
|
2
|
+
// so individual step-builders (webhook.send, mail.send, ...) don't
|
|
3
|
+
// import from each other and silently break on a future split.
|
|
4
|
+
|
|
5
|
+
export const STEP_DISPATCH_AGGREGATE_TYPE = "step-dispatch";
|
|
6
|
+
// System-event namespace (kumiko:system:*) — bypasses registry +
|
|
7
|
+
// ownership checks in append-event-core. Reserved for framework-internal
|
|
8
|
+
// step-engine coordination. The bundled step-dispatcher MSP listens
|
|
9
|
+
// for the literal type-string.
|
|
10
|
+
export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
|
|
11
|
+
export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
|
|
12
|
+
export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
|
|
13
|
+
|
|
14
|
+
// --- Tier-3 / Workflow async step constants ---
|
|
15
|
+
// Written by wait / waitForEvent / retry steps onto the workflow-run stream.
|
|
16
|
+
// The Resume-Loop reads these to decide when to resume a suspended run.
|
|
17
|
+
export const WORKFLOW_WAITING_TYPE = "kumiko:system:workflow.step.waiting";
|
|
18
|
+
export const WORKFLOW_WAITING_FOR_EVENT_TYPE = "kumiko:system:workflow.step.waiting-for-event";
|
|
19
|
+
export const WORKFLOW_RESUMED_TYPE = "kumiko:system:workflow.step.resumed";
|
|
20
|
+
|
|
21
|
+
// Workflow-run aggregate type — each workflow run is an event-sourced
|
|
22
|
+
// aggregate stream.
|
|
23
|
+
export const WORKFLOW_AGGREGATE_TYPE = "workflow-run";
|
|
24
|
+
|
|
25
|
+
// Workflow-run lifecycle events — written by the event-trigger subscriber
|
|
26
|
+
// and the resume-loop onto a workflow-run aggregate stream.
|
|
27
|
+
export const WORKFLOW_RUN_STARTED_TYPE = "kumiko:system:workflow.run-started";
|
|
28
|
+
export const WORKFLOW_RUN_COMPLETED_TYPE = "kumiko:system:workflow.run-completed";
|
|
29
|
+
export const WORKFLOW_RUN_FAILED_TYPE = "kumiko:system:workflow.run-failed";
|
|
30
|
+
|
|
31
|
+
// Step return sentinel — when a step's run() returns this value,
|
|
32
|
+
// runStepList stops and yields a "suspended" outcome. The caller
|
|
33
|
+
// (defineWorkflow / workflow-engine) persists the suspension state.
|
|
34
|
+
export const SUSPEND_SENTINEL = Symbol("kumiko:step:suspend");
|
|
35
|
+
|
|
36
|
+
// Workflow retry scheduled — written by the retry step when a sub-pipeline
|
|
37
|
+
// fails and a retry attempt is scheduled with backoff.
|
|
38
|
+
export const WORKFLOW_RETRY_SCHEDULED_TYPE = "kumiko:system:workflow.retry.scheduled";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// r.step.aggregate.appendEvent — write an additional domain-event onto
|
|
2
|
+
// an existing aggregate stream.
|
|
3
|
+
//
|
|
4
|
+
// Wraps ctx.unsafeAppendEvent: the event lands on the named aggregate
|
|
5
|
+
// stream in the active TX, downstream projections (multiStreamProjection)
|
|
6
|
+
// fire, audit-trail captures it. Used when a write-handler needs to
|
|
7
|
+
// record a domain event that's NOT one of the auto-generated CRUD
|
|
8
|
+
// events (e.g. "incident.update-posted" on the same incident stream
|
|
9
|
+
// that already carries "incident.created").
|
|
10
|
+
//
|
|
11
|
+
// Why `unsafeAppendEvent` (not the strict `appendEvent`): step.run sees
|
|
12
|
+
// `ctx` as PipelineCtx<unknown, KumikoEventTypeMap> after the variance-
|
|
13
|
+
// bridge cast in run-pipeline.ts. The strict TMap-typed appendEvent
|
|
14
|
+
// would collapse `keyof TMap` to `never` from the framework-side. Strict
|
|
15
|
+
// typing of appendEvent inside steps is a deferred pass (post-M.1.5).
|
|
16
|
+
// At the call-site users still spell `type` as a string-literal — TS
|
|
17
|
+
// catches typos against the QualifiedEventName union when the literal
|
|
18
|
+
// matches a registered EventDef.
|
|
19
|
+
//
|
|
20
|
+
// No result-key — appendEvent doesn't surface a value to subsequent
|
|
21
|
+
// steps (the event-store assigns the position, but consumers don't
|
|
22
|
+
// need it during the same handler call).
|
|
23
|
+
|
|
24
|
+
import { defineStep } from "../define-step";
|
|
25
|
+
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
26
|
+
import { resolveOptional, resolveRequired } from "./_resolver-utils";
|
|
27
|
+
|
|
28
|
+
type AggregateAppendEventArgs = {
|
|
29
|
+
readonly aggregateId: StepResolver<string>;
|
|
30
|
+
readonly aggregateType: string;
|
|
31
|
+
readonly type: string;
|
|
32
|
+
readonly payload: StepResolver<unknown>;
|
|
33
|
+
readonly headers?: StepResolver<Readonly<Record<string, string | number | boolean>>>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
defineStep<AggregateAppendEventArgs, void>({
|
|
37
|
+
kind: "aggregate.appendEvent",
|
|
38
|
+
defaultFailureStrategy: "throw",
|
|
39
|
+
run: async (args, ctx: PipelineCtx) => {
|
|
40
|
+
const aggregateId = resolveRequired(args.aggregateId, ctx);
|
|
41
|
+
const payload = resolveRequired(args.payload, ctx);
|
|
42
|
+
const headers = resolveOptional(args.headers, ctx);
|
|
43
|
+
|
|
44
|
+
await ctx.unsafeAppendEvent({
|
|
45
|
+
aggregateId,
|
|
46
|
+
aggregateType: args.aggregateType,
|
|
47
|
+
type: args.type,
|
|
48
|
+
payload,
|
|
49
|
+
...(headers !== undefined && { headers }),
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function buildAggregateAppendEventStep(args: AggregateAppendEventArgs): StepInstance {
|
|
55
|
+
return { kind: "aggregate.appendEvent", args };
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// r.step.aggregate.create — open a new event-sourced aggregate stream.
|
|
2
|
+
//
|
|
3
|
+
// Wraps the existing createEventStoreExecutor.create(): writes the
|
|
4
|
+
// `<entity>.created` event to the aggregate stream + applies the inline
|
|
5
|
+
// projection, both in the active TX. Lifecycle hooks (postSave),
|
|
6
|
+
// field-access write rules, crypto-shredding, audit-trail and the rest
|
|
7
|
+
// of the framework-protections all run because we go through the
|
|
8
|
+
// executor (the canonical aggregate-mutation path).
|
|
9
|
+
//
|
|
10
|
+
// Returns the SaveContext { id, data, changes, previous, isNew, event }
|
|
11
|
+
// — landed under steps.<name> so subsequent steps can read steps.<name>.id.
|
|
12
|
+
//
|
|
13
|
+
// Failure-handling: M.1.1's "throw"-only strategy applies. If the
|
|
14
|
+
// executor returns a WriteFailure, we re-raise as a KumikoError so the
|
|
15
|
+
// dispatcher's catch maps it to the standard write-failure shape on the
|
|
16
|
+
// HTTP response.
|
|
17
|
+
|
|
18
|
+
import type { EventStoreExecutor } from "../../db/event-store-executor";
|
|
19
|
+
import { reraiseAsKumikoError } from "../../errors/write-error-info";
|
|
20
|
+
import { defineStep } from "../define-step";
|
|
21
|
+
import type { SaveContext } from "../types/hooks";
|
|
22
|
+
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
23
|
+
import { resolveRequired } from "./_resolver-utils";
|
|
24
|
+
|
|
25
|
+
type AggregateCreateArgs = {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly executor: EventStoreExecutor;
|
|
28
|
+
readonly data: StepResolver<Record<string, unknown>>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
defineStep<AggregateCreateArgs, SaveContext>({
|
|
32
|
+
kind: "aggregate.create",
|
|
33
|
+
defaultFailureStrategy: "throw",
|
|
34
|
+
resultKey: (args) => args.name,
|
|
35
|
+
run: async (args, ctx: PipelineCtx) => {
|
|
36
|
+
const data = resolveRequired(args.data, ctx);
|
|
37
|
+
const result = await args.executor.create(data, ctx.event.user, ctx.db);
|
|
38
|
+
if (!result.isSuccess) {
|
|
39
|
+
throw reraiseAsKumikoError(result.error);
|
|
40
|
+
}
|
|
41
|
+
return result.data;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export function buildAggregateCreateStep(
|
|
46
|
+
name: string,
|
|
47
|
+
opts: {
|
|
48
|
+
readonly executor: EventStoreExecutor;
|
|
49
|
+
readonly data: StepResolver<Record<string, unknown>>;
|
|
50
|
+
},
|
|
51
|
+
): StepInstance {
|
|
52
|
+
return {
|
|
53
|
+
kind: "aggregate.create",
|
|
54
|
+
args: { name, executor: opts.executor, data: opts.data } satisfies AggregateCreateArgs,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// r.step.aggregate.update — apply a delta to an existing aggregate stream.
|
|
2
|
+
//
|
|
3
|
+
// Wraps the existing createEventStoreExecutor.update(): writes the
|
|
4
|
+
// `<entity>.updated` event + applies the inline projection in the
|
|
5
|
+
// active TX. Optimistic-locking via the optional `version` field —
|
|
6
|
+
// pipeline-author can supply the loaded version (often via a prior
|
|
7
|
+
// r.step.read.findOne) or skip with skipOptimisticLock.
|
|
8
|
+
//
|
|
9
|
+
// Returns the SaveContext { id, data, changes, previous, isNew, event }
|
|
10
|
+
// — landed under steps.<name>. `changes` and `previous` are useful for
|
|
11
|
+
// hooks/audit consumers; the `id` matches the input id.
|
|
12
|
+
//
|
|
13
|
+
// Failure-handling mirrors aggregate.create: WriteFailure → re-raised
|
|
14
|
+
// as KumikoError → dispatcher catches and maps.
|
|
15
|
+
|
|
16
|
+
import type { EventStoreExecutor } from "../../db/event-store-executor";
|
|
17
|
+
import { reraiseAsKumikoError } from "../../errors/write-error-info";
|
|
18
|
+
import { defineStep } from "../define-step";
|
|
19
|
+
import type { SaveContext } from "../types/hooks";
|
|
20
|
+
import type { EntityId } from "../types/identifiers";
|
|
21
|
+
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
22
|
+
import { resolveOptional, resolveRequired } from "./_resolver-utils";
|
|
23
|
+
|
|
24
|
+
type AggregateUpdateArgs = {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly executor: EventStoreExecutor;
|
|
27
|
+
readonly id: StepResolver<EntityId>;
|
|
28
|
+
readonly changes: StepResolver<Record<string, unknown>>;
|
|
29
|
+
readonly version?: StepResolver<number | undefined>;
|
|
30
|
+
readonly skipOptimisticLock?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
defineStep<AggregateUpdateArgs, SaveContext>({
|
|
34
|
+
kind: "aggregate.update",
|
|
35
|
+
defaultFailureStrategy: "throw",
|
|
36
|
+
resultKey: (args) => args.name,
|
|
37
|
+
run: async (args, ctx: PipelineCtx) => {
|
|
38
|
+
const id = resolveRequired(args.id, ctx);
|
|
39
|
+
const changes = resolveRequired(args.changes, ctx);
|
|
40
|
+
const version = resolveOptional(args.version, ctx);
|
|
41
|
+
const result = await args.executor.update(
|
|
42
|
+
{ id, version, changes },
|
|
43
|
+
ctx.event.user,
|
|
44
|
+
ctx.db,
|
|
45
|
+
args.skipOptimisticLock ? { skipOptimisticLock: true } : undefined,
|
|
46
|
+
);
|
|
47
|
+
if (!result.isSuccess) {
|
|
48
|
+
throw reraiseAsKumikoError(result.error);
|
|
49
|
+
}
|
|
50
|
+
return result.data;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function buildAggregateUpdateStep(
|
|
55
|
+
name: string,
|
|
56
|
+
opts: {
|
|
57
|
+
readonly executor: EventStoreExecutor;
|
|
58
|
+
readonly id: StepResolver<EntityId>;
|
|
59
|
+
readonly changes: StepResolver<Record<string, unknown>>;
|
|
60
|
+
readonly version?: StepResolver<number | undefined>;
|
|
61
|
+
readonly skipOptimisticLock?: boolean;
|
|
62
|
+
},
|
|
63
|
+
): StepInstance {
|
|
64
|
+
return {
|
|
65
|
+
kind: "aggregate.update",
|
|
66
|
+
args: { name, ...opts } satisfies AggregateUpdateArgs,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// r.step.branch — conditional sub-pipeline execution.
|
|
2
|
+
//
|
|
3
|
+
// Evaluates the `if` resolver, runs the `then` step-array if truthy,
|
|
4
|
+
// otherwise the `else` step-array (if provided). Branch is a
|
|
5
|
+
// side-effect container: it doesn't surface a result-key, doesn't
|
|
6
|
+
// allow mid-flight `r.step.return`. Use it to gate ES-mutations or
|
|
7
|
+
// projection-writes on a condition derived from prior steps.
|
|
8
|
+
//
|
|
9
|
+
// ```ts
|
|
10
|
+
// r.step.branch({
|
|
11
|
+
// if: ({ steps }) => (steps["user"] as User | null) !== null,
|
|
12
|
+
// onTrue: [r.step.aggregate.appendEvent({...})],
|
|
13
|
+
// onFalse: [r.step.unsafeProjectionUpsert({...})],
|
|
14
|
+
// })
|
|
15
|
+
// ```
|
|
16
|
+
//
|
|
17
|
+
// Naming-note (Q14 revised): `onTrue`/`onFalse` instead of `then`/`else`
|
|
18
|
+
// because Biome's `noThenProperty` lint flags `then` as a thenable-trap
|
|
19
|
+
// (await-on-the-args-object would invoke the array, not awaited
|
|
20
|
+
// resolution). `if` remains as JS-keyword-as-property — string-key access
|
|
21
|
+
// is allowed and reads as natural English.
|
|
22
|
+
//
|
|
23
|
+
// Sub-pipeline-form (Q11): `onTrue` / `onFalse` are static StepInstance
|
|
24
|
+
// arrays — `r` is captured from the outer pipeline closure, no nested
|
|
25
|
+
// r-arg. Boot-validator walks them recursively (Q17). Build-time guard
|
|
26
|
+
// (Q12) rejects nested `r.step.return` — branch is not a mid-flight
|
|
27
|
+
// exit (would trigger the discriminated-union TData-Inference trap).
|
|
28
|
+
|
|
29
|
+
import { defineStep } from "../define-step";
|
|
30
|
+
import { runStepList } from "../run-pipeline";
|
|
31
|
+
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
32
|
+
import { validateNoReturnSteps } from "./_no-return-guard";
|
|
33
|
+
import { resolveRequired } from "./_resolver-utils";
|
|
34
|
+
import { SUSPEND_SENTINEL } from "./_step-dispatch-constants";
|
|
35
|
+
|
|
36
|
+
type BranchArgs = {
|
|
37
|
+
readonly if: StepResolver<boolean>;
|
|
38
|
+
readonly onTrue: readonly StepInstance[];
|
|
39
|
+
readonly onFalse?: readonly StepInstance[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
defineStep<BranchArgs, undefined | typeof SUSPEND_SENTINEL>({
|
|
43
|
+
kind: "branch",
|
|
44
|
+
defaultFailureStrategy: "throw",
|
|
45
|
+
subPaths: ["onTrue", "onFalse"],
|
|
46
|
+
run: async (args, ctx: PipelineCtx) => {
|
|
47
|
+
const condition = resolveRequired(args.if, ctx);
|
|
48
|
+
const branchSteps = condition ? args.onTrue : (args.onFalse ?? []);
|
|
49
|
+
|
|
50
|
+
// Recursive sub-step execution. The acc-maps in ctx are typed
|
|
51
|
+
// Readonly for the public-facing PipelineCtx (resolvers shouldn't
|
|
52
|
+
// mutate them directly), but the runtime objects are the same
|
|
53
|
+
// mutable maps that runPipeline owns. Casting back to mutable here
|
|
54
|
+
// is a framework-internal boundary, not a user-API boundary.
|
|
55
|
+
const stepsAcc = ctx.steps as Record<string, unknown>;
|
|
56
|
+
const scopeAcc = ctx.scope as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
const outcome = await runStepList(
|
|
59
|
+
branchSteps,
|
|
60
|
+
ctx.event,
|
|
61
|
+
ctx,
|
|
62
|
+
stepsAcc,
|
|
63
|
+
scopeAcc,
|
|
64
|
+
ctx.workflow,
|
|
65
|
+
);
|
|
66
|
+
if (outcome.kind === "return") {
|
|
67
|
+
// Build-time guard (validateNoReturnSteps) rejects any return-step
|
|
68
|
+
// inside onTrue/onFalse. If we land here at runtime, someone
|
|
69
|
+
// hand-crafted a StepInstance with kind="return" and bypassed the
|
|
70
|
+
// builder. Fail loud rather than silently bubbling the return up to
|
|
71
|
+
// the outer pipeline (would shadow Q12).
|
|
72
|
+
throw new Error("r.step.return is not allowed inside r.step.branch onTrue/onFalse (Q12)");
|
|
73
|
+
}
|
|
74
|
+
if (outcome.kind === "suspended") {
|
|
75
|
+
return SUSPEND_SENTINEL;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export function buildBranchStep(args: BranchArgs): StepInstance {
|
|
81
|
+
validateNoReturnSteps(args.onTrue, "r.step.branch.onTrue");
|
|
82
|
+
if (args.onFalse) validateNoReturnSteps(args.onFalse, "r.step.branch.onFalse");
|
|
83
|
+
return { kind: "branch", args };
|
|
84
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// r.step.callFeature — typed sub-command on another Kumiko feature.
|
|
2
|
+
// Tier-2: requires r.requires.step("callFeature"). Sync (no dispatcher).
|
|
3
|
+
// Cross-tenant via opts.as (Sysadmin-role-checked at the dispatcher layer).
|
|
4
|
+
|
|
5
|
+
import { defineStep } from "../define-step";
|
|
6
|
+
import type { SessionUser } from "../types";
|
|
7
|
+
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
8
|
+
import { resolveRequired } from "./_resolver-utils";
|
|
9
|
+
|
|
10
|
+
type CallFeatureArgs = {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly handler: string;
|
|
13
|
+
readonly payload: StepResolver<unknown>;
|
|
14
|
+
readonly as?: SessionUser;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
defineStep<CallFeatureArgs, unknown>({
|
|
18
|
+
kind: "callFeature",
|
|
19
|
+
tier: 2,
|
|
20
|
+
defaultFailureStrategy: "throw",
|
|
21
|
+
resultKey: (args) => args.name,
|
|
22
|
+
run: async (args, ctx: PipelineCtx) => {
|
|
23
|
+
const payload = resolveRequired(args.payload, ctx);
|
|
24
|
+
const result = args.as
|
|
25
|
+
? await ctx.writeAs(args.as, args.handler, payload)
|
|
26
|
+
: await ctx.write(args.handler, payload);
|
|
27
|
+
if (!result.isSuccess) {
|
|
28
|
+
// Preserve the structured WriteFailure as `cause` so the
|
|
29
|
+
// dispatcher's catch maps it back to a typed error response
|
|
30
|
+
// (e.g. validation_failed stays validation_failed, not a
|
|
31
|
+
// generic internal_error).
|
|
32
|
+
const err = new Error(`callFeature("${args.handler}") returned failure`);
|
|
33
|
+
(err as Error & { cause?: unknown }).cause = result.error;
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
return result.data;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export function buildCallFeatureStep(
|
|
41
|
+
name: string,
|
|
42
|
+
opts: {
|
|
43
|
+
readonly handler: string;
|
|
44
|
+
readonly payload: StepResolver<unknown>;
|
|
45
|
+
readonly as?: SessionUser;
|
|
46
|
+
},
|
|
47
|
+
): StepInstance {
|
|
48
|
+
return { kind: "callFeature", args: { name, ...opts } };
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// r.step.compute — derive a value from the pipeline-context and stash
|
|
2
|
+
// it under `steps.<name>` for subsequent steps to consume.
|
|
3
|
+
//
|
|
4
|
+
// Typical use:
|
|
5
|
+
// r.step.compute("startedAt", () => Temporal.Now.instant()),
|
|
6
|
+
// r.step.compute("isPriority", ({ event }) => event.payload.tier === "Pro"),
|
|
7
|
+
//
|
|
8
|
+
// `compute` is the simplest non-trivial step — it shows how the
|
|
9
|
+
// `steps`-accumulator carries values forward across the pipeline. The
|
|
10
|
+
// runtime side is a single function call; the value of this step lies
|
|
11
|
+
// in being the smallest building block that exercises step-result
|
|
12
|
+
// threading end-to-end.
|
|
13
|
+
//
|
|
14
|
+
// Type-safety note: in M.1, `steps` is Record<string, unknown> — call
|
|
15
|
+
// sites cast or guard at the read end. Strict-typed result-key
|
|
16
|
+
// accumulation is a follow-up (see step-vocabulary.md M.1-Followups).
|
|
17
|
+
|
|
18
|
+
import { defineStep } from "../define-step";
|
|
19
|
+
import type { PipelineCtx, StepInstance } from "../types/step";
|
|
20
|
+
|
|
21
|
+
type ComputeStepArgs = {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly fn: (ctx: PipelineCtx) => unknown;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
defineStep<ComputeStepArgs, unknown>({
|
|
27
|
+
kind: "compute",
|
|
28
|
+
defaultFailureStrategy: "throw",
|
|
29
|
+
resultKey: (args) => args.name,
|
|
30
|
+
run: (args, ctx) => args.fn(ctx),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export function buildComputeStep<TResult>(
|
|
34
|
+
name: string,
|
|
35
|
+
fn: (ctx: PipelineCtx) => TResult,
|
|
36
|
+
): StepInstance {
|
|
37
|
+
return {
|
|
38
|
+
kind: "compute",
|
|
39
|
+
args: { name, fn } satisfies ComputeStepArgs,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// r.step.forEach — iterate a sub-pipeline over an array.
|
|
2
|
+
//
|
|
3
|
+
// The sub-pipeline (`do`) runs once per item; the current item lands
|
|
4
|
+
// under `scope[as]` for resolvers inside `do` to read. After the loop,
|
|
5
|
+
// `scope[as]` is restored to its prior value (or deleted if it didn't
|
|
6
|
+
// exist before) — scope-keys are forEach-local, not bleeding into
|
|
7
|
+
// subsequent top-level steps.
|
|
8
|
+
//
|
|
9
|
+
// ```ts
|
|
10
|
+
// r.step.forEach({
|
|
11
|
+
// over: ({ steps }) => steps["componentIds"] as string[],
|
|
12
|
+
// as: "componentId",
|
|
13
|
+
// do: [
|
|
14
|
+
// r.step.unsafeProjectionUpsert({
|
|
15
|
+
// table: incidentComponentsTable,
|
|
16
|
+
// on: ["incidentId", "componentId"],
|
|
17
|
+
// row: ({ scope, steps }) => ({
|
|
18
|
+
// incidentId: (steps["incident"] as { id: string }).id,
|
|
19
|
+
// componentId: scope["componentId"] as string,
|
|
20
|
+
// }),
|
|
21
|
+
// }),
|
|
22
|
+
// ],
|
|
23
|
+
// })
|
|
24
|
+
// ```
|
|
25
|
+
//
|
|
26
|
+
// M.1.6 supports `concurrency: 1` only (sequential). Concurrent
|
|
27
|
+
// execution (Promise.all-style) is Followup #12 — TX-sharing,
|
|
28
|
+
// AbortSignal-cancellation, and lifecycle-hook ordering each warrant
|
|
29
|
+
// their own audit cycle (Q16).
|
|
30
|
+
//
|
|
31
|
+
// Q15: `as` is required — without it, the current item is unreachable
|
|
32
|
+
// from the sub-pipeline's resolvers. Q12: r.step.return inside `do` is
|
|
33
|
+
// rejected at build time (would trigger the discriminated-union
|
|
34
|
+
// TData-Inference trap).
|
|
35
|
+
|
|
36
|
+
import { defineStep } from "../define-step";
|
|
37
|
+
import { runStepList } from "../run-pipeline";
|
|
38
|
+
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
39
|
+
import { validateNoReturnSteps } from "./_no-return-guard";
|
|
40
|
+
import { resolveRequired } from "./_resolver-utils";
|
|
41
|
+
import { SUSPEND_SENTINEL } from "./_step-dispatch-constants";
|
|
42
|
+
|
|
43
|
+
type ForEachArgs<TItem = unknown> = {
|
|
44
|
+
readonly over: StepResolver<readonly TItem[]>;
|
|
45
|
+
readonly as: string;
|
|
46
|
+
readonly do: readonly StepInstance[];
|
|
47
|
+
// Reserved for Followup #12. Today only `1` is accepted; adding `N`
|
|
48
|
+
// requires the work documented in step-vocabulary.md Q16.
|
|
49
|
+
readonly concurrency?: 1;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
defineStep<ForEachArgs, undefined | typeof SUSPEND_SENTINEL>({
|
|
53
|
+
kind: "forEach",
|
|
54
|
+
defaultFailureStrategy: "throw",
|
|
55
|
+
subPaths: ["do"],
|
|
56
|
+
run: async (args, ctx: PipelineCtx) => {
|
|
57
|
+
const items = resolveRequired(args.over, ctx);
|
|
58
|
+
if (!Array.isArray(items)) {
|
|
59
|
+
throw new Error(`r.step.forEach: 'over' resolver must return an array (got ${typeof items})`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mutable-acc cast — same framework-internal boundary as branch.run.
|
|
63
|
+
// The runtime objects are the maps that runPipeline owns; readonly
|
|
64
|
+
// typing in PipelineCtx is for resolver-API hygiene.
|
|
65
|
+
const stepsAcc = ctx.steps as Record<string, unknown>;
|
|
66
|
+
const scopeAcc = ctx.scope as Record<string, unknown>;
|
|
67
|
+
|
|
68
|
+
// Save-and-restore so the scope-key is forEach-local. Without this,
|
|
69
|
+
// a nested `r.step.compute` reading scope[as] AFTER the forEach
|
|
70
|
+
// would silently see the last iteration's item.
|
|
71
|
+
const hadKey = Object.hasOwn(scopeAcc, args.as);
|
|
72
|
+
const previousValue = scopeAcc[args.as];
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
scopeAcc[args.as] = item;
|
|
77
|
+
const outcome = await runStepList(
|
|
78
|
+
args.do,
|
|
79
|
+
ctx.event,
|
|
80
|
+
ctx,
|
|
81
|
+
stepsAcc,
|
|
82
|
+
scopeAcc,
|
|
83
|
+
ctx.workflow,
|
|
84
|
+
);
|
|
85
|
+
if (outcome.kind === "return") {
|
|
86
|
+
throw new Error("r.step.return is not allowed inside r.step.forEach.do (Q12)");
|
|
87
|
+
}
|
|
88
|
+
if (outcome.kind === "suspended") {
|
|
89
|
+
return SUSPEND_SENTINEL;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
if (hadKey) {
|
|
94
|
+
scopeAcc[args.as] = previousValue;
|
|
95
|
+
} else {
|
|
96
|
+
delete scopeAcc[args.as];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export function buildForEachStep<TItem = unknown>(args: ForEachArgs<TItem>): StepInstance {
|
|
103
|
+
validateNoReturnSteps(args.do, "r.step.forEach.do");
|
|
104
|
+
if (args.concurrency !== undefined && args.concurrency !== 1) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`r.step.forEach: concurrency=${args.concurrency} not supported in M.1.6 (only 1). ` +
|
|
107
|
+
`Concurrent forEach is Followup #12 — TX-sharing, AbortSignal, hook-ordering each need their own slice.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { kind: "forEach", args };
|
|
111
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// r.step.mail.send — deferred transactional e-mail via the step-dispatcher.
|
|
2
|
+
// Tier-2: requires r.requires.step("mail.send"). Mirrors webhook.send.
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { defineStep } from "../define-step";
|
|
6
|
+
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
7
|
+
import { resolveOptional, resolveRequired } from "./_resolver-utils";
|
|
8
|
+
import {
|
|
9
|
+
STEP_DISPATCH_AGGREGATE_TYPE,
|
|
10
|
+
STEP_DISPATCH_REQUESTED_TYPE,
|
|
11
|
+
} from "./_step-dispatch-constants";
|
|
12
|
+
|
|
13
|
+
type MailSendArgs = {
|
|
14
|
+
readonly to: StepResolver<string | readonly string[]>;
|
|
15
|
+
readonly subject: StepResolver<string>;
|
|
16
|
+
readonly body: StepResolver<string>;
|
|
17
|
+
readonly from?: StepResolver<string>;
|
|
18
|
+
readonly mode: "deferred";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
defineStep<MailSendArgs, void>({
|
|
22
|
+
kind: "mail.send",
|
|
23
|
+
tier: 2,
|
|
24
|
+
defaultFailureStrategy: "throw",
|
|
25
|
+
run: async (args, ctx: PipelineCtx) => {
|
|
26
|
+
const to = resolveRequired(args.to, ctx);
|
|
27
|
+
const subject = resolveRequired(args.subject, ctx);
|
|
28
|
+
const body = resolveRequired(args.body, ctx);
|
|
29
|
+
const from = resolveOptional(args.from, ctx);
|
|
30
|
+
await ctx.unsafeAppendEvent({
|
|
31
|
+
aggregateId: randomUUID(),
|
|
32
|
+
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
33
|
+
type: STEP_DISPATCH_REQUESTED_TYPE,
|
|
34
|
+
payload: {
|
|
35
|
+
stepKind: "mail.send",
|
|
36
|
+
spec: { to, subject, body, ...(from && { from }) },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function buildMailSendStep(args: MailSendArgs): StepInstance {
|
|
43
|
+
return { kind: "mail.send", args };
|
|
44
|
+
}
|