@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
@@ -56,15 +56,20 @@ import type {
56
56
  SecretOptions,
57
57
  TranslationKeys,
58
58
  TranslationsDef,
59
+ TreeActionDef,
60
+ TreeActionsHandle,
61
+ TreeChildrenSubscribe,
59
62
  ValidationHookFn,
60
63
  WriteHandlerDef,
61
64
  WriteHandlerFn,
62
65
  } from "./types";
63
66
  import { HookPhases } from "./types";
67
+ import type { RequiresApi } from "./types/feature";
64
68
  import { resolveName } from "./types/handlers";
65
69
  import type { HttpRouteDefinition } from "./types/http-route";
66
70
  import type { NavDefinition } from "./types/nav";
67
71
  import type { ScreenDefinition } from "./types/screen";
72
+ import type { PipelineDef } from "./types/step";
68
73
  import type { WorkspaceDefinition } from "./types/workspace";
69
74
 
70
75
  const LIFECYCLE_TYPES = Object.values(LifecycleHookTypes);
@@ -87,6 +92,11 @@ export function defineFeature<const TName extends string, TExports = undefined>(
87
92
  ): FeatureDefinition & { readonly exports: TExports } {
88
93
  const requires: string[] = [];
89
94
  const optionalRequires: string[] = [];
95
+ // Read-side projection-tables declared via r.requires.projection("table").
96
+ // Boot-validator checks unsafeProjection-* step calls against this set.
97
+ const requiredProjections = new Set<string>();
98
+ // Tier-2 step kinds declared via r.requires.step("webhook.send"). Q9.
99
+ const requiredSteps = new Set<string>();
90
100
  const entities: Record<string, EntityDefinition> = {};
91
101
  const relations: Record<string, Record<string, RelationDefinition>> = {};
92
102
  const writeHandlers: Record<string, WriteHandlerDef> = {};
@@ -135,6 +145,14 @@ export function defineFeature<const TName extends string, TExports = undefined>(
135
145
 
136
146
  let isSystemScoped = false;
137
147
  let toggleableDefault: boolean | undefined;
148
+ // Visual-Tree-Slots — at-most-one per feature, only-once-guard im
149
+ // registrar (siehe r.treeActions / r.tree). Undefined wenn das Feature
150
+ // keinen Visual-Tree-Beitrag liefert (Zero-Whitelist-Filter).
151
+ // Name-Collision-Sicherheit: object-literal-method-Names im registrar
152
+ // sind keine bindings im closure-scope, daher kollidiert die `treeActions`
153
+ // closure-let-var nicht mit der `treeActions(...)` registrar-Methode.
154
+ let treeActions: Readonly<Record<string, TreeActionDef>> | undefined;
155
+ let treeProvider: TreeChildrenSubscribe | undefined;
138
156
 
139
157
  // Map handler name to entity via colon convention.
140
158
  // "task:create" → entity "task". No colon → standalone handler, no mapping.
@@ -153,9 +171,18 @@ export function defineFeature<const TName extends string, TExports = undefined>(
153
171
  isSystemScoped = true;
154
172
  },
155
173
 
156
- requires(...featureNames: string[]): void {
157
- requires.push(...featureNames);
158
- },
174
+ requires: (() => {
175
+ const fn = (...featureNames: string[]) => {
176
+ requires.push(...featureNames);
177
+ };
178
+ fn.projection = (tableName: string) => {
179
+ requiredProjections.add(tableName);
180
+ };
181
+ fn.step = (stepKind: string) => {
182
+ requiredSteps.add(stepKind);
183
+ };
184
+ return fn as RequiresApi;
185
+ })(),
159
186
 
160
187
  optionalRequires(...featureNames: string[]): void {
161
188
  optionalRequires.push(...featureNames);
@@ -186,11 +213,27 @@ export function defineFeature<const TName extends string, TExports = undefined>(
186
213
  writeHandlers[def.name] = {
187
214
  name: def.name,
188
215
  schema: def.schema,
189
- // @cast-boundary engine-bridge — typed Dev-API erased internal storage
216
+ // @cast-boundary engine-bridge — typed Dev-API's handler is
217
+ // generic over the schema's parsed payload (`WriteEvent<output<TSchema>>`),
218
+ // the storage form WriteHandlerFn carries `WriteEvent<unknown>`.
219
+ // Function-arg variance: TS sees the typed handler as stricter
220
+ // than the loose storage shape and rejects direct assignment.
221
+ // The runtime value is identical — the cast crosses that boundary.
222
+ // `satisfies` does not work here (it asserts assignability, which
223
+ // is what fails). Explicit cast is the right tool.
190
224
  handler: def.handler as WriteHandlerFn,
191
225
  ...(def.access && { access: def.access }),
192
- ...(def.skipTransitionGuard && { skipTransitionGuard: true }),
226
+ ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
193
227
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
228
+ // Forward the pipeline-build closure so boot-validators and
229
+ // Designer/AI tooling can inspect the step list. Absent on
230
+ // free-form handlers — defineWriteHandler only sets `perform`
231
+ // when the author used the pipeline form. Variance cast
232
+ // mirrors the handler-cast above: PipelineDef<output<TSchema>>
233
+ // is stricter than PipelineDef<unknown> for the same reason.
234
+ ...(def.perform !== undefined && {
235
+ perform: def.perform as PipelineDef,
236
+ }),
194
237
  };
195
238
  tryMapEntity(def.name);
196
239
  return { name: def.name };
@@ -220,7 +263,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
220
263
  name: def.name,
221
264
  schema: def.schema,
222
265
  // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
223
- handler: def.handler as QueryHandlerFn,
266
+ handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
224
267
  ...(def.access && { access: def.access }),
225
268
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
226
269
  };
@@ -345,7 +388,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
345
388
  ? {
346
389
  on: Array.isArray(options.trigger.on)
347
390
  ? options.trigger.on.map(resolveName)
348
- : resolveName(options.trigger.on as NameOrRef),
391
+ : resolveName(options.trigger.on as NameOrRef), // @cast-boundary engine-bridge
349
392
  }
350
393
  : options.trigger;
351
394
  jobs[jobName] = { ...options, trigger, name: jobName, handler };
@@ -723,9 +766,41 @@ export function defineFeature<const TName extends string, TExports = undefined>(
723
766
  };
724
767
  return { name: qualifiedName, type: options.type };
725
768
  },
769
+
770
+ treeActions<const TActions extends Record<string, TreeActionDef>>(
771
+ actions: TActions,
772
+ ): TreeActionsHandle<TName, TActions> {
773
+ // Only-once-guard: zweiter Aufruf ist Author-Bug, soll am
774
+ // Feature-File aufschlagen (gleicher Stil wie r.toggleable).
775
+ if (treeActions !== undefined) {
776
+ throw new Error(
777
+ `[Feature ${name}] r.treeActions() already called. ` +
778
+ `Each feature may declare a single tree-actions schema.`,
779
+ );
780
+ }
781
+ treeActions = actions;
782
+ // Return typed handle für setup-export. Frozen damit Caller die
783
+ // Map nicht nachträglich mutieren (würde Pattern-AST + Runtime-
784
+ // Lookup divergieren lassen).
785
+ return Object.freeze({
786
+ id: name,
787
+ treeActions: actions,
788
+ });
789
+ },
790
+
791
+ tree(provider: TreeChildrenSubscribe): void {
792
+ // Only-once-guard analog zu r.treeActions.
793
+ if (treeProvider !== undefined) {
794
+ throw new Error(
795
+ `[Feature ${name}] r.tree() already called. ` +
796
+ `Each feature may declare a single tree-provider.`,
797
+ );
798
+ }
799
+ treeProvider = provider;
800
+ },
726
801
  };
727
802
 
728
- const exports = setup(registrar) as TExports;
803
+ const exports = setup(registrar) as TExports; // @cast-boundary engine-bridge
729
804
 
730
805
  return {
731
806
  name,
@@ -733,6 +808,8 @@ export function defineFeature<const TName extends string, TExports = undefined>(
733
808
  exports,
734
809
  requires,
735
810
  optionalRequires,
811
+ requiredProjections,
812
+ requiredSteps,
736
813
  ...(toggleableDefault !== undefined && { toggleableDefault }),
737
814
  entities,
738
815
  relations,
@@ -746,7 +823,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
746
823
  preDelete: phasedLifecycleHooks.preDelete,
747
824
  postDelete: phasedLifecycleHooks.postDelete,
748
825
  preQuery: lifecycleHooks["preQuery"] ?? {},
749
- } as HookMap,
826
+ } as HookMap, // @cast-boundary engine-payload
750
827
  entityHooks: {
751
828
  postSave: entityPostSave,
752
829
  preDelete: entityPreDelete,
@@ -775,5 +852,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
775
852
  workspaces,
776
853
  httpRoutes,
777
854
  rawTables,
855
+ ...(treeActions !== undefined && { treeActions }),
856
+ ...(treeProvider !== undefined && { treeProvider }),
778
857
  };
779
858
  }
@@ -1,4 +1,5 @@
1
1
  import type { ZodType, z } from "zod";
2
+ import { runPipeline } from "./run-pipeline";
2
3
  import type {
3
4
  AccessRule,
4
5
  HandlerContext,
@@ -8,6 +9,7 @@ import type {
8
9
  WriteEvent,
9
10
  WriteResult,
10
11
  } from "./types";
12
+ import type { PipelineDef } from "./types/step";
11
13
 
12
14
  // --- Write Handler Definition ---
13
15
  //
@@ -19,6 +21,10 @@ import type {
19
21
  // definition-site (framework's compile, where the augmentation isn't
20
22
  // visible) and collapse `keyof TMap` to `never`. See the spike-findings
21
23
  // memory for the empirical proof.
24
+ //
25
+ // Two authoring forms — `handler` (free-form) or `perform: pipeline(...)`
26
+ // (step-pipeline). A `perform` is compiled to a handler-function at
27
+ // definition time; the dispatcher only ever sees `handler`.
22
28
 
23
29
  export type WriteHandlerDefinition<
24
30
  TName extends string = string,
@@ -29,23 +35,103 @@ export type WriteHandlerDefinition<
29
35
  readonly name: TName;
30
36
  readonly schema: TSchema;
31
37
  readonly access?: AccessRule;
32
- readonly skipTransitionGuard?: boolean;
38
+ readonly unsafeSkipTransitionGuard?: boolean;
33
39
  readonly rateLimit?: RateLimitOption;
34
40
  readonly handler: (
35
41
  event: WriteEvent<z.infer<TSchema>>,
36
42
  context: HandlerContext<TMap>,
37
43
  ) => Promise<WriteResult<TData>>;
44
+ // Preserved when the author wrote a `perform` block — the original
45
+ // PipelineDef. Designer/AI/AST tools read this when present; the
46
+ // dispatcher ignores it and just calls `handler`. Absent on free-form
47
+ // handlers.
48
+ readonly perform?: PipelineDef<z.infer<TSchema>, TData>;
38
49
  };
39
50
 
51
+ // Author-facing input — accepts either the free-form `handler` or the
52
+ // pipeline-form `perform`. defineWriteHandler narrows them to the
53
+ // canonical WriteHandlerDefinition shape.
54
+ export type WriteHandlerInput<
55
+ TName extends string = string,
56
+ TSchema extends ZodType = ZodType,
57
+ TData = unknown,
58
+ TMap extends object = KumikoEventTypeMap,
59
+ > = {
60
+ readonly name: TName;
61
+ readonly schema: TSchema;
62
+ readonly access?: AccessRule;
63
+ readonly unsafeSkipTransitionGuard?: boolean;
64
+ readonly rateLimit?: RateLimitOption;
65
+ } & (
66
+ | {
67
+ readonly handler: (
68
+ event: WriteEvent<z.infer<TSchema>>,
69
+ context: HandlerContext<TMap>,
70
+ ) => Promise<WriteResult<TData>>;
71
+ readonly perform?: never;
72
+ }
73
+ | {
74
+ readonly perform: PipelineDef<z.infer<TSchema>, TData>;
75
+ readonly handler?: never;
76
+ }
77
+ );
78
+
40
79
  export function defineWriteHandler<
41
80
  const TName extends string,
42
81
  TSchema extends ZodType,
43
82
  TData = unknown,
44
83
  TMap extends object = KumikoEventTypeMap,
45
84
  >(
46
- def: WriteHandlerDefinition<TName, TSchema, TData, TMap>,
85
+ def: WriteHandlerInput<TName, TSchema, TData, TMap>,
47
86
  ): WriteHandlerDefinition<TName, TSchema, TData, TMap> {
48
- return def;
87
+ // Runtime-guard against accidentally setting BOTH handler+perform.
88
+ // The discriminated-union type-error
89
+ // "Type 'PipelineDef<...>' is not assignable to type 'undefined'."
90
+ // is functional but cryptic for less TS-experienced users; this throws
91
+ // a name-and-explanation error message instead. Followup #3.
92
+ // The cast is necessary because the discriminated union narrows
93
+ // `handler` away once `perform` is present (and vice-versa) — at this
94
+ // boundary we want to read both regardless of the narrowing.
95
+ const probe = def as {
96
+ readonly handler?: unknown;
97
+ readonly perform?: unknown;
98
+ readonly name: TName;
99
+ };
100
+ if (probe.handler !== undefined && probe.perform !== undefined) {
101
+ throw new Error(
102
+ `defineWriteHandler("${def.name}"): both \`handler\` and \`perform\` are set. ` +
103
+ `Pick one — \`handler\` for the free-form async function, ` +
104
+ `\`perform: pipeline(...)\` for the step-pipeline form. ` +
105
+ `(See step-vocabulary.md for which form fits.)`,
106
+ );
107
+ }
108
+
109
+ // Conditional spreads (`...(def.access && { access: def.access })`)
110
+ // mirror the existing convention in entity-handlers.ts /
111
+ // define-feature.ts — optional fields stay absent rather than being
112
+ // serialised as `key: undefined`.
113
+ const base = {
114
+ name: def.name,
115
+ schema: def.schema,
116
+ ...(def.access && { access: def.access }),
117
+ ...(def.unsafeSkipTransitionGuard && {
118
+ unsafeSkipTransitionGuard: def.unsafeSkipTransitionGuard,
119
+ }),
120
+ ...(def.rateLimit && { rateLimit: def.rateLimit }),
121
+ };
122
+
123
+ if ("perform" in def && def.perform !== undefined) {
124
+ const performDef = def.perform;
125
+ const compiledHandler = async (
126
+ event: WriteEvent<z.infer<TSchema>>,
127
+ ctx: HandlerContext<TMap>,
128
+ ): Promise<WriteResult<TData>> => {
129
+ return runPipeline<z.infer<TSchema>, TData, TMap>(performDef, event, ctx);
130
+ };
131
+ return { ...base, handler: compiledHandler, perform: performDef };
132
+ }
133
+
134
+ return { ...base, handler: def.handler };
49
135
  }
50
136
 
51
137
  // --- Query Handler Definition ---
@@ -11,9 +11,9 @@ type RoleMap<T extends readonly string[]> = {
11
11
  };
12
12
 
13
13
  export function defineRoles<const T extends readonly string[]>(roles: T): RoleMap<T> {
14
- const map = {} as Record<string, string>;
14
+ const map = {} as Record<string, string>; // @cast-boundary schema-walk
15
15
  for (const role of roles) {
16
16
  map[role] = role;
17
17
  }
18
- return map as RoleMap<T>;
18
+ return map as RoleMap<T>; // @cast-boundary schema-walk
19
19
  }
@@ -0,0 +1,28 @@
1
+ // Step-Registry + defineStep factory.
2
+ //
3
+ // Steps are registered at module-load time via defineStep(). The runtime
4
+ // (run-pipeline.ts) looks them up by `kind` to dispatch run-calls.
5
+ //
6
+ // The registry is process-global; Tier-2 step opt-in via
7
+ // `r.requires.step("…")` (Q9 in step-vocabulary.md) is a future pass.
8
+
9
+ import type { StepDef, StepKind } from "./types/step";
10
+
11
+ const stepRegistry = new Map<StepKind, StepDef>();
12
+
13
+ export function defineStep<TArgs, TResult>(def: StepDef<TArgs, TResult>): StepDef<TArgs, TResult> {
14
+ const existing = stepRegistry.get(def.kind);
15
+ if (existing && existing !== def) {
16
+ throw new Error(`Step kind "${def.kind}" is already registered with a different definition`);
17
+ }
18
+ stepRegistry.set(def.kind, def as StepDef);
19
+ return def;
20
+ }
21
+
22
+ export function getStep(kind: StepKind): StepDef | undefined {
23
+ return stepRegistry.get(kind);
24
+ }
25
+
26
+ export function listStepKinds(): readonly StepKind[] {
27
+ return Array.from(stepRegistry.keys());
28
+ }
@@ -0,0 +1,110 @@
1
+ // defineWorkflow — define a persistent, suspendable workflow.
2
+ // Tier-3 / Workflow-only mount-point. See step-vocabulary.md Sample 2 for
3
+ // the full lifecycle (run.started → wait → resume → run.completed) and
4
+ // Q7 (Snapshot-at-Start) for the in-flight upgrade story.
5
+
6
+ import { createHash } from "node:crypto";
7
+ import type { WriteEvent } from "./types/handlers";
8
+ import type { PipelineDef } from "./types/step";
9
+
10
+ /**
11
+ * Trigger configuration for a workflow. Determines what starts a run.
12
+ */
13
+ export type WorkflowTrigger =
14
+ | {
15
+ readonly kind: "event";
16
+ readonly eventType: string;
17
+ readonly filter?: (event: WriteEvent) => boolean;
18
+ }
19
+ | {
20
+ readonly kind: "cron";
21
+ readonly schedule: string; // cron expression
22
+ }
23
+ | {
24
+ readonly kind: "webhook";
25
+ readonly path: string;
26
+ };
27
+
28
+ /**
29
+ * Workflow definition — the result of defineWorkflow().
30
+ */
31
+ export type WorkflowDefinition<TPayload = unknown, TData = unknown> = {
32
+ readonly __kind: "workflow";
33
+ readonly name: string;
34
+ readonly trigger: WorkflowTrigger;
35
+ /** The pipeline definition containing the step list closure. */
36
+ readonly pipelineDef: PipelineDef<TPayload, TData>;
37
+ /** Idempotency key for deduplication — prevents duplicate runs. */
38
+ readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
39
+ };
40
+
41
+ /**
42
+ * Input shape for defineWorkflow() — the user-facing API.
43
+ */
44
+ export type WorkflowInput<TPayload = unknown, TData = unknown> = {
45
+ readonly name: string;
46
+ readonly trigger: WorkflowTrigger;
47
+ readonly steps: PipelineDef<TPayload, TData>;
48
+ readonly idempotencyKey?: string | ((event: WriteEvent<TPayload>) => string);
49
+ readonly onError?: PipelineDef<unknown>;
50
+ };
51
+
52
+ /**
53
+ * Define a suspendable workflow.
54
+ *
55
+ * Example:
56
+ * ```ts
57
+ * defineWorkflow({
58
+ * name: "user-onboarding",
59
+ * trigger: { kind: "event", eventType: "user.signed-up" },
60
+ * steps: pipeline(({ event, r }) => [
61
+ * r.step.mail.send({ to: () => event.payload.email, subject: "Welcome!", body: "..." }),
62
+ * r.step.wait({ for: "P1D" }),
63
+ * r.step.read.findOne("user", { table: userTable, where: ... }),
64
+ * r.step.branch({ if: ({ steps }) => ..., onTrue: [...], onFalse: [...] }),
65
+ * r.step.retry({ times: 3, backoff: "exponential", do: [
66
+ * r.step.webhook.send({ url: "...", mode: "deferred" }),
67
+ * ]}),
68
+ * ]),
69
+ * });
70
+ * ```
71
+ */
72
+ export function defineWorkflow<TPayload = unknown, TData = unknown>(
73
+ input: WorkflowInput<TPayload, TData>,
74
+ ): WorkflowDefinition<TPayload, TData> {
75
+ return {
76
+ __kind: "workflow",
77
+ name: input.name,
78
+ trigger: input.trigger,
79
+ pipelineDef: input.steps,
80
+ idempotencyKey: input.idempotencyKey,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Q7 Snapshot-at-Start fingerprint. SHA-256 over the workflow's stable
86
+ * identity (name + trigger + serialized pipeline-closure source). Persisted
87
+ * in `workflow.run.started` and re-checked at every resume so a library-
88
+ * upgrade that changes the closure source surfaces as a loud
89
+ * `workflow-definition-changed` failure on in-flight runs instead of a
90
+ * silent semantic drift.
91
+ *
92
+ * Limitations (will be tightened in M.5 with the Designer/AST layer):
93
+ * - `build.toString()` captures the closure source but not bindings —
94
+ * two definitions that import different external helpers with the
95
+ * same source bytes would collide. Acceptable for M.4 because the
96
+ * fingerprint is a *change-detector*, not a deep semantic identity.
97
+ * - Minifiers / source-maps will produce different fingerprints across
98
+ * environments. Run-the-fingerprint-in-the-same-environment is the
99
+ * contract; cross-env replay is out of scope.
100
+ */
101
+ export function computeDefinitionFingerprint(
102
+ workflow: Pick<WorkflowDefinition, "name" | "trigger" | "pipelineDef">,
103
+ ): string {
104
+ const material = JSON.stringify({
105
+ name: workflow.name,
106
+ trigger: workflow.trigger,
107
+ source: workflow.pipelineDef.build.toString(),
108
+ });
109
+ return createHash("sha256").update(material).digest("hex");
110
+ }
@@ -112,8 +112,8 @@ function parseHandlerName<TVerb extends string>(
112
112
  if (!entityName) {
113
113
  throw new Error(`Handler name "${name}" is missing the entity part before the colon.`);
114
114
  }
115
- // @cast-boundary engine-bridge — verbCandidate validated against validVerbs union
116
- if (!(validVerbs as readonly string[]).includes(verbCandidate)) {
115
+ const verbs = validVerbs as readonly string[]; // @cast-boundary engine-bridge
116
+ if (!verbs.includes(verbCandidate)) {
117
117
  throw new Error(
118
118
  `Unknown verb "${verbCandidate}" in handler name "${name}". Standard verbs: ${validVerbs.join("/")}. For custom verbs use the explicit r.writeHandler / r.queryHandler form.`,
119
119
  );
@@ -151,17 +151,17 @@ export function defineEntityWriteHandler(
151
151
  changes: buildUpdateSchema(entity),
152
152
  });
153
153
  handler = async (event, ctx) =>
154
- executor.update(event.payload as UpdatePayload, event.user, ctx.db);
154
+ executor.update(event.payload as UpdatePayload, event.user, ctx.db); // @cast-boundary engine-payload
155
155
  break;
156
156
  case "delete":
157
157
  schema = idSchema;
158
158
  handler = async (event, ctx) =>
159
- executor.delete(event.payload as IdPayload, event.user, ctx.db);
159
+ executor.delete(event.payload as IdPayload, event.user, ctx.db); // @cast-boundary engine-payload
160
160
  break;
161
161
  case "restore":
162
162
  schema = idSchema;
163
163
  handler = async (event, ctx) =>
164
- executor.restore(event.payload as IdPayload, event.user, ctx.db);
164
+ executor.restore(event.payload as IdPayload, event.user, ctx.db); // @cast-boundary engine-payload
165
165
  break;
166
166
  default:
167
167
  assertUnreachable(verb, "write verb");
@@ -205,7 +205,8 @@ export function defineEntityQueryHandler(
205
205
  // läuft (Remote-Combobox-Search). Der executor wird beim
206
206
  // Definition-Time gebaut, kennt den Adapter also nicht —
207
207
  // Runtime-Override holt das.
208
- const result = await executor.list(query.payload as ListPayload, query.user, ctx.db, {
208
+ const listPayload = query.payload as ListPayload; // @cast-boundary engine-payload
209
+ const result = await executor.list(listPayload, query.user, ctx.db, {
209
210
  ...(ctx.searchAdapter !== undefined && { searchAdapter: ctx.searchAdapter }),
210
211
  });
211
212
  if (!hasRefFields) return result;
@@ -221,7 +222,7 @@ export function defineEntityQueryHandler(
221
222
  case "detail":
222
223
  schema = idSchema;
223
224
  handler = async (query, ctx) => {
224
- const row = await executor.detail(query.payload as IdPayload, query.user, ctx.db);
225
+ const row = await executor.detail(query.payload as IdPayload, query.user, ctx.db); // @cast-boundary engine-payload
225
226
  if (row === null || !hasRefFields) return row;
226
227
  return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), ctx.db);
227
228
  };
@@ -345,7 +346,7 @@ export function createEntityExecutor(
345
346
  export function defineProjectionQueryHandler(
346
347
  name: string,
347
348
  projectionQualifiedName: string,
348
- options?: { access?: AccessRule; allTenants?: boolean },
349
+ options?: { access?: AccessRule; unsafeAllTenants?: boolean },
349
350
  ): QueryHandlerDef {
350
351
  return {
351
352
  name,
@@ -357,7 +358,7 @@ export function defineProjectionQueryHandler(
357
358
  handler: async (_query, ctx) =>
358
359
  ctx.queryProjection(
359
360
  projectionQualifiedName,
360
- options?.allTenants ? { allTenants: true } : undefined,
361
+ options?.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
361
362
  ),
362
363
  ...(options?.access && { access: options.access }),
363
364
  };
@@ -4,11 +4,11 @@ import type { AppendEventArgs, EventDef, HandlerContext } from "./types/handlers
4
4
  // MultiStreamApplyContext-style callers pass their own appendEvent without
5
5
  // a full HandlerContext. Real handlers just pass `ctx`.
6
6
  //
7
- // Uses appendEventUnsafe internally because EventDef's TPayload comes from
7
+ // Uses unsafeAppendEvent internally because EventDef's TPayload comes from
8
8
  // a runtime-defined zod-schema — emitEvent does the type-check itself via
9
9
  // the EventDef generic, so it doesn't need the strict KumikoEventTypeMap
10
10
  // path. The strict appendEvent is for direct in-handler callsites.
11
- export type EmitCtx = Pick<HandlerContext, "appendEventUnsafe">;
11
+ export type EmitCtx = Pick<HandlerContext, "unsafeAppendEvent">;
12
12
 
13
13
  // Typed wrapper around ctx.appendEvent. Two wins over the raw call:
14
14
  //
@@ -42,7 +42,7 @@ export async function emitEvent<TPayload>(
42
42
  type: eventDef.name,
43
43
  payload: args.payload,
44
44
  };
45
- await ctx.appendEventUnsafe(appendArgs);
45
+ await ctx.unsafeAppendEvent(appendArgs);
46
46
  }
47
47
 
48
48
  // Read-side counterpart: narrow a StoredEvent's `payload` (declared as
@@ -69,5 +69,5 @@ export function typedPayload<TPayload>(
69
69
  `Check the projection-apply / reducer mapping — the event was routed to the wrong handler.`,
70
70
  );
71
71
  }
72
- return event.payload as TPayload;
72
+ return event.payload as TPayload; // @cast-boundary engine-payload
73
73
  }
@@ -39,7 +39,7 @@ export function createTextField<R extends true | false = false>(
39
39
  searchable: false,
40
40
  sortable: false,
41
41
  ...overrides,
42
- } as TextFieldDef & { required: R };
42
+ } as TextFieldDef & { required: R }; // @cast-boundary engine-payload
43
43
  }
44
44
 
45
45
  /**
@@ -59,7 +59,7 @@ export function createLongTextField<R extends true | false = false>(
59
59
  type: "longText",
60
60
  required: false,
61
61
  ...overrides,
62
- } as LongTextFieldDef & { required: R };
62
+ } as LongTextFieldDef & { required: R }; // @cast-boundary engine-payload
63
63
  }
64
64
 
65
65
  export function createBooleanField<R extends true | false = false>(
@@ -70,7 +70,7 @@ export function createBooleanField<R extends true | false = false>(
70
70
  required: false,
71
71
  default: false,
72
72
  ...overrides,
73
- } as BooleanFieldDef & { required: R };
73
+ } as BooleanFieldDef & { required: R }; // @cast-boundary engine-payload
74
74
  }
75
75
 
76
76
  export function createSelectField<
@@ -85,7 +85,7 @@ export function createSelectField<
85
85
  type: "select",
86
86
  required: false,
87
87
  ...opts,
88
- } as SelectFieldDef<TOptions> & { required: R };
88
+ } as SelectFieldDef<TOptions> & { required: R }; // @cast-boundary engine-payload
89
89
  }
90
90
 
91
91
  /**
@@ -126,7 +126,7 @@ export function createNumberField<R extends true | false = false>(
126
126
  type: "number",
127
127
  required: false,
128
128
  ...overrides,
129
- } as NumberFieldDef & { required: R };
129
+ } as NumberFieldDef & { required: R }; // @cast-boundary engine-payload
130
130
  }
131
131
 
132
132
  export function createBigIntField<R extends true | false = false>(
@@ -136,7 +136,7 @@ export function createBigIntField<R extends true | false = false>(
136
136
  type: "bigInt",
137
137
  required: false,
138
138
  ...overrides,
139
- } as BigIntFieldDef & { required: R };
139
+ } as BigIntFieldDef & { required: R }; // @cast-boundary engine-payload
140
140
  }
141
141
 
142
142
  export function createMoneyField<R extends true | false = false>(
@@ -145,7 +145,7 @@ export function createMoneyField<R extends true | false = false>(
145
145
  return {
146
146
  type: "money",
147
147
  ...overrides,
148
- } as MoneyFieldDef & { required: R };
148
+ } as MoneyFieldDef & { required: R }; // @cast-boundary engine-payload
149
149
  }
150
150
 
151
151
  export function createEmbeddedField(
@@ -166,7 +166,7 @@ export function createDateField<R extends true | false = false>(
166
166
  type: "date",
167
167
  required: false,
168
168
  ...overrides,
169
- } as DateFieldDef & { required: R };
169
+ } as DateFieldDef & { required: R }; // @cast-boundary engine-payload
170
170
  }
171
171
 
172
172
  /**
@@ -186,7 +186,7 @@ export function createTimestampField<R extends true | false = false>(
186
186
  return {
187
187
  ...overrides,
188
188
  type: "timestamp",
189
- required: (overrides?.required ?? false) as R,
189
+ required: (overrides?.required ?? false) as R, // @cast-boundary engine-payload
190
190
  };
191
191
  }
192
192
 
@@ -201,7 +201,7 @@ export function createTzField<R extends true | false = false>(
201
201
  type: "tz",
202
202
  required: false,
203
203
  ...overrides,
204
- } as TzFieldDef & { required: R };
204
+ } as TzFieldDef & { required: R }; // @cast-boundary engine-payload
205
205
  }
206
206
 
207
207
  /**
@@ -241,7 +241,7 @@ export function createLocatedTimestampField<R extends true | false = false>(
241
241
  type: "locatedTimestamp",
242
242
  required: false,
243
243
  ...overrides,
244
- } as LocatedTimestampFieldDef & { required: R };
244
+ } as LocatedTimestampFieldDef & { required: R }; // @cast-boundary engine-payload
245
245
  }
246
246
 
247
247
  /**
@@ -334,5 +334,5 @@ export function createEntity<F>(def: {
334
334
  // aggregate-ids are UUID. Opt-out with `idType: "serial"` for pre-ES
335
335
  // legacy tables (should be rare).
336
336
  ...def,
337
- } as F extends FieldsMap ? EntityDefinition<F> : never;
337
+ } as F extends FieldsMap ? EntityDefinition<F> : never; // @cast-boundary engine-payload
338
338
  }