@cosmicdrift/kumiko-framework 0.2.3 → 0.4.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 (166) hide show
  1. package/CHANGELOG.md +93 -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 +44 -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 +93 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/index.ts +11 -1
  116. package/src/engine/types/step.ts +334 -0
  117. package/src/engine/types/target-ref.ts +21 -0
  118. package/src/engine/types/tree-node.ts +132 -0
  119. package/src/engine/types/workspace.ts +7 -0
  120. package/src/engine/validate-projection-allowlist.ts +161 -0
  121. package/src/event-store/snapshot.ts +1 -1
  122. package/src/event-store/upcaster-dead-letter.ts +1 -1
  123. package/src/event-store/upcaster.ts +1 -1
  124. package/src/files/file-routes.ts +1 -1
  125. package/src/files/types.ts +2 -2
  126. package/src/jobs/job-runner.ts +10 -10
  127. package/src/lifecycle/lifecycle.ts +0 -3
  128. package/src/logging/index.ts +1 -0
  129. package/src/logging/pino-logger.ts +11 -7
  130. package/src/logging/utils.ts +24 -0
  131. package/src/observability/prometheus-meter.ts +7 -5
  132. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  133. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  134. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  135. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  136. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  137. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  138. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  139. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  140. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  141. package/src/pipeline/append-event-core.ts +22 -6
  142. package/src/pipeline/dispatcher-utils.ts +188 -0
  143. package/src/pipeline/dispatcher.ts +63 -283
  144. package/src/pipeline/distributed-lock.ts +1 -1
  145. package/src/pipeline/entity-cache.ts +2 -2
  146. package/src/pipeline/event-consumer-state.ts +0 -13
  147. package/src/pipeline/event-dispatcher.ts +4 -4
  148. package/src/pipeline/index.ts +0 -2
  149. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  150. package/src/pipeline/msp-rebuild.ts +5 -5
  151. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  152. package/src/pipeline/projection-rebuild.ts +2 -2
  153. package/src/pipeline/projection-state.ts +0 -12
  154. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  155. package/src/rate-limit/resolver.ts +1 -1
  156. package/src/search/in-memory-adapter.ts +1 -1
  157. package/src/search/meilisearch-adapter.ts +3 -3
  158. package/src/search/types.ts +1 -1
  159. package/src/secrets/leak-guard.ts +2 -2
  160. package/src/stack/request-helper.ts +9 -5
  161. package/src/stack/test-stack.ts +1 -1
  162. package/src/testing/handler-context.ts +4 -4
  163. package/src/testing/http-cookies.ts +1 -1
  164. package/src/time/tz-context.ts +1 -2
  165. package/src/ui-types/index.ts +4 -0
  166. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -597,7 +597,7 @@ const writeHandlerSchema: PatternFormSchema = {
597
597
  input: "json-readonly",
598
598
  },
599
599
  {
600
- path: "skipTransitionGuard",
600
+ path: "unsafeSkipTransitionGuard",
601
601
  label: { en: "Skip transition guard", de: "Übergangs-Guard überspringen" },
602
602
  input: "boolean",
603
603
  },
@@ -1054,6 +1054,44 @@ const exposesApiSchema: PatternFormSchema = {
1054
1054
  ],
1055
1055
  };
1056
1056
 
1057
+ // Visual-Tree pattern schemas. treeActions is a static map (Designer
1058
+ // renders the action-name → ActionDef pairs as a nested form), tree is
1059
+ // closure-only (Designer shows the provider body as read-only code).
1060
+ const treeActionsSchema: PatternFormSchema = {
1061
+ kind: "treeActions",
1062
+ label: { en: "Tree actions", de: "Tree-Actions" },
1063
+ summary: { en: "Action verbs the Visual-Tree dispatches via buildTarget." },
1064
+ category: "ui",
1065
+ editability: "static",
1066
+ singleton: true,
1067
+ fields: [
1068
+ {
1069
+ path: "definitions",
1070
+ label: { en: "Action definitions", de: "Action-Definitionen" },
1071
+ input: "json-readonly",
1072
+ readOnly: true,
1073
+ },
1074
+ ],
1075
+ };
1076
+
1077
+ const treeSchema: PatternFormSchema = {
1078
+ kind: "tree",
1079
+ label: { en: "Tree provider", de: "Tree-Provider" },
1080
+ summary: { en: "Subscribe-Function emitting top-level Visual-Tree nodes." },
1081
+ category: "ui",
1082
+ editability: "opaque",
1083
+ singleton: true,
1084
+ fields: [
1085
+ {
1086
+ path: "providerBody",
1087
+ label: { en: "Provider body (source)", de: "Provider-Body (Source)" },
1088
+ input: "code-block",
1089
+ language: "typescript",
1090
+ readOnly: true,
1091
+ },
1092
+ ],
1093
+ };
1094
+
1057
1095
  const unknownSchema: PatternFormSchema = {
1058
1096
  kind: "unknown",
1059
1097
  label: { en: "Unknown call", de: "Unbekannter Call" },
@@ -1113,8 +1151,10 @@ export const PATTERN_LIBRARY: Readonly<Record<FeaturePatternKind, PatternFormSch
1113
1151
  extendsRegistrar: extendsRegistrarSchema,
1114
1152
  usesApi: usesApiSchema,
1115
1153
  exposesApi: exposesApiSchema,
1154
+ treeActions: treeActionsSchema,
1155
+ tree: treeSchema,
1116
1156
  unknown: unknownSchema,
1117
- };
1157
+ } satisfies Readonly<Record<FeaturePatternKind, PatternFormSchema>>;
1118
1158
 
1119
1159
  /**
1120
1160
  * Lookup helper — convenience over `PATTERN_LIBRARY[kind]`. Throws when
@@ -0,0 +1,88 @@
1
+ // pipeline() — public factory used in defineWriteHandler({ perform: pipeline(...) }).
2
+ //
3
+ // The closure receives { event, r } and returns the immutable list of
4
+ // step instances. `r` is the StepBuilder singleton; new tier-1 steps
5
+ // add a builder factory in steps/<x>.ts and expose it under `step` below.
6
+ //
7
+ // `steps` and `scope` are NOT exposed at build time — they only exist on
8
+ // the resolver-side PipelineCtx (run-pipeline.ts). Resolvers that need
9
+ // prior step results destructure them from the resolver's ctx.
10
+ //
11
+ // Naming note (Followup #1): the public `pipeline()` helper shares its
12
+ // name with the internal `packages/framework/src/pipeline/` directory
13
+ // (dispatcher, lifecycle, outbox-poller). No user-visible collision —
14
+ // the internal directory isn't an export — but maintainer repo-wide
15
+ // grep for `pipeline` returns mixed results. If a rename ever lands,
16
+ // candidates are `r.steps([...])` for the public API or
17
+ // `engine-pipeline/` / `runtime/` for the internal directory.
18
+ // Decision-cost grows with each new caller; the rename window narrows
19
+ // after the first external consumer.
20
+
21
+ import { buildAggregateAppendEventStep } from "./steps/aggregate-append-event";
22
+ import { buildAggregateCreateStep } from "./steps/aggregate-create";
23
+ import { buildAggregateUpdateStep } from "./steps/aggregate-update";
24
+ import { buildBranchStep } from "./steps/branch";
25
+ import { buildCallFeatureStep } from "./steps/call-feature";
26
+ import { buildComputeStep } from "./steps/compute";
27
+ import { buildForEachStep } from "./steps/for-each";
28
+ import { buildMailSendStep } from "./steps/mail-send";
29
+ import { buildReadFindManyStep } from "./steps/read-find-many";
30
+ import { buildReadFindOneStep } from "./steps/read-find-one";
31
+ import { buildRetryStep } from "./steps/retry";
32
+ import { buildReturnStep } from "./steps/return";
33
+ import { buildUnsafeProjectionDeleteStep } from "./steps/unsafe-projection-delete";
34
+ import { buildUnsafeProjectionUpsertStep } from "./steps/unsafe-projection-upsert";
35
+ import { buildWaitStep } from "./steps/wait";
36
+ import { buildWaitForEventStep } from "./steps/wait-for-event";
37
+ import { buildWebhookSendStep } from "./steps/webhook-send";
38
+ import type { WriteEvent } from "./types/handlers";
39
+ import type { PipelineBuildCtx, PipelineDef, StepBuilder, StepInstance } from "./types/step";
40
+
41
+ const stepBuilder: StepBuilder = {
42
+ step: {
43
+ return: buildReturnStep,
44
+ compute: buildComputeStep,
45
+ branch: buildBranchStep,
46
+ forEach: buildForEachStep,
47
+ unsafeProjectionUpsert: buildUnsafeProjectionUpsertStep,
48
+ unsafeProjectionDelete: buildUnsafeProjectionDeleteStep,
49
+ aggregate: {
50
+ create: buildAggregateCreateStep,
51
+ update: buildAggregateUpdateStep,
52
+ appendEvent: buildAggregateAppendEventStep,
53
+ },
54
+ read: {
55
+ findOne: buildReadFindOneStep,
56
+ findMany: buildReadFindManyStep,
57
+ },
58
+ webhook: {
59
+ send: buildWebhookSendStep,
60
+ },
61
+ mail: {
62
+ send: buildMailSendStep,
63
+ },
64
+ callFeature: buildCallFeatureStep,
65
+ // Tier-3 / Workflow-only steps
66
+ wait: buildWaitStep,
67
+ waitForEvent: buildWaitForEventStep,
68
+ retry: buildRetryStep,
69
+ },
70
+ };
71
+
72
+ export function pipeline<TPayload = unknown, TData = unknown>(
73
+ closure: (ctx: PipelineBuildCtx<TPayload>) => readonly StepInstance[],
74
+ ): PipelineDef<TPayload, TData> {
75
+ return {
76
+ __kind: "pipeline",
77
+ build: closure,
78
+ };
79
+ }
80
+
81
+ // Internal: invoked by run-pipeline.ts to materialise the step list.
82
+ // Not exported from the engine barrel — pipeline-internal plumbing.
83
+ export function buildPipelineSteps<TPayload>(
84
+ pipelineDef: PipelineDef<TPayload>,
85
+ event: WriteEvent<TPayload>,
86
+ ): readonly StepInstance[] {
87
+ return pipelineDef.build({ event, r: stepBuilder });
88
+ }
@@ -76,7 +76,7 @@ export function setFields(
76
76
  // strict about the concrete row, so we feed it the erased value; the
77
77
  // type-safety guarantee for `values` lives at the setFields call-site.
78
78
  // biome-ignore lint/suspicious/noExplicitAny: see note above.
79
- const set = values as any;
79
+ const set = values as any; // @cast-boundary engine-bridge
80
80
  await tx
81
81
  .update(table)
82
82
  .set(set)
@@ -27,5 +27,5 @@ export function readClaim<T extends ClaimKeyType>(
27
27
  if (!claims) return undefined;
28
28
  const raw = claims[handle.name];
29
29
  if (raw === undefined || raw === null) return undefined;
30
- return raw as ClaimKeyJsType<T>;
30
+ return raw as ClaimKeyJsType<T>; // @cast-boundary schema-walk
31
31
  }
@@ -35,6 +35,8 @@ import type {
35
35
  ScreenDefinition,
36
36
  SecretKeyDefinition,
37
37
  TranslationKeys,
38
+ TreeActionDef,
39
+ TreeChildrenSubscribe,
38
40
  WorkspaceDefinition,
39
41
  WriteHandlerDef,
40
42
  } from "./types";
@@ -198,6 +200,14 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
198
200
  const navsByWorkspace = new Map<string, string[]>();
199
201
  let defaultWorkspace: WorkspaceDefinition | undefined;
200
202
 
203
+ // Visual-Tree-Provider — keyed by declaring feature name (NOT qualified;
204
+ // ein Feature liefert genau einen Provider). Visual-Tree-Component
205
+ // iteriert die Map zur Mount-Zeit. Tree-Actions parallel — featureName
206
+ // → erased Action-Map (compile-time-typed Variante geht über
207
+ // setup-export-handle, siehe FeatureRegistrar.treeActions docs).
208
+ const treeProvidersMap = new Map<string, TreeChildrenSubscribe>();
209
+ const treeActionsMap = new Map<string, Readonly<Record<string, TreeActionDef>>>();
210
+
201
211
  // Local alias for readability — `qualifyEntityName` is the shared helper
202
212
  // from qualified-name.ts, also used by validateBoot to keep ingest and
203
213
  // validation in lockstep on the qualification rule.
@@ -596,6 +606,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
596
606
  }
597
607
  }
598
608
 
609
+ // Visual-Tree slots — at-most-one per feature (only-once-guard im
610
+ // registrar). Erased Maps für Runtime-Lookup; compile-time-typed
611
+ // Surface läuft über FeatureDefinition.exports (TreeActionsHandle).
612
+ if (feature.treeProvider !== undefined) {
613
+ treeProvidersMap.set(feature.name, feature.treeProvider);
614
+ }
615
+ if (feature.treeActions !== undefined) {
616
+ treeActionsMap.set(feature.name, feature.treeActions);
617
+ }
618
+
599
619
  // Auth-claims hooks: order of registration is preserved. Feature name is
600
620
  // captured alongside so the resolver can apply the auto-prefix at merge
601
621
  // time — the feature author never ships pre-prefixed keys.
@@ -984,7 +1004,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
984
1004
  const allHandlerNames = new Set([...writeHandlerMap.keys(), ...queryHandlerMap.keys()]);
985
1005
  for (const [qualifiedName, notifDef] of notificationMap) {
986
1006
  // Both maps are populated in lockstep — same key-set by construction.
987
- const featureName = notificationFeatureMap.get(qualifiedName) as string;
1007
+ const featureName = notificationFeatureMap.get(qualifiedName) as string; // @cast-boundary engine-bridge
988
1008
  // I'll try the easy path first: if the trigger is already a fully qualified QN
989
1009
  // (cross-feature), I take it as-is. Otherwise I qualify with the own feature —
990
1010
  // as a write handler first (the common case), then as a query. If nothing
@@ -1124,7 +1144,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1124
1144
  },
1125
1145
 
1126
1146
  getRelations(entityName: string): EntityRelations {
1127
- return (relationMap.get(entityName) ?? {}) as EntityRelations;
1147
+ return (relationMap.get(entityName) ?? {}) as EntityRelations; // @cast-boundary schema-walk
1128
1148
  },
1129
1149
 
1130
1150
  getSearchIncludes(entityName: string): ReadonlyMap<string, readonly string[]> {
@@ -1355,6 +1375,14 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1355
1375
  getDefaultWorkspace(): WorkspaceDefinition | undefined {
1356
1376
  return defaultWorkspace;
1357
1377
  },
1378
+
1379
+ getTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe> {
1380
+ return treeProvidersMap;
1381
+ },
1382
+
1383
+ getTreeActions(featureName: string): Readonly<Record<string, TreeActionDef>> | undefined {
1384
+ return treeActionsMap.get(featureName);
1385
+ },
1358
1386
  };
1359
1387
  }
1360
1388
 
@@ -141,6 +141,10 @@ export async function resolveConfigOrParam<T extends ConfigKeyType>(
141
141
  // The caller is signalling intent; we honour the constraint instead.
142
142
  return ctx.config(handle);
143
143
  }
144
+ default: {
145
+ const _exhaustive: never = keyDef.type;
146
+ throw new Error(`resolveConfigOrParam: unhandled config key type "${_exhaustive}"`);
147
+ }
144
148
  }
145
149
  }
146
150
 
@@ -0,0 +1,162 @@
1
+ // run-pipeline — executes a PipelineDef against (event, ctx) and
2
+ // returns a WriteResult.
3
+ //
4
+ // Execution model:
5
+ // 1. Build the immutable step list by invoking the pipeline closure.
6
+ // 2. Walk the list sequentially via runStepList. For each step:
7
+ // - Look up the registered StepDef by kind.
8
+ // - Build the live PipelineCtx (handler-ctx + accumulated steps + scope).
9
+ // - Call step.run(args, ctx) — capture its return value.
10
+ // - Detect the sentinel RETURN_RESULT_KEY → end pipeline with that
11
+ // WriteResult; otherwise stash the value under resultKey if any.
12
+ // 3. If the loop exhausts without a `return` step, surface a loud error
13
+ // so a forgotten r.step.return doesn't fall through silently.
14
+ //
15
+ // runStepList is exported for sub-step builders (branch/forEach in M.1.6)
16
+ // — they invoke it recursively over their own step-arrays, sharing the
17
+ // outer pipeline's stepsAcc + scopeAcc maps so sub-step results
18
+ // propagate up to subsequent top-level steps.
19
+ //
20
+ // Failure-strategy is "throw" only in M.1 — runPipeline lets thrown
21
+ // errors propagate to the dispatcher's catch which maps them to
22
+ // WriteFailure / HTTP. "return" / "skip" / fallback strategies land in
23
+ // later slices together with their own integration tests.
24
+
25
+ import { getStep } from "./define-step";
26
+ import { buildPipelineSteps } from "./pipeline";
27
+ import { SUSPEND_SENTINEL } from "./steps/_step-dispatch-constants";
28
+ import { RETURN_RESULT_KEY } from "./steps/return";
29
+ import type { KumikoEventTypeMap } from "./types/event-type-map";
30
+ import type { HandlerContext, WriteEvent, WriteResult } from "./types/handlers";
31
+ import type { PipelineCtx, PipelineDef, StepInstance } from "./types/step";
32
+
33
+ // Result of walking a step-list. "return" surfaces the WriteResult of an
34
+ // r.step.return; "exhausted" means all steps ran without hitting a return
35
+ // — the caller (top-level pipeline) treats that as an error, sub-step
36
+ // callers (branch/forEach) treat it as normal completion.
37
+ // "suspended" means a Tier-3 step returned SUSPEND_SENTINEL — the pipeline
38
+ // is paused pending external (time/event) and must be resumed later.
39
+ export type StepListOutcome =
40
+ | { readonly kind: "return"; readonly result: WriteResult<unknown> }
41
+ | { readonly kind: "suspended"; readonly stepIndex: number }
42
+ | { readonly kind: "exhausted" };
43
+
44
+ export async function runPipeline<TPayload, TData, TMap extends object = KumikoEventTypeMap>(
45
+ pipelineDef: PipelineDef<TPayload, TData>,
46
+ event: WriteEvent<TPayload>,
47
+ handlerCtx: HandlerContext<TMap>,
48
+ workflow?: PipelineCtx["workflow"],
49
+ resumeFrom?: number,
50
+ ): Promise<WriteResult<TData>> {
51
+ const steps = buildPipelineSteps(pipelineDef, event);
52
+ const stepsAcc: Record<string, unknown> = {};
53
+ const scopeAcc: Record<string, unknown> = {};
54
+
55
+ const outcome = await runStepList(
56
+ steps,
57
+ event,
58
+ handlerCtx,
59
+ stepsAcc,
60
+ scopeAcc,
61
+ workflow,
62
+ resumeFrom,
63
+ );
64
+ if (outcome.kind === "return") {
65
+ // RETURN_RESULT_KEY is only produced by r.step.return, whose run()
66
+ // returns WriteResult<unknown>. The pipeline's generic TData is
67
+ // bound at build time (defineWriteHandler ↔ pipeline<P, D>(...));
68
+ // matching the runtime value to that compile-time type is the
69
+ // contract user-side. Cast crosses that boundary.
70
+ return outcome.result as WriteResult<TData>;
71
+ }
72
+
73
+ if (outcome.kind === "suspended") {
74
+ // Suspension is only valid when running inside a workflow context.
75
+ // The caller (workflow-engine) handles the suspension lifecycle;
76
+ // we throw here because runPipeline's contract requires a WriteResult.
77
+ // The workflow engine calls runStepList directly to detect suspension.
78
+ if (!workflow) {
79
+ throw new Error(
80
+ "Pipeline suspended without a workflow context — Tier-3 steps are only allowed inside defineWorkflow.",
81
+ );
82
+ }
83
+ // Return a minimal WriteResult signalling suspension. The workflow
84
+ // engine extracts the outcome from runStepList directly.
85
+ return { isSuccess: true, data: undefined } as unknown as WriteResult<TData>;
86
+ }
87
+
88
+ throw new Error(
89
+ "Pipeline ended without an r.step.return(...) — every pipeline must explicitly return a WriteResult.",
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Walk a step-list. Stateful in `stepsAcc` + `scopeAcc` (the caller's
95
+ * mutable maps) — sub-step builders share those with the outer pipeline
96
+ * so step results propagate. Returns either an early r.step.return
97
+ * outcome or an "exhausted" signal.
98
+ *
99
+ * Sub-step builders (branch.run, forEach.run) re-enter via this
100
+ * function. The same TMap-variance bridge applies — sub-steps treat
101
+ * ctx as PipelineCtx<unknown, KumikoEventTypeMap>, the runtime value
102
+ * is the outer's full HandlerContext.
103
+ */
104
+ export async function runStepList<TPayload, TMap extends object = KumikoEventTypeMap>(
105
+ steps: readonly StepInstance[],
106
+ event: WriteEvent<TPayload>,
107
+ handlerCtx: HandlerContext<TMap>,
108
+ stepsAcc: Record<string, unknown>,
109
+ scopeAcc: Record<string, unknown>,
110
+ workflow?: PipelineCtx["workflow"],
111
+ resumeFrom?: number,
112
+ ): Promise<StepListOutcome> {
113
+ for (const [i, instance] of steps.entries()) {
114
+ // On resume, skip steps at or before the resume point — their
115
+ // effects (waiting/retry-scheduled events) were already written
116
+ // during the original pipeline run. The next unexecuted step
117
+ // is at resumeFrom + 1.
118
+ if (resumeFrom !== undefined && i < resumeFrom) {
119
+ continue;
120
+ }
121
+
122
+ // When resuming, re-execute the suspended step itself. Steps
123
+ // like wait/waitForEvent detect resumption by checking if a
124
+ // waiting event for their stepIndex already exists; retry
125
+ // uses retryAttempt from the workflow context.
126
+ // Steps that don't handle resumption (read/compute/aggregate)
127
+ // re-execute naturally — idempotent reads are safe, and
128
+ // event-sourced writes append new positions.
129
+ const stepDef = getStep(instance.kind);
130
+ if (!stepDef) {
131
+ throw new Error(`Unknown step kind "${instance.kind}" at step index ${i}`);
132
+ }
133
+
134
+ const pipelineCtx: PipelineCtx<TPayload, TMap> = {
135
+ ...handlerCtx,
136
+ event,
137
+ steps: stepsAcc,
138
+ scope: scopeAcc,
139
+ ...(workflow && {
140
+ workflow: { ...workflow, stepIndex: i },
141
+ }),
142
+ } as PipelineCtx<TPayload, TMap>;
143
+
144
+ const value = await stepDef.run(instance.args, pipelineCtx as unknown as PipelineCtx);
145
+
146
+ // Tier-3 suspension: the step wrote a waiting event and returned
147
+ // SUSPEND_SENTINEL to signal the pipeline should stop. The caller
148
+ // (defineWorkflow/workflow-engine) persists the suspension state.
149
+ if (value === SUSPEND_SENTINEL) {
150
+ return { kind: "suspended", stepIndex: i };
151
+ }
152
+
153
+ const key = stepDef.resultKey?.(instance.args);
154
+ if (key === RETURN_RESULT_KEY) {
155
+ return { kind: "return", result: value as WriteResult<unknown> };
156
+ }
157
+ if (key !== undefined) {
158
+ stepsAcc[key] = value;
159
+ }
160
+ }
161
+ return { kind: "exhausted" };
162
+ }
@@ -173,10 +173,8 @@ export function buildUpdateSchema(
173
173
  // data via the event-store-executor's `changes` payload.
174
174
  // Cast widens the discriminated union so destructure works for variants
175
175
  // without a `default` field; remainder is structurally a FieldDefinition.
176
- const { default: _default, ...stripped } = field as FieldDefinition & {
177
- default?: unknown;
178
- };
179
- shape[name] = fieldToZod(stripped as FieldDefinition, currencies).optional();
176
+ const { default: _default, ...stripped } = field as FieldDefinition & { default?: unknown }; // @cast-boundary schema-walk
177
+ shape[name] = fieldToZod(stripped as FieldDefinition, currencies).optional(); // @cast-boundary schema-walk
180
178
  }
181
179
 
182
180
  return z.object(shape);
@@ -36,7 +36,7 @@ export function defineTransitions<const TMap extends Record<string, readonly str
36
36
  canTransition: (from, to) => internal.get(from)?.has(to) === true,
37
37
  allowedFrom: (from) => {
38
38
  const set = internal.get(from);
39
- return set ? ([...set] as TStates[]) : [];
39
+ return set ? ([...set] as TStates[]) : []; // @cast-boundary schema-walk
40
40
  },
41
41
  assertTransition: (from, to) => {
42
42
  const set = internal.get(from);
@@ -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
+ }