@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
@@ -0,0 +1,34 @@
1
+ // r.step.return — terminate the pipeline with an explicit WriteResult.
2
+ //
3
+ // Most pipelines end with a return so the handler shape stays explicit
4
+ // (`isSuccess: true, data: {...}`). When a pipeline omits an explicit
5
+ // return, run-pipeline throws — silent fallthrough would mask the most
6
+ // common authoring mistake (forgotten r.step.return at the end).
7
+
8
+ import { defineStep } from "../define-step";
9
+ import type { WriteResult } from "../types/handlers";
10
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
11
+ import { resolveRequired } from "./_resolver-utils";
12
+
13
+ type ReturnStepArgs = {
14
+ readonly resolver: StepResolver<WriteResult<unknown>>;
15
+ };
16
+
17
+ // Sentinel result-key consumed by run-pipeline to detect "this step
18
+ // terminates the pipeline with this WriteResult". Leading double-underscore
19
+ // is reserved for runtime-internal results — user code can't collide.
20
+ export const RETURN_RESULT_KEY = "__return";
21
+
22
+ defineStep<ReturnStepArgs, WriteResult<unknown>>({
23
+ kind: "return",
24
+ defaultFailureStrategy: "throw",
25
+ resultKey: () => RETURN_RESULT_KEY,
26
+ run: (args, ctx: PipelineCtx) => resolveRequired(args.resolver, ctx),
27
+ });
28
+
29
+ export function buildReturnStep<TData>(resolver: StepResolver<WriteResult<TData>>): StepInstance {
30
+ return {
31
+ kind: "return",
32
+ args: { resolver } satisfies ReturnStepArgs,
33
+ };
34
+ }
@@ -0,0 +1,46 @@
1
+ // r.step.unsafeProjectionDelete — delete row(s) from a read-side
2
+ // projection table.
3
+ //
4
+ // Sibling to unsafeProjectionUpsert with the same boot-validation
5
+ // contract: target table must be in the owning feature's
6
+ // r.requires.projection allowlist, must NOT be a registered
7
+ // r.entity-aggregate-table. Skips the same set of framework-protections
8
+ // (lifecycle hooks, field-access, audit-trail, etc.) — see
9
+ // step-vocabulary.md "Was unsafeProjection.* überspringt".
10
+ //
11
+ // Convention (not enforced): most legitimate read-side deletions are
12
+ // downstream of an aggregate event (e.g. delete-user → cascading
13
+ // subscription rows vanish). The right home for those is
14
+ // `r.multiStreamProjection.apply` keyed on the aggregate event, not
15
+ // an inline-step. The inline-step is appropriate when the deletion
16
+ // must commit in the same TX as the aggregate-mutation that triggered
17
+ // it (stronger consistency than an async projection). Reviewer judges.
18
+
19
+ import type { SQL, Table } from "drizzle-orm";
20
+ import { defineStep } from "../define-step";
21
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
22
+ import { asQueryTarget } from "./_drizzle-boundary";
23
+ import { resolveRequired } from "./_resolver-utils";
24
+
25
+ // `where` is REQUIRED — table-wide DELETE without a clause is a TRUNCATE
26
+ // in disguise, exactly the footgun the `unsafe`-prefix is meant to
27
+ // surface. If a real use-case needs full-table purge, add an explicit
28
+ // `r.step.unsafeProjectionTruncate` step rather than loosening this
29
+ // type to `SQL | undefined`.
30
+ type UnsafeProjectionDeleteArgs = {
31
+ readonly table: Table;
32
+ readonly where: StepResolver<SQL>;
33
+ };
34
+
35
+ defineStep<UnsafeProjectionDeleteArgs, void>({
36
+ kind: "unsafeProjectionDelete",
37
+ defaultFailureStrategy: "throw",
38
+ run: async (args, ctx: PipelineCtx) => {
39
+ const where = resolveRequired(args.where, ctx);
40
+ await ctx.db.delete(asQueryTarget(args.table)).where(where);
41
+ },
42
+ });
43
+
44
+ export function buildUnsafeProjectionDeleteStep(args: UnsafeProjectionDeleteArgs): StepInstance {
45
+ return { kind: "unsafeProjectionDelete", args };
46
+ }
@@ -0,0 +1,69 @@
1
+ // r.step.unsafeProjectionUpsert — inline read-side-projection write.
2
+ //
3
+ // Idempotent on the supplied conflict-key columns (typically the
4
+ // natural key + tenantId). Skips lifecycle hooks, field-access,
5
+ // crypto-shredding, schema-versioning, audit-trail, read-access-log.
6
+ // See "Was unsafeProjection.* überspringt" in
7
+ // docs/plans/architecture/intern/step-vocabulary.md.
8
+ //
9
+ // Use only on tables explicitly declared via r.requires.projection in
10
+ // the owning feature. Aggregate-tables (registered via r.entity) are
11
+ // rejected by boot-validation — domain mutation MUST go through
12
+ // r.step.aggregate.*.
13
+
14
+ import { getTableColumns, type Table } from "drizzle-orm";
15
+ import type { PgColumn } from "drizzle-orm/pg-core";
16
+ import { defineStep } from "../define-step";
17
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
18
+ import { asQueryTarget } from "./_drizzle-boundary";
19
+ import { resolveRequired } from "./_resolver-utils";
20
+
21
+ type UnsafeProjectionUpsertArgs = {
22
+ readonly table: Table;
23
+ readonly on: readonly string[];
24
+ readonly row: StepResolver<Record<string, unknown>>;
25
+ };
26
+
27
+ defineStep<UnsafeProjectionUpsertArgs, void>({
28
+ kind: "unsafeProjectionUpsert",
29
+ defaultFailureStrategy: "throw",
30
+ run: async (args, ctx: PipelineCtx) => {
31
+ const resolvedRow = resolveRequired(args.row, ctx);
32
+
33
+ const columns = getTableColumns(args.table) as Record<string, unknown>;
34
+ const conflictTargets = args.on.map((key) => {
35
+ const col = columns[key];
36
+ if (!col) {
37
+ throw new Error(`unsafeProjectionUpsert: column "${key}" not found on target table`);
38
+ }
39
+ return col;
40
+ });
41
+
42
+ // SET clause is the same row minus the conflict-key columns —
43
+ // updating a key to itself is harmless but verbose.
44
+ const updateSet: Record<string, unknown> = {};
45
+ for (const [k, v] of Object.entries(resolvedRow)) {
46
+ if (!args.on.includes(k)) updateSet[k] = v;
47
+ }
48
+
49
+ // @cast-boundary drizzle-bridge — The values + set + target casts
50
+ // cross the drizzle type-boundary for the same reason as
51
+ // asQueryTarget: resolvedRow is Record<string, unknown> by design
52
+ // (M.1 phantom-typing limit), drizzle's typed-builder expects
53
+ // table-specific shapes. Step-author owns shape correctness.
54
+ // `as never` (not `as any`) — never is contravariantly assignable to
55
+ // every drizzle Insert-shape; explicit "this bypass cannot be made
56
+ // type-safe without lifting <TTable extends Table>" marker.
57
+ await ctx.db
58
+ .insert(asQueryTarget(args.table))
59
+ .values(resolvedRow as never)
60
+ .onConflictDoUpdate({
61
+ target: conflictTargets as unknown as PgColumn[],
62
+ set: updateSet as never,
63
+ });
64
+ },
65
+ });
66
+
67
+ export function buildUnsafeProjectionUpsertStep(args: UnsafeProjectionUpsertArgs): StepInstance {
68
+ return { kind: "unsafeProjectionUpsert", args };
69
+ }
@@ -0,0 +1,71 @@
1
+ // r.step.waitForEvent — suspend the workflow until a matching domain event
2
+ // is observed, or a timeout expires.
3
+ // Tier-3 / Workflow-only: only available inside defineWorkflow.
4
+ //
5
+ // Writes a kumiko:system:workflow.step.waiting-for-event event onto the
6
+ // workflow-run stream and returns the SUSPEND_SENTINEL. The Resume-Loop
7
+ // monitors for matching events (via subscription or poll); when matched,
8
+ // it writes workflow.step.resumed with the matched event's data.
9
+ //
10
+ // The `match` resolver is optional — when omitted, any event of the given
11
+ // type resumes the workflow. When provided, it receives the event payload
12
+ // and must return true for the event to trigger resume.
13
+
14
+ import { defineStep } from "../define-step";
15
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
16
+ import { addDuration } from "./_duration-utils";
17
+ import { resolveRequired } from "./_resolver-utils";
18
+ import {
19
+ SUSPEND_SENTINEL,
20
+ WORKFLOW_AGGREGATE_TYPE,
21
+ WORKFLOW_WAITING_FOR_EVENT_TYPE,
22
+ } from "./_step-dispatch-constants";
23
+
24
+ type WaitForEventArgs = {
25
+ readonly event: string;
26
+ readonly match?: StepResolver<(payload: unknown) => boolean>;
27
+ readonly timeout: StepResolver<string>;
28
+ };
29
+
30
+ defineStep<WaitForEventArgs, undefined | typeof SUSPEND_SENTINEL>({
31
+ kind: "workflow.waitForEvent",
32
+ tier: 3,
33
+ defaultFailureStrategy: "throw",
34
+ run: async (args, ctx: PipelineCtx): Promise<undefined | typeof SUSPEND_SENTINEL> => {
35
+ if (!ctx.workflow) {
36
+ throw new Error(
37
+ "r.step.waitForEvent is only allowed inside defineWorkflow — " +
38
+ "sync handlers cannot suspend.",
39
+ );
40
+ }
41
+
42
+ const timeout = resolveRequired(args.timeout, ctx);
43
+
44
+ const now = Temporal.Now.instant().toString();
45
+ const timeoutAt =
46
+ timeout.startsWith("P") || timeout.startsWith("PT") ? addDuration(now, timeout) : timeout;
47
+
48
+ await ctx.unsafeAppendEvent({
49
+ aggregateId: ctx.workflow.runId,
50
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
51
+ type: WORKFLOW_WAITING_FOR_EVENT_TYPE,
52
+ payload: {
53
+ eventType: args.event,
54
+ timeoutAt,
55
+ stepIndex: ctx.workflow.stepIndex,
56
+ workflowName: ctx.workflow.workflowName,
57
+ triggerEventType: ctx.event.type,
58
+ triggerPayload: ctx.event.payload,
59
+ ...(ctx.workflow.definitionFingerprint && {
60
+ definitionFingerprint: ctx.workflow.definitionFingerprint,
61
+ }),
62
+ },
63
+ });
64
+
65
+ return SUSPEND_SENTINEL;
66
+ },
67
+ });
68
+
69
+ export function buildWaitForEventStep(args: WaitForEventArgs): StepInstance {
70
+ return { kind: "workflow.waitForEvent", args };
71
+ }
@@ -0,0 +1,69 @@
1
+ // r.step.wait — suspend the workflow run for a given duration.
2
+ // Tier-3 / Workflow-only: only available inside defineWorkflow.
3
+ //
4
+ // Writes a kumiko:system:workflow.step.waiting event onto the workflow-run
5
+ // stream and returns the SUSPEND_SENTINEL to halt the pipeline. The
6
+ // Resume-Loop picks up waiting runs when the duration expires, writes
7
+ // workflow.step.resumed, and re-executes from the next step.
8
+ //
9
+ // The `for` resolver accepts ISO-8601 duration strings ("PT1H", "P1D")
10
+ // or absolute ISO timestamps ("2026-05-16T12:00:00Z").
11
+
12
+ import { defineStep } from "../define-step";
13
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
14
+ import { addDuration } from "./_duration-utils";
15
+ import { resolveRequired } from "./_resolver-utils";
16
+ import {
17
+ SUSPEND_SENTINEL,
18
+ WORKFLOW_AGGREGATE_TYPE,
19
+ WORKFLOW_WAITING_TYPE,
20
+ } from "./_step-dispatch-constants";
21
+
22
+ type WaitStepArgs = {
23
+ readonly for: StepResolver<string>;
24
+ };
25
+
26
+ defineStep<WaitStepArgs, undefined | typeof SUSPEND_SENTINEL>({
27
+ kind: "workflow.wait",
28
+ tier: 3,
29
+ defaultFailureStrategy: "throw",
30
+ run: async (args, ctx: PipelineCtx): Promise<undefined | typeof SUSPEND_SENTINEL> => {
31
+ if (!ctx.workflow) {
32
+ throw new Error(
33
+ "r.step.wait is only allowed inside defineWorkflow — " +
34
+ "sync handlers cannot suspend (use r.step.webhook.send with mode: 'deferred' instead).",
35
+ );
36
+ }
37
+
38
+ const duration = resolveRequired(args.for, ctx);
39
+
40
+ const now = Temporal.Now.instant().toString();
41
+ const wakeAt =
42
+ duration.startsWith("P") || duration.startsWith("PT") ? addDuration(now, duration) : duration;
43
+
44
+ await ctx.unsafeAppendEvent({
45
+ aggregateId: ctx.workflow.runId,
46
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
47
+ type: WORKFLOW_WAITING_TYPE,
48
+ payload: {
49
+ wakeAt,
50
+ stepIndex: ctx.workflow.stepIndex,
51
+ workflowName: ctx.workflow.workflowName,
52
+ // Trigger snapshot: pinned so the resume-loop re-feeds the
53
+ // pipeline with what the original run saw. event-sourcing across
54
+ // suspensions hinges on this being stable.
55
+ triggerEventType: ctx.event.type,
56
+ triggerPayload: ctx.event.payload,
57
+ ...(ctx.workflow.definitionFingerprint && {
58
+ definitionFingerprint: ctx.workflow.definitionFingerprint,
59
+ }),
60
+ },
61
+ });
62
+
63
+ return SUSPEND_SENTINEL;
64
+ },
65
+ });
66
+
67
+ export function buildWaitStep(args: WaitStepArgs): StepInstance {
68
+ return { kind: "workflow.wait", args };
69
+ }
@@ -0,0 +1,71 @@
1
+ // r.step.webhook.send — deferred HTTP-POST via the step-dispatcher.
2
+ // Tier-2: requires `r.requires.step("webhook.send")` in the owning feature.
3
+ //
4
+ // Writes a `kumiko:step:dispatch-requested` event onto a fresh step-dispatch
5
+ // stream in the current TX. The step-dispatcher subscription (bundled-feature
6
+ // `step-dispatcher`) reads after COMMIT and performs the fetch with retry.
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { defineStep } from "../define-step";
10
+ import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
11
+ import { resolveOptional, resolveRequired } from "./_resolver-utils";
12
+ import {
13
+ STEP_DISPATCH_AGGREGATE_TYPE,
14
+ STEP_DISPATCH_REQUESTED_TYPE,
15
+ } from "./_step-dispatch-constants";
16
+
17
+ // Re-export for back-compat callers (bundled step-dispatcher imports
18
+ // these). The canonical home is _step-dispatch-constants.ts.
19
+ export {
20
+ STEP_DISPATCH_AGGREGATE_TYPE,
21
+ STEP_DISPATCH_FAILED_TYPE,
22
+ STEP_DISPATCH_REQUESTED_TYPE,
23
+ STEP_DISPATCHED_TYPE,
24
+ } from "./_step-dispatch-constants";
25
+
26
+ type WebhookHttpMethod = "POST" | "PUT" | "PATCH";
27
+
28
+ type WebhookAuth =
29
+ | { readonly kind: "bearer"; readonly secretRef: string }
30
+ | { readonly kind: "header"; readonly name: string; readonly secretRef: string };
31
+
32
+ type WebhookSendArgs = {
33
+ readonly url: StepResolver<string>;
34
+ readonly method?: WebhookHttpMethod;
35
+ readonly headers?: StepResolver<Readonly<Record<string, string>>>;
36
+ readonly body?: StepResolver<unknown>;
37
+ readonly auth?: WebhookAuth;
38
+ readonly mode: "deferred";
39
+ readonly retry?: { readonly times: number; readonly backoff: "exponential" | "linear" };
40
+ };
41
+
42
+ defineStep<WebhookSendArgs, void>({
43
+ kind: "webhook.send",
44
+ tier: 2,
45
+ defaultFailureStrategy: "throw",
46
+ run: async (args, ctx: PipelineCtx) => {
47
+ const url = resolveRequired(args.url, ctx);
48
+ const headers = resolveOptional(args.headers, ctx) ?? {};
49
+ const body = resolveOptional(args.body, ctx);
50
+ await ctx.unsafeAppendEvent({
51
+ aggregateId: randomUUID(),
52
+ aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
53
+ type: STEP_DISPATCH_REQUESTED_TYPE,
54
+ payload: {
55
+ stepKind: "webhook.send",
56
+ spec: {
57
+ url,
58
+ method: args.method ?? "POST",
59
+ headers,
60
+ body,
61
+ ...(args.auth && { auth: args.auth }),
62
+ },
63
+ retry: args.retry ?? { times: 3, backoff: "exponential" },
64
+ },
65
+ });
66
+ },
67
+ });
68
+
69
+ export function buildWebhookSendStep(args: WebhookSendArgs): StepInstance {
70
+ return { kind: "webhook.send", args };
71
+ }
@@ -1,5 +1,5 @@
1
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import type { SessionUser } from "./types";
2
+ import type { TenantId } from "./types/identifiers";
3
3
 
4
4
  // Stringified so it round-trips through SessionUser.id (string UUID-shape).
5
5
  // Not a real UUID — SYSTEM acts as an alias for "no human caller" and event-
@@ -54,6 +54,7 @@ import type { NavDefinition } from "./nav";
54
54
  import type { MultiStreamProjectionDefinition, ProjectionDefinition } from "./projection";
55
55
  import type { EntityRelations, RelationDefinition } from "./relations";
56
56
  import type { ScreenDefinition } from "./screen";
57
+ import type { TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe } from "./tree-node";
57
58
  import type { WorkspaceDefinition } from "./workspace";
58
59
 
59
60
  // --- Metrics (declared by features via r.metric()) ---
@@ -148,6 +149,14 @@ export type FeatureDefinition = {
148
149
  readonly exports?: unknown;
149
150
  readonly requires: readonly string[];
150
151
  readonly optionalRequires: readonly string[];
152
+ // Read-side projection-tables this feature is allowed to write via
153
+ // r.step.unsafeProjectionUpsert / unsafeProjectionDelete. Declared via
154
+ // r.requires.projection("table_name"). Hard requirement — boot-error
155
+ // if a step targets a non-listed table or one that's already an
156
+ // r.entity-registered aggregate-table. See step-vocabulary.md Q10.
157
+ readonly requiredProjections: ReadonlySet<string>;
158
+ // Tier-2 step kinds opted-in via r.requires.step("webhook.send"). Q9.
159
+ readonly requiredSteps: ReadonlySet<string>;
151
160
  // Declared via r.toggleable({ default }). Presence makes the feature
152
161
  // operator-switchable via the feature-toggles bundled feature; absence
153
162
  // means the feature is always-on (e.g. auth, tenant, user — core infra
@@ -224,6 +233,21 @@ export type FeatureDefinition = {
224
233
  // shellWorkspaces consumes the resolved per-workspace nav list at mount
225
234
  // time; engine validates roles + nav refs at boot.
226
235
  readonly workspaces: Readonly<Record<string, WorkspaceDefinition>>;
236
+ // Tree-Actions-Map declared via r.treeActions(). At-most-one per feature
237
+ // (only-once-guard at registration). Erased to `Record<string,
238
+ // TreeActionDef>` for runtime registry-lookup (Visual-Tree-Component
239
+ // dispatching, Pattern-AST consumers). The compile-time-typed surface
240
+ // is the registrar's return value (TreeActionsHandle) which the
241
+ // feature exports via setup-return — buildTarget consumes the handle,
242
+ // not this slot. See visual-tree.md A5 + A7.
243
+ readonly treeActions?: Readonly<Record<string, TreeActionDef>>;
244
+ // Tree-Provider declared via r.tree(). At-most-one per feature.
245
+ // Provider liefert die Top-Level-Knoten dieses Features im Visual-
246
+ // Workspace (navigation: "tree"). Subscribe-Form mit lazy-Eval: erst
247
+ // beim Mount des Workspaces aufgerufen, kann Updates emittieren.
248
+ // Feature ohne treeProvider ist im Visual-Workspace unsichtbar
249
+ // (Zero-Whitelist-Filter aus visual-tree.md A2).
250
+ readonly treeProvider?: TreeChildrenSubscribe;
227
251
  // HTTP-Routes declared via r.httpRoute(). Index is "METHOD path"
228
252
  // (z.B. "GET /feed.xml") — eindeutig pro Feature. Die App-Server-
229
253
  // Boot-Stage iteriert getAllHttpRoutes() und mountet jede Route auf
@@ -250,9 +274,22 @@ type RefOrRefs = NameOrRef | readonly NameOrRef[];
250
274
  * keeping strict-mode alive even when handlers route via `eventDef.name`
251
275
  * instead of hand-typed string literals.
252
276
  */
277
+ /**
278
+ * `r.requires` is a callable+namespace: existing call form takes feature
279
+ * names (`r.requires("auth", "tenant")`), the `.projection` extension
280
+ * declares read-side projection tables that this feature's pipeline
281
+ * steps are allowed to write via `r.step.unsafeProjectionUpsert`.
282
+ * Hard-required for any unsafeProjection-* step usage (see Q10).
283
+ */
284
+ export type RequiresApi = ((...featureNames: string[]) => void) & {
285
+ readonly projection: (tableName: string) => void;
286
+ // Tier-2 step opt-in (Q9). Tier-1 implicit, Tier-2 must be declared.
287
+ readonly step: (stepKind: string) => void;
288
+ };
289
+
253
290
  export type FeatureRegistrar<TFeature extends string = string> = {
254
291
  systemScope(): void;
255
- requires(...featureNames: string[]): void;
292
+ requires: RequiresApi;
256
293
  optionalRequires(...featureNames: string[]): void;
257
294
  // Declare the feature as operator-togglable. `default` is the effective
258
295
  // state when no global-toggle row exists. Must be called at most once per
@@ -506,6 +543,45 @@ export type FeatureRegistrar<TFeature extends string = string> = {
506
543
  // a non-empty string is the contract. If you can't write a reason,
507
544
  // declare data via `r.entity()` instead.
508
545
  rawTable(name: string, table: PgTable, options: RawTableOptions): void;
546
+
547
+ // Register the tree-actions schema for this feature — a map of
548
+ // action-name → action-definition (with optional typed args). At-most-
549
+ // one call per feature.
550
+ //
551
+ // Returns a TreeActionsHandle that the feature exports via setup-return
552
+ // (Memory `[EventDef-Exports-Pattern]`). The handle carries the
553
+ // literal-typed action-map that `buildTarget` consumes for compile-
554
+ // time validation:
555
+ //
556
+ // const handle = r.treeActions({
557
+ // edit: { args: { slug: "" as string } },
558
+ // list: {},
559
+ // });
560
+ // return { handle };
561
+ //
562
+ // Without this typed return, the action-map collapses to
563
+ // `Record<string, TreeActionDef>` at the buildTarget call-site and
564
+ // every action becomes accept-anything-string. See visual-tree.md A5.
565
+ //
566
+ // The runtime FeatureDefinition.treeActions slot stores the same map
567
+ // as erased Record (registry lookup, Pattern-AST consumers).
568
+ treeActions<const TActions extends Record<string, TreeActionDef>>(
569
+ actions: TActions,
570
+ ): TreeActionsHandle<TFeature, TActions>;
571
+
572
+ // Register the tree-provider for this feature — the Subscribe-Function
573
+ // that emits the top-level Tree-Knoten when the Visual-Workspace
574
+ // (navigation: "tree") mounts. At-most-one call per feature.
575
+ //
576
+ // Provider returns a Subscribe-Function (emit-fn → unsubscribe-fn).
577
+ // Initial-emit synchron oder async, weitere Emits beliebig oft (e.g.
578
+ // on entity-update SSE). Provider sind session-bound; tenantId fließt
579
+ // über die Backend-Session bei fetch/dispatch, nicht über ein ctx-Arg.
580
+ //
581
+ // A feature without r.tree() is invisible in `navigation: "tree"`-
582
+ // workspaces — that's the Zero-Whitelist-Filter from visual-tree.md A2:
583
+ // provider-Vorhandensein ist der Filter, kein Workspace-Mapping.
584
+ tree(provider: TreeChildrenSubscribe): void;
509
585
  };
510
586
 
511
587
  // --- Registry (created from features) ---
@@ -677,4 +753,20 @@ export type Registry = {
677
753
  // validator rejects more than one. Apps without a default fall back to
678
754
  // the first workspace the user has access to.
679
755
  getDefaultWorkspace(): WorkspaceDefinition | undefined;
756
+
757
+ // Tree-Providers declared via r.tree() across all features. Keyed by
758
+ // declaring feature name (NOT qualified — Provider sind feature-bound,
759
+ // ein Feature liefert genau eine Provider-Function). The Visual-Tree
760
+ // component (renderer-web) iteriert getTreeProviders() beim Mount des
761
+ // navigation: "tree"-Workspaces, ruft jeden Provider mit ctx auf,
762
+ // sammelt die emitted TreeNode[] und merged sie zur Top-Level-Liste.
763
+ // See visual-tree.md A2 (Zero-Whitelist) + A4 (Subscribe-Form).
764
+ getTreeProviders(): ReadonlyMap<string, TreeChildrenSubscribe>;
765
+
766
+ // Tree-Actions-Map des Features. Returns the erased Record (compile-
767
+ // time-typed handle wandert über setup-export, nicht hier). Visual-
768
+ // Tree-Component nutzt das für Runtime-Action-Lookup beim Klick auf
769
+ // einen TreeNode.target — der Resolver findet das Feature via
770
+ // TargetRef.featureId und holt sich die zugehörige Action-Definition.
771
+ getTreeActions(featureName: string): Readonly<Record<string, TreeActionDef>> | undefined;
680
772
  };
@@ -175,9 +175,9 @@ export function withResponseData<T>(result: WriteResult<unknown>, data: T): Writ
175
175
 
176
176
  // --- Context Types ---
177
177
 
178
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
179
178
  // Forward import: Registry is in feature.ts (circular type import — fine in TS)
180
179
  import type { Registry } from "./feature";
180
+ import type { TenantId } from "./identifiers";
181
181
 
182
182
  // Minimal interface for job event triggers (framework-owned, concrete type in jobs/)
183
183
  export type JobRunnerRef = {
@@ -312,7 +312,7 @@ export type AppContext = SharedContextFields & {
312
312
  // TMap propagates the strict event-type-map through `appendEvent`. Defaults
313
313
  // to the global KumikoEventTypeMap (augmented per app via
314
314
  // `declare module "@cosmicdrift/kumiko-framework/engine"`). Code that bypasses the
315
- // type-map (runtime-pluggable events) uses `appendEventUnsafe`.
315
+ // type-map (runtime-pluggable events) uses `unsafeAppendEvent`.
316
316
  export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedContextFields & {
317
317
  readonly db: TenantDb;
318
318
  readonly registry: Registry;
@@ -353,11 +353,11 @@ export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedCon
353
353
  readonly appendEvent: AppendEventFn<TMap>;
354
354
 
355
355
  // Escape-hatch for runtime-pluggable features without a compile-time
356
- // augmentation. See AppendEventUnsafeFn — same runtime as appendEvent,
356
+ // augmentation. See UnsafeAppendEventFn — same runtime as appendEvent,
357
357
  // but the type-surface is `payload: unknown`. Use only when the event-
358
358
  // type is not knowable at compile-time; otherwise the strict path
359
359
  // (appendEvent) is the contract Designer/AI rely on.
360
- readonly appendEventUnsafe: AppendEventUnsafeFn;
360
+ readonly unsafeAppendEvent: UnsafeAppendEventFn;
361
361
 
362
362
  // Marten FetchForWriting equivalent: load the current stream, optionally
363
363
  // enforce expectedVersion, and get a handle that appends further events
@@ -428,11 +428,11 @@ export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedCon
428
428
  // name without the feature having to import the drizzle-table directly.
429
429
  //
430
430
  // Auto-applies tenant_id filter when the projection table has a tenant_id
431
- // column (or opt out with { allTenants: true } for system-scoped reads
431
+ // column (or opt out with { unsafeAllTenants: true } for system-scoped reads
432
432
  // like cross-tenant analytics). Unknown projection name throws.
433
433
  readonly queryProjection: <T = Record<string, unknown>>(
434
434
  qualifiedName: string,
435
- options?: { readonly allTenants?: boolean },
435
+ options?: { readonly unsafeAllTenants?: boolean },
436
436
  ) => Promise<readonly T[]>;
437
437
 
438
438
  // Always populated — Noop when no observability provider is configured.
@@ -601,7 +601,7 @@ export type TypedAppendEventArgs<TMap extends object, K extends keyof TMap> = {
601
601
  // Strict-only form. Single overload — `<K extends keyof TMap>` against the
602
602
  // app's pre-bound TMap. No fallback overload: apps that need runtime-pluggable
603
603
  // events (where the type-string isn't known at compile-time) reach for
604
- // `appendEventUnsafe`.
604
+ // `unsafeAppendEvent`.
605
605
  //
606
606
  // Why no fallback overload:
607
607
  // A two-overload form (`(args: AppendEventArgs)` as the second sig)
@@ -619,13 +619,13 @@ export type TypedAppendEventArgs<TMap extends object, K extends keyof TMap> = {
619
619
  // KumikoEventTypeMap>(...)` wrappers. Handlers inside those wrappers
620
620
  // get a strict ctx.appendEvent.
621
621
  // - Cross-package callers (e.g. bundled-features's set.write.ts) that
622
- // can't afford a local wrapper reach for `ctx.appendEventUnsafe`
622
+ // can't afford a local wrapper reach for `ctx.unsafeAppendEvent`
623
623
  // instead — same runtime, looser type-surface.
624
624
  export type AppendEventFn<TMap extends object = KumikoEventTypeMap> = <K extends keyof TMap>(
625
625
  args: TypedAppendEventArgs<TMap, K>,
626
626
  ) => Promise<void>;
627
627
 
628
- export type AppendEventUnsafeFn = (args: AppendEventArgs) => Promise<void>;
628
+ export type UnsafeAppendEventFn = (args: AppendEventArgs) => Promise<void>;
629
629
 
630
630
  // Args for ctx.fetchForWriting — Marten FetchForWriting equivalent. Returns
631
631
  // the current stream state + a handle that appends without re-specifying
@@ -737,8 +737,16 @@ export type WriteHandlerDef = {
737
737
  readonly schema: ZodType;
738
738
  readonly handler: WriteHandlerFn;
739
739
  readonly access?: AccessRule;
740
- readonly skipTransitionGuard?: boolean;
740
+ readonly unsafeSkipTransitionGuard?: boolean;
741
741
  readonly rateLimit?: RateLimitOption;
742
+ // Set when the author wrote a `perform: pipeline(...)` block. Boot-
743
+ // validators (projection-allowlist) and Designer/AI tooling read this
744
+ // to inspect the step list. Absent on free-form handlers.
745
+ // Inline-import is intentional: step.ts imports HandlerContext from
746
+ // this file, a top-level `import type { PipelineDef } from "./step"`
747
+ // would form a type-only circular import that TS resolves but tooling
748
+ // (incremental compile, IDEs) sometimes mis-handles.
749
+ readonly perform?: import("./step").PipelineDef;
742
750
  };
743
751
 
744
752
  export type QueryHandlerDef = {
@@ -102,7 +102,6 @@ export type {
102
102
  AppContext,
103
103
  AppendEventArgs,
104
104
  AppendEventFn,
105
- AppendEventUnsafeFn,
106
105
  AuthClaimsContext,
107
106
  AuthClaimsFn,
108
107
  AuthClaimsHookDef,
@@ -133,6 +132,7 @@ export type {
133
132
  RateLimitOption,
134
133
  RateLimitPer,
135
134
  SessionUser,
135
+ UnsafeAppendEventFn,
136
136
  WriteEvent,
137
137
  WriteHandlerDef,
138
138
  WriteHandlerFn,
@@ -211,4 +211,14 @@ export type {
211
211
  ToolbarAction,
212
212
  } from "./screen";
213
213
  export { normalizeEditField, normalizeListColumn } from "./screen";
214
+ export type { TargetRef } from "./target-ref";
215
+ export type {
216
+ Subscribe,
217
+ TreeAction,
218
+ TreeActionDef,
219
+ TreeActionsHandle,
220
+ TreeChildrenSubscribe,
221
+ TreeNode,
222
+ TreeNodeState,
223
+ } from "./tree-node";
214
224
  export type { WorkspaceDefinition } from "./workspace";