@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.
Files changed (191) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +124 -38
  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/auth/__tests__/roles.test.ts +24 -0
  10. package/src/auth/index.ts +7 -0
  11. package/src/auth/roles.ts +42 -0
  12. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  13. package/src/compliance/__tests__/profiles.test.ts +308 -0
  14. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  15. package/src/compliance/duration-spec.ts +44 -0
  16. package/src/compliance/index.ts +31 -0
  17. package/src/compliance/override-schema.ts +136 -0
  18. package/src/compliance/profiles.ts +427 -0
  19. package/src/compliance/sub-processors.ts +152 -0
  20. package/src/db/__tests__/big-int-field.test.ts +131 -0
  21. package/src/db/assert-exists-in.ts +2 -2
  22. package/src/db/cursor.ts +3 -3
  23. package/src/db/event-store-executor.ts +19 -13
  24. package/src/db/located-timestamp.ts +1 -1
  25. package/src/db/money.ts +12 -2
  26. package/src/db/pg-error.ts +1 -1
  27. package/src/db/row-helpers.ts +1 -1
  28. package/src/db/table-builder.ts +20 -5
  29. package/src/db/tenant-db.ts +9 -9
  30. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  31. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  32. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  33. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  34. package/src/engine/__tests__/build-target.test.ts +135 -0
  35. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  36. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  37. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  38. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  39. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  40. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  41. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  42. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  43. package/src/engine/__tests__/raw-table.test.ts +2 -2
  44. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  45. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  46. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  47. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  48. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  49. package/src/engine/__tests__/steps-read.test.ts +142 -0
  50. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  51. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  52. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  53. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  54. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  55. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  56. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  57. package/src/engine/boot-validator/api-ext.ts +77 -0
  58. package/src/engine/boot-validator/config-deps.ts +163 -0
  59. package/src/engine/boot-validator/entity-handler.ts +466 -0
  60. package/src/engine/boot-validator/index.ts +159 -0
  61. package/src/engine/boot-validator/ownership.ts +198 -0
  62. package/src/engine/boot-validator/pii-retention.ts +155 -0
  63. package/src/engine/boot-validator/screens-nav.ts +624 -0
  64. package/src/engine/boot-validator.ts +1 -1528
  65. package/src/engine/build-app-schema.ts +1 -1
  66. package/src/engine/build-target.ts +99 -0
  67. package/src/engine/codemod/index.ts +15 -0
  68. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  69. package/src/engine/config-helpers.ts +9 -19
  70. package/src/engine/constants.ts +1 -1
  71. package/src/engine/define-feature.ts +127 -9
  72. package/src/engine/define-handler.ts +89 -3
  73. package/src/engine/define-roles.ts +2 -2
  74. package/src/engine/define-step.ts +28 -0
  75. package/src/engine/define-workflow.ts +110 -0
  76. package/src/engine/entity-handlers.ts +10 -9
  77. package/src/engine/event-helpers.ts +4 -4
  78. package/src/engine/extension-names.ts +105 -0
  79. package/src/engine/extensions/user-data.ts +106 -0
  80. package/src/engine/factories.ts +26 -16
  81. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  82. package/src/engine/feature-ast/extractors/index.ts +74 -0
  83. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  84. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  85. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  86. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  87. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  88. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  89. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  90. package/src/engine/feature-ast/parse.ts +13 -0
  91. package/src/engine/feature-ast/patch.ts +9 -1
  92. package/src/engine/feature-ast/patcher.ts +10 -3
  93. package/src/engine/feature-ast/patterns.ts +71 -1
  94. package/src/engine/feature-ast/render.ts +31 -1
  95. package/src/engine/index.ts +66 -2
  96. package/src/engine/pattern-library/__tests__/library.test.ts +11 -0
  97. package/src/engine/pattern-library/library.ts +78 -2
  98. package/src/engine/pipeline.ts +88 -0
  99. package/src/engine/projection-helpers.ts +1 -1
  100. package/src/engine/read-claim.ts +1 -1
  101. package/src/engine/registry.ts +30 -2
  102. package/src/engine/resolve-config-or-param.ts +4 -0
  103. package/src/engine/run-pipeline.ts +162 -0
  104. package/src/engine/schema-builder.ts +10 -4
  105. package/src/engine/state-machine.ts +1 -1
  106. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  107. package/src/engine/steps/_duration-utils.ts +33 -0
  108. package/src/engine/steps/_no-return-guard.ts +21 -0
  109. package/src/engine/steps/_resolver-utils.ts +42 -0
  110. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  111. package/src/engine/steps/aggregate-append-event.ts +56 -0
  112. package/src/engine/steps/aggregate-create.ts +56 -0
  113. package/src/engine/steps/aggregate-update.ts +68 -0
  114. package/src/engine/steps/branch.ts +84 -0
  115. package/src/engine/steps/call-feature.ts +49 -0
  116. package/src/engine/steps/compute.ts +41 -0
  117. package/src/engine/steps/for-each.ts +111 -0
  118. package/src/engine/steps/mail-send.ts +44 -0
  119. package/src/engine/steps/read-find-many.ts +51 -0
  120. package/src/engine/steps/read-find-one.ts +58 -0
  121. package/src/engine/steps/retry.ts +87 -0
  122. package/src/engine/steps/return.ts +34 -0
  123. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  124. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  125. package/src/engine/steps/wait-for-event.ts +71 -0
  126. package/src/engine/steps/wait.ts +69 -0
  127. package/src/engine/steps/webhook-send.ts +71 -0
  128. package/src/engine/system-user.ts +1 -1
  129. package/src/engine/types/feature.ts +143 -1
  130. package/src/engine/types/fields.ts +134 -10
  131. package/src/engine/types/handlers.ts +18 -10
  132. package/src/engine/types/identifiers.ts +1 -0
  133. package/src/engine/types/index.ts +15 -1
  134. package/src/engine/types/step.ts +334 -0
  135. package/src/engine/types/target-ref.ts +21 -0
  136. package/src/engine/types/tree-node.ts +130 -0
  137. package/src/engine/types/workspace.ts +7 -0
  138. package/src/engine/validate-projection-allowlist.ts +161 -0
  139. package/src/event-store/snapshot.ts +1 -1
  140. package/src/event-store/upcaster-dead-letter.ts +1 -1
  141. package/src/event-store/upcaster.ts +1 -1
  142. package/src/files/__tests__/read-stream.test.ts +105 -0
  143. package/src/files/__tests__/write-stream.test.ts +233 -0
  144. package/src/files/__tests__/zip-stream.test.ts +357 -0
  145. package/src/files/file-routes.ts +1 -1
  146. package/src/files/in-memory-provider.ts +38 -0
  147. package/src/files/index.ts +3 -0
  148. package/src/files/local-provider.ts +58 -1
  149. package/src/files/types.ts +36 -8
  150. package/src/files/zip-stream.ts +251 -0
  151. package/src/jobs/job-runner.ts +10 -10
  152. package/src/lifecycle/lifecycle.ts +0 -3
  153. package/src/logging/index.ts +1 -0
  154. package/src/logging/pino-logger.ts +11 -7
  155. package/src/logging/utils.ts +24 -0
  156. package/src/observability/prometheus-meter.ts +7 -5
  157. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  158. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  159. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  160. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  161. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  162. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  163. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  164. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  165. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  166. package/src/pipeline/append-event-core.ts +22 -6
  167. package/src/pipeline/dispatcher-utils.ts +188 -0
  168. package/src/pipeline/dispatcher.ts +63 -283
  169. package/src/pipeline/distributed-lock.ts +1 -1
  170. package/src/pipeline/entity-cache.ts +2 -2
  171. package/src/pipeline/event-consumer-state.ts +0 -13
  172. package/src/pipeline/event-dispatcher.ts +4 -4
  173. package/src/pipeline/index.ts +0 -2
  174. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  175. package/src/pipeline/msp-rebuild.ts +5 -5
  176. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  177. package/src/pipeline/projection-rebuild.ts +2 -2
  178. package/src/pipeline/projection-state.ts +0 -12
  179. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  180. package/src/rate-limit/resolver.ts +1 -1
  181. package/src/search/in-memory-adapter.ts +1 -1
  182. package/src/search/meilisearch-adapter.ts +3 -3
  183. package/src/search/types.ts +1 -1
  184. package/src/secrets/leak-guard.ts +2 -2
  185. package/src/stack/request-helper.ts +9 -5
  186. package/src/stack/test-stack.ts +1 -1
  187. package/src/testing/handler-context.ts +4 -4
  188. package/src/testing/http-cookies.ts +1 -1
  189. package/src/time/tz-context.ts +1 -2
  190. package/src/ui-types/index.ts +4 -0
  191. 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
+ }