@cosmicdrift/kumiko-framework 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +124 -39
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/compliance/profiles.ts +8 -8
  10. package/src/db/assert-exists-in.ts +2 -2
  11. package/src/db/cursor.ts +3 -3
  12. package/src/db/event-store-executor.ts +19 -13
  13. package/src/db/located-timestamp.ts +1 -1
  14. package/src/db/money.ts +12 -2
  15. package/src/db/pg-error.ts +1 -1
  16. package/src/db/row-helpers.ts +1 -1
  17. package/src/db/table-builder.ts +3 -5
  18. package/src/db/tenant-db.ts +9 -9
  19. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  20. package/src/engine/__tests__/build-target.test.ts +135 -0
  21. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  22. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  23. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  24. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  25. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  26. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  27. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  28. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  29. package/src/engine/__tests__/raw-table.test.ts +2 -2
  30. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  31. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  32. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  33. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  34. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  35. package/src/engine/__tests__/steps-read.test.ts +142 -0
  36. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  37. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  38. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  39. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  40. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  41. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  42. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  43. package/src/engine/boot-validator/api-ext.ts +77 -0
  44. package/src/engine/boot-validator/config-deps.ts +163 -0
  45. package/src/engine/boot-validator/entity-handler.ts +466 -0
  46. package/src/engine/boot-validator/index.ts +159 -0
  47. package/src/engine/boot-validator/ownership.ts +198 -0
  48. package/src/engine/boot-validator/pii-retention.ts +155 -0
  49. package/src/engine/boot-validator/screens-nav.ts +624 -0
  50. package/src/engine/boot-validator.ts +1 -1804
  51. package/src/engine/build-app-schema.ts +1 -1
  52. package/src/engine/build-target.ts +99 -0
  53. package/src/engine/codemod/index.ts +15 -0
  54. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  55. package/src/engine/config-helpers.ts +9 -19
  56. package/src/engine/constants.ts +1 -1
  57. package/src/engine/define-feature.ts +88 -9
  58. package/src/engine/define-handler.ts +89 -3
  59. package/src/engine/define-roles.ts +2 -2
  60. package/src/engine/define-step.ts +28 -0
  61. package/src/engine/define-workflow.ts +110 -0
  62. package/src/engine/entity-handlers.ts +10 -9
  63. package/src/engine/event-helpers.ts +4 -4
  64. package/src/engine/factories.ts +12 -12
  65. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  66. package/src/engine/feature-ast/extractors/index.ts +74 -0
  67. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  68. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  69. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  70. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  71. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  72. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  73. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  74. package/src/engine/feature-ast/parse.ts +7 -0
  75. package/src/engine/feature-ast/patch.ts +9 -1
  76. package/src/engine/feature-ast/patcher.ts +10 -3
  77. package/src/engine/feature-ast/patterns.ts +49 -1
  78. package/src/engine/feature-ast/render.ts +17 -1
  79. package/src/engine/index.ts +45 -2
  80. package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
  81. package/src/engine/pattern-library/library.ts +42 -2
  82. package/src/engine/pipeline.ts +88 -0
  83. package/src/engine/projection-helpers.ts +1 -1
  84. package/src/engine/read-claim.ts +1 -1
  85. package/src/engine/registry.ts +30 -2
  86. package/src/engine/resolve-config-or-param.ts +4 -0
  87. package/src/engine/run-pipeline.ts +162 -0
  88. package/src/engine/schema-builder.ts +2 -4
  89. package/src/engine/state-machine.ts +1 -1
  90. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  91. package/src/engine/steps/_duration-utils.ts +33 -0
  92. package/src/engine/steps/_no-return-guard.ts +21 -0
  93. package/src/engine/steps/_resolver-utils.ts +42 -0
  94. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  95. package/src/engine/steps/aggregate-append-event.ts +56 -0
  96. package/src/engine/steps/aggregate-create.ts +56 -0
  97. package/src/engine/steps/aggregate-update.ts +68 -0
  98. package/src/engine/steps/branch.ts +84 -0
  99. package/src/engine/steps/call-feature.ts +49 -0
  100. package/src/engine/steps/compute.ts +41 -0
  101. package/src/engine/steps/for-each.ts +111 -0
  102. package/src/engine/steps/mail-send.ts +44 -0
  103. package/src/engine/steps/read-find-many.ts +51 -0
  104. package/src/engine/steps/read-find-one.ts +58 -0
  105. package/src/engine/steps/retry.ts +87 -0
  106. package/src/engine/steps/return.ts +34 -0
  107. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  108. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  109. package/src/engine/steps/wait-for-event.ts +71 -0
  110. package/src/engine/steps/wait.ts +69 -0
  111. package/src/engine/steps/webhook-send.ts +71 -0
  112. package/src/engine/system-user.ts +1 -1
  113. package/src/engine/types/feature.ts +92 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/identifiers.ts +1 -0
  116. package/src/engine/types/index.ts +12 -1
  117. package/src/engine/types/step.ts +334 -0
  118. package/src/engine/types/target-ref.ts +21 -0
  119. package/src/engine/types/tree-node.ts +130 -0
  120. package/src/engine/types/workspace.ts +7 -0
  121. package/src/engine/validate-projection-allowlist.ts +161 -0
  122. package/src/event-store/snapshot.ts +1 -1
  123. package/src/event-store/upcaster-dead-letter.ts +1 -1
  124. package/src/event-store/upcaster.ts +1 -1
  125. package/src/files/file-routes.ts +1 -1
  126. package/src/files/types.ts +2 -2
  127. package/src/jobs/job-runner.ts +10 -10
  128. package/src/lifecycle/lifecycle.ts +0 -3
  129. package/src/logging/index.ts +1 -0
  130. package/src/logging/pino-logger.ts +11 -7
  131. package/src/logging/utils.ts +24 -0
  132. package/src/observability/prometheus-meter.ts +7 -5
  133. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  134. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  135. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  136. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  137. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  138. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  139. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  140. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  141. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  142. package/src/pipeline/append-event-core.ts +22 -6
  143. package/src/pipeline/dispatcher-utils.ts +188 -0
  144. package/src/pipeline/dispatcher.ts +63 -283
  145. package/src/pipeline/distributed-lock.ts +1 -1
  146. package/src/pipeline/entity-cache.ts +2 -2
  147. package/src/pipeline/event-consumer-state.ts +0 -13
  148. package/src/pipeline/event-dispatcher.ts +4 -4
  149. package/src/pipeline/index.ts +0 -2
  150. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  151. package/src/pipeline/msp-rebuild.ts +5 -5
  152. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  153. package/src/pipeline/projection-rebuild.ts +2 -2
  154. package/src/pipeline/projection-state.ts +0 -12
  155. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  156. package/src/rate-limit/resolver.ts +1 -1
  157. package/src/search/in-memory-adapter.ts +1 -1
  158. package/src/search/meilisearch-adapter.ts +3 -3
  159. package/src/search/types.ts +1 -1
  160. package/src/secrets/leak-guard.ts +2 -2
  161. package/src/stack/request-helper.ts +9 -5
  162. package/src/stack/test-stack.ts +1 -1
  163. package/src/testing/handler-context.ts +4 -4
  164. package/src/testing/http-cookies.ts +1 -1
  165. package/src/time/tz-context.ts +1 -2
  166. package/src/ui-types/index.ts +4 -0
  167. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -0,0 +1,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
+ }
@@ -0,0 +1,51 @@
1
+ // r.step.read.findMany — load multiple rows from a projection table.
2
+ //
3
+ // Sibling to read.findOne — same tenant-filter caveat (caller-owned),
4
+ // same drizzle-boundary cast. Resolves to a row-array (possibly empty),
5
+ // landed under steps.<name>.
6
+ //
7
+ // Optional `limit` — defaults to no-limit (caller-chosen, NOT a
8
+ // guard-rail). Most legitimate uses iterate via r.step.forEach (M.1.6)
9
+ // over the result, where unbounded arrays would be the bug. Set
10
+ // `limit` explicitly when the row-count could grow without bound.
11
+
12
+ import type { SQL, Table } from "drizzle-orm";
13
+ import { defineStep } from "../define-step";
14
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
15
+ import { asQueryTarget } from "./_drizzle-boundary";
16
+ import { resolveOptional } from "./_resolver-utils";
17
+
18
+ type ReadFindManyArgs = {
19
+ readonly name: string;
20
+ readonly table: Table;
21
+ readonly where?: StepResolver<SQL | undefined>;
22
+ readonly limit?: number;
23
+ };
24
+
25
+ defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
26
+ kind: "read.findMany",
27
+ defaultFailureStrategy: "throw",
28
+ resultKey: (args) => args.name,
29
+ run: async (args, ctx: PipelineCtx) => {
30
+ const where = resolveOptional(args.where, ctx);
31
+ const baseQuery = ctx.db.select().from(asQueryTarget(args.table));
32
+ const filteredQuery = where === undefined ? baseQuery : baseQuery.where(where);
33
+ const finalQuery = args.limit === undefined ? filteredQuery : filteredQuery.limit(args.limit);
34
+ const rows = await finalQuery;
35
+ return rows as readonly Record<string, unknown>[];
36
+ },
37
+ });
38
+
39
+ export function buildReadFindManyStep(
40
+ name: string,
41
+ opts: {
42
+ readonly table: Table;
43
+ readonly where?: StepResolver<SQL | undefined>;
44
+ readonly limit?: number;
45
+ },
46
+ ): StepInstance {
47
+ return {
48
+ kind: "read.findMany",
49
+ args: { name, ...opts } satisfies ReadFindManyArgs,
50
+ };
51
+ }
@@ -0,0 +1,58 @@
1
+ // r.step.read.findOne — load a single row from a projection table.
2
+ //
3
+ // Thin wrapper on ctx.db.select().from(table).where(where).limit(1).
4
+ // Resolves to the first row or null. Tenant-isolation: the caller's
5
+ // `where` clause is responsible for any tenantId filter — read.findOne
6
+ // does NOT auto-inject one (different from ctx.queryProjection which
7
+ // does). That's deliberate: most read-step uses are aggregate-lookups
8
+ // where the where-clause already pins a uuid that's globally unique;
9
+ // auto-tenant-filtering would be redundant and would surprise users
10
+ // who pass an explicit tenantId.
11
+ //
12
+ // Use when a subsequent step needs a row from the read-side. For
13
+ // cross-feature reads, prefer `r.step.callFeature(...)` (M.2) so the
14
+ // other feature's query-handler runs (with its access-rules + audit).
15
+ //
16
+ // `where` should resolve to a clause that matches at most one row
17
+ // (typical: equality on PK / unique-constraint). When multiple rows
18
+ // satisfy the clause, the LIMIT 1 picks one in driver-defined order
19
+ // (Postgres: insertion order in practice, but not specified) — that's
20
+ // fine for "find by uuid", a footgun for "find by tenantId". No
21
+ // runtime check; reviewer responsibility.
22
+
23
+ import type { SQL, Table } from "drizzle-orm";
24
+ import { defineStep } from "../define-step";
25
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
26
+ import { asQueryTarget } from "./_drizzle-boundary";
27
+ import { resolveRequired } from "./_resolver-utils";
28
+
29
+ type ReadFindOneArgs = {
30
+ readonly name: string;
31
+ readonly table: Table;
32
+ readonly where: StepResolver<SQL | undefined>;
33
+ };
34
+
35
+ defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
36
+ kind: "read.findOne",
37
+ defaultFailureStrategy: "throw",
38
+ resultKey: (args) => args.name,
39
+ run: async (args, ctx: PipelineCtx) => {
40
+ const where = resolveRequired(args.where, ctx);
41
+ const query = ctx.db.select().from(asQueryTarget(args.table));
42
+ const rows = where === undefined ? await query.limit(1) : await query.where(where).limit(1);
43
+ return (rows[0] as Record<string, unknown> | undefined) ?? null;
44
+ },
45
+ });
46
+
47
+ export function buildReadFindOneStep(
48
+ name: string,
49
+ opts: {
50
+ readonly table: Table;
51
+ readonly where: StepResolver<SQL | undefined>;
52
+ },
53
+ ): StepInstance {
54
+ return {
55
+ kind: "read.findOne",
56
+ args: { name, ...opts } satisfies ReadFindOneArgs,
57
+ };
58
+ }
@@ -0,0 +1,87 @@
1
+ // r.step.retry — wrap a sub-pipeline with retry+backoff.
2
+ // Tier-3 / Workflow-only: only available inside defineWorkflow.
3
+ //
4
+ // On first execution, runs the `do` sub-pipeline inside a try-catch.
5
+ // If it throws and retries remain, writes a kumiko:system:workflow.retry.scheduled
6
+ // event and returns SUSPEND_SENTINEL to suspend. The Resume-Loop picks it up
7
+ // after the backoff duration and re-enters the step at the same index.
8
+ // After all attempts exhausted, the original error propagates.
9
+
10
+ import { defineStep } from "../define-step";
11
+ import { runStepList } from "../run-pipeline";
12
+ import type { PipelineCtx, StepInstance } from "../types/step";
13
+ import {
14
+ SUSPEND_SENTINEL,
15
+ WORKFLOW_AGGREGATE_TYPE,
16
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
17
+ } from "./_step-dispatch-constants";
18
+
19
+ type RetryStepArgs = {
20
+ readonly times: number;
21
+ readonly backoff: "exponential" | "linear";
22
+ readonly do: readonly StepInstance[];
23
+ };
24
+
25
+ defineStep<RetryStepArgs, undefined | typeof SUSPEND_SENTINEL>({
26
+ kind: "workflow.retry",
27
+ tier: 3,
28
+ defaultFailureStrategy: "throw",
29
+ subPaths: ["do"],
30
+ run: async (args, ctx: PipelineCtx): Promise<undefined | typeof SUSPEND_SENTINEL> => {
31
+ if (!ctx.workflow) {
32
+ throw new Error(
33
+ "r.step.retry is only allowed inside defineWorkflow — " + "sync handlers cannot suspend.",
34
+ );
35
+ }
36
+
37
+ const stepsAcc = ctx.steps as Record<string, unknown>;
38
+ const scopeAcc = ctx.scope as Record<string, unknown>;
39
+ const maxAttempts = args.times;
40
+ const attempt = ctx.workflow.retryAttempt ?? 1;
41
+
42
+ try {
43
+ await runStepList(args.do, ctx.event, ctx, stepsAcc, scopeAcc, ctx.workflow);
44
+ return undefined;
45
+ } catch (error) {
46
+ if (attempt >= maxAttempts) {
47
+ throw error;
48
+ }
49
+
50
+ const backoffMs = calculateBackoff(attempt, args.backoff);
51
+ const wakeAt = Temporal.Now.instant().add({ milliseconds: backoffMs }).toString();
52
+
53
+ await ctx.unsafeAppendEvent({
54
+ aggregateId: ctx.workflow.runId,
55
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
56
+ type: WORKFLOW_RETRY_SCHEDULED_TYPE,
57
+ payload: {
58
+ stepIndex: ctx.workflow.stepIndex,
59
+ attempt,
60
+ maxAttempts,
61
+ wakeAt,
62
+ workflowName: ctx.workflow.workflowName,
63
+ error: String(error),
64
+ triggerEventType: ctx.event.type,
65
+ triggerPayload: ctx.event.payload,
66
+ ...(ctx.workflow.definitionFingerprint && {
67
+ definitionFingerprint: ctx.workflow.definitionFingerprint,
68
+ }),
69
+ },
70
+ });
71
+
72
+ return SUSPEND_SENTINEL;
73
+ }
74
+ },
75
+ });
76
+
77
+ export function buildRetryStep(args: RetryStepArgs): StepInstance {
78
+ return { kind: "workflow.retry", args };
79
+ }
80
+
81
+ export function calculateBackoff(attempt: number, strategy: "exponential" | "linear"): number {
82
+ const baseMs = 10_000;
83
+ if (strategy === "linear") {
84
+ return baseMs * attempt;
85
+ }
86
+ return baseMs * 2 ** (attempt - 1);
87
+ }