@cosmicdrift/kumiko-framework 0.2.2 → 0.3.0

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