@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,198 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getStep } from "../define-step";
3
+ import {
4
+ SUSPEND_SENTINEL,
5
+ WORKFLOW_AGGREGATE_TYPE,
6
+ WORKFLOW_WAITING_FOR_EVENT_TYPE,
7
+ WORKFLOW_WAITING_TYPE,
8
+ } from "../steps/_step-dispatch-constants";
9
+ import { buildRetryStep, calculateBackoff } from "../steps/retry";
10
+ import { buildWaitStep } from "../steps/wait";
11
+ import { buildWaitForEventStep } from "../steps/wait-for-event";
12
+ import type { PipelineCtx } from "../types/step";
13
+
14
+ const mockUnsafeAppendEvent = vi.fn();
15
+
16
+ const workflowCtx = {
17
+ unsafeAppendEvent: mockUnsafeAppendEvent,
18
+ event: { type: "user.signed-up", payload: { email: "test@example.com" } },
19
+ steps: {},
20
+ scope: {},
21
+ workflow: {
22
+ runId: "wr_abc123",
23
+ workflowName: "test-workflow",
24
+ stepIndex: 0,
25
+ },
26
+ } as unknown as PipelineCtx;
27
+
28
+ const nonWorkflowCtx = {
29
+ unsafeAppendEvent: mockUnsafeAppendEvent,
30
+ event: { type: "test", payload: { url: "https://hooks.example/test" } },
31
+ steps: {},
32
+ scope: {},
33
+ } as unknown as PipelineCtx;
34
+
35
+ describe("buildWaitStep", () => {
36
+ it("returns a StepInstance with kind workflow.wait", () => {
37
+ const step = buildWaitStep({ for: "PT1H" });
38
+ expect(step.kind).toBe("workflow.wait");
39
+ });
40
+
41
+ it("accepts an ISO-8601 duration string", () => {
42
+ const step = buildWaitStep({ for: "P1D" });
43
+ expect((step.args as { for: string }).for).toBe("P1D");
44
+ });
45
+ });
46
+
47
+ describe("workflow.wait run", () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ it("throws when used outside defineWorkflow (no ctx.workflow)", async () => {
53
+ const stepDef = getStep("workflow.wait");
54
+ expect(stepDef).toBeDefined();
55
+ await expect(stepDef!.run({ for: "PT1H" }, nonWorkflowCtx)).rejects.toThrow(
56
+ /only allowed inside defineWorkflow/,
57
+ );
58
+ });
59
+
60
+ it("writes a workflow.step.waiting event and returns SUSPEND_SENTINEL", async () => {
61
+ const stepDef = getStep("workflow.wait");
62
+ expect(stepDef).toBeDefined();
63
+
64
+ const result = await stepDef!.run({ for: "PT1H" }, workflowCtx);
65
+
66
+ expect(result).toBe(SUSPEND_SENTINEL);
67
+ expect(mockUnsafeAppendEvent).toHaveBeenCalledOnce();
68
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
69
+ expect(eventArg.aggregateType).toBe(WORKFLOW_AGGREGATE_TYPE);
70
+ expect(eventArg.type).toBe(WORKFLOW_WAITING_TYPE);
71
+ expect(eventArg.aggregateId).toBe("wr_abc123");
72
+ expect(eventArg.payload.stepIndex).toBe(0);
73
+ expect(typeof eventArg.payload.wakeAt).toBe("string");
74
+ expect(eventArg.payload.workflowName).toBe("test-workflow");
75
+ });
76
+
77
+ it("accepts an absolute ISO timestamp as the `for` value", async () => {
78
+ const stepDef = getStep("workflow.wait");
79
+ expect(stepDef).toBeDefined();
80
+
81
+ const future = new Date(Date.now() + 86400000).toISOString();
82
+ const result = await stepDef!.run({ for: future }, workflowCtx);
83
+
84
+ expect(result).toBe(SUSPEND_SENTINEL);
85
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
86
+ expect(eventArg.payload.wakeAt).toBe(future);
87
+ });
88
+ });
89
+
90
+ describe("buildWaitForEventStep", () => {
91
+ it("returns a StepInstance with kind workflow.waitForEvent", () => {
92
+ const step = buildWaitForEventStep({
93
+ event: "user.confirmed-email",
94
+ timeout: "P7D",
95
+ });
96
+ expect(step.kind).toBe("workflow.waitForEvent");
97
+ });
98
+
99
+ it("accepts an optional match resolver", () => {
100
+ const step = buildWaitForEventStep({
101
+ event: "user.confirmed-email",
102
+ match: (payload: unknown) => (payload as { email: string }).email === "test@test.com",
103
+ timeout: "P7D",
104
+ });
105
+ expect(step.kind).toBe("workflow.waitForEvent");
106
+ });
107
+ });
108
+
109
+ describe("workflow.waitForEvent run", () => {
110
+ beforeEach(() => {
111
+ vi.clearAllMocks();
112
+ });
113
+
114
+ it("throws when used outside defineWorkflow", async () => {
115
+ const stepDef = getStep("workflow.waitForEvent");
116
+ expect(stepDef).toBeDefined();
117
+ await expect(
118
+ stepDef!.run({ event: "user.confirmed-email", timeout: "P7D" }, nonWorkflowCtx),
119
+ ).rejects.toThrow(/only allowed inside defineWorkflow/);
120
+ });
121
+
122
+ it("writes a workflow.step.waiting-for-event event and returns SUSPEND_SENTINEL", async () => {
123
+ const stepDef = getStep("workflow.waitForEvent");
124
+ expect(stepDef).toBeDefined();
125
+
126
+ const result = await stepDef!.run(
127
+ { event: "user.confirmed-email", timeout: "P7D" },
128
+ workflowCtx,
129
+ );
130
+
131
+ expect(result).toBe(SUSPEND_SENTINEL);
132
+ expect(mockUnsafeAppendEvent).toHaveBeenCalledOnce();
133
+ const eventArg = mockUnsafeAppendEvent.mock.calls[0]![0];
134
+ expect(eventArg.aggregateType).toBe(WORKFLOW_AGGREGATE_TYPE);
135
+ expect(eventArg.type).toBe(WORKFLOW_WAITING_FOR_EVENT_TYPE);
136
+ expect(eventArg.aggregateId).toBe("wr_abc123");
137
+ expect(eventArg.payload.eventType).toBe("user.confirmed-email");
138
+ expect(typeof eventArg.payload.timeoutAt).toBe("string");
139
+ expect(eventArg.payload.workflowName).toBe("test-workflow");
140
+ });
141
+ });
142
+
143
+ describe("buildRetryStep", () => {
144
+ it("returns a StepInstance with kind workflow.retry", () => {
145
+ const step = buildRetryStep({
146
+ times: 3,
147
+ backoff: "exponential",
148
+ do: [],
149
+ });
150
+ expect(step.kind).toBe("workflow.retry");
151
+ });
152
+
153
+ it("requires times and backoff", () => {
154
+ const step = buildRetryStep({
155
+ times: 5,
156
+ backoff: "linear",
157
+ do: [],
158
+ });
159
+ expect((step.args as { times: number }).times).toBe(5);
160
+ });
161
+ });
162
+
163
+ describe("workflow.retry run", () => {
164
+ beforeEach(() => {
165
+ vi.clearAllMocks();
166
+ });
167
+
168
+ it("throws when used outside defineWorkflow", async () => {
169
+ const stepDef = getStep("workflow.retry");
170
+ expect(stepDef).toBeDefined();
171
+ await expect(
172
+ stepDef!.run({ times: 3, backoff: "exponential", do: [] }, nonWorkflowCtx),
173
+ ).rejects.toThrow(/only allowed inside defineWorkflow/);
174
+ });
175
+
176
+ it("executes the do sub-pipeline and returns undefined on success", async () => {
177
+ const stepDef = getStep("workflow.retry");
178
+ expect(stepDef).toBeDefined();
179
+
180
+ const result = await stepDef!.run({ times: 3, backoff: "exponential", do: [] }, workflowCtx);
181
+
182
+ expect(result).toBeUndefined();
183
+ });
184
+ });
185
+
186
+ describe("calculateBackoff", () => {
187
+ it("returns baseMs * attempt for linear strategy", () => {
188
+ expect(calculateBackoff(1, "linear")).toBe(10_000);
189
+ expect(calculateBackoff(3, "linear")).toBe(30_000);
190
+ });
191
+
192
+ it("returns baseMs * 2^(attempt-1) for exponential strategy", () => {
193
+ expect(calculateBackoff(1, "exponential")).toBe(10_000);
194
+ expect(calculateBackoff(2, "exponential")).toBe(20_000);
195
+ expect(calculateBackoff(3, "exponential")).toBe(40_000);
196
+ expect(calculateBackoff(4, "exponential")).toBe(80_000);
197
+ });
198
+ });
@@ -0,0 +1,491 @@
1
+ // Boot-validator tests for r.step.unsafeProjection-* allowlist enforcement.
2
+ // Recursive walk through sub-pipelines (branch.onTrue/onFalse, forEach.do)
3
+ // is included here, plus the self-registration via defineStep({ subPaths })
4
+ // gate (Followup #15).
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import { eq } from "drizzle-orm";
8
+ import { pgTable, text, uuid } from "drizzle-orm/pg-core";
9
+ import { describe, expect, it } from "vitest";
10
+ import { z } from "zod";
11
+ import { defineFeature } from "../define-feature";
12
+ import { defineWriteHandler } from "../define-handler";
13
+ import { defineStep } from "../define-step";
14
+ import { createEntity, createTextField } from "../factories";
15
+ import { pipeline } from "../pipeline";
16
+ import { validateProjectionAllowlist } from "../validate-projection-allowlist";
17
+
18
+ describe("validateProjectionAllowlist", () => {
19
+ const demoLogTable = pgTable("validate_demo_log", {
20
+ id: uuid("id").primaryKey().defaultRandom(),
21
+ message: text("message").notNull(),
22
+ });
23
+
24
+ it("rejects unsafeProjectionUpsert on an undeclared table", () => {
25
+ const featureWithMissingDeclaration = defineFeature("vproj-missing", (r) => {
26
+ // Note: NO r.requires.projection("validate_demo_log") here.
27
+ r.writeHandler(
28
+ defineWriteHandler({
29
+ name: "log",
30
+ schema: z.object({ msg: z.string() }),
31
+ access: { roles: ["User"] },
32
+ perform: pipeline<{ msg: string }, { ok: true }>(({ event, r }) => [
33
+ r.step.unsafeProjectionUpsert({
34
+ table: demoLogTable,
35
+ on: ["id"],
36
+ row: () => ({ message: event.payload.msg }),
37
+ }),
38
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
39
+ ]),
40
+ }),
41
+ );
42
+ });
43
+
44
+ expect(() => validateProjectionAllowlist([featureWithMissingDeclaration])).toThrow(
45
+ /did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
46
+ );
47
+ });
48
+
49
+ it("rejects unsafeProjectionUpsert on an aggregate-table (registered via r.entity)", () => {
50
+ // Feature A registers `widget` as an aggregate (with table "widgets").
51
+ const ownerFeature = defineFeature("vproj-owner", (r) => {
52
+ r.entity(
53
+ "widget",
54
+ createEntity({
55
+ table: "widgets",
56
+ fields: { label: createTextField({ required: true }) },
57
+ }),
58
+ );
59
+ });
60
+
61
+ // Feature B tries to upsert directly into the widgets table — bypassing
62
+ // the aggregate-pipeline. Even with r.requires.projection it must fail.
63
+ const trespasserFeature = defineFeature("vproj-trespasser", (r) => {
64
+ r.requires.projection("widgets");
65
+ const widgetsTable = pgTable("widgets", {
66
+ id: uuid("id").primaryKey().defaultRandom(),
67
+ label: text("label").notNull(),
68
+ });
69
+ r.writeHandler(
70
+ defineWriteHandler({
71
+ name: "sneaky",
72
+ schema: z.object({}),
73
+ access: { roles: ["User"] },
74
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
75
+ r.step.unsafeProjectionUpsert({
76
+ table: widgetsTable,
77
+ on: ["id"],
78
+ row: () => ({ label: "trespass" }),
79
+ }),
80
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
81
+ ]),
82
+ }),
83
+ );
84
+ });
85
+
86
+ expect(() => validateProjectionAllowlist([ownerFeature, trespasserFeature])).toThrow(
87
+ /aggregate-projection of feature "vproj-owner".*r\.step\.aggregate\.\*/s,
88
+ );
89
+ });
90
+
91
+ it("rejects unsafeProjectionDelete on an aggregate-table (parallel to upsert case)", () => {
92
+ // Both unsafe-projection-* steps share UNSAFE_PROJECTION_KINDS in
93
+ // the validator. Verify the aggregate-table guard fires for delete
94
+ // too — without this test, a future kind-set narrowing could break
95
+ // delete's protection silently.
96
+ const ownerFeature = defineFeature("vproj-delete-owner", (r) => {
97
+ r.entity(
98
+ "widget",
99
+ createEntity({
100
+ table: "widgets-delete",
101
+ fields: { label: createTextField({ required: true }) },
102
+ }),
103
+ );
104
+ });
105
+
106
+ const trespasserFeature = defineFeature("vproj-delete-trespasser", (r) => {
107
+ r.requires.projection("widgets-delete");
108
+ const widgetsTable = pgTable("widgets-delete", {
109
+ id: uuid("id").primaryKey().defaultRandom(),
110
+ label: text("label").notNull(),
111
+ });
112
+ r.writeHandler(
113
+ defineWriteHandler({
114
+ name: "sneaky-delete",
115
+ schema: z.object({}),
116
+ access: { roles: ["User"] },
117
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
118
+ r.step.unsafeProjectionDelete({
119
+ table: widgetsTable,
120
+ where: () => eq(widgetsTable.id, "anything"),
121
+ }),
122
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
123
+ ]),
124
+ }),
125
+ );
126
+ });
127
+
128
+ expect(() => validateProjectionAllowlist([ownerFeature, trespasserFeature])).toThrow(
129
+ /aggregate-projection of feature "vproj-delete-owner".*r\.step\.aggregate\.\*/s,
130
+ );
131
+ });
132
+
133
+ it("rejects unsafeProjectionDelete on an undeclared table (same gate as upsert)", () => {
134
+ const featureWithoutDecl = defineFeature("vproj-delete-missing", (r) => {
135
+ r.writeHandler(
136
+ defineWriteHandler({
137
+ name: "purge",
138
+ schema: z.object({}),
139
+ access: { roles: ["User"] },
140
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
141
+ r.step.unsafeProjectionDelete({
142
+ table: demoLogTable,
143
+ where: () => eq(demoLogTable.id, "anything"),
144
+ }),
145
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
146
+ ]),
147
+ }),
148
+ );
149
+ });
150
+
151
+ expect(() => validateProjectionAllowlist([featureWithoutDecl])).toThrow(
152
+ /did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
153
+ );
154
+ });
155
+
156
+ it("walks into branch.onTrue to find unsafeProjection-* (Q17 recursive)", () => {
157
+ // Without recursive walk, the allowlist gate would be bypassed by
158
+ // wrapping the forbidden step in branch.onTrue — exactly the kind of
159
+ // bypass that the unsafe-prefix is meant to make visible.
160
+ const featureWithBranchedUnsafe = defineFeature("vproj-branched", (r) => {
161
+ r.writeHandler(
162
+ defineWriteHandler({
163
+ name: "branchedWrite",
164
+ schema: z.object({}),
165
+ access: { roles: ["User"] },
166
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
167
+ r.step.branch({
168
+ if: () => true,
169
+ onTrue: [
170
+ r.step.unsafeProjectionUpsert({
171
+ table: demoLogTable,
172
+ on: ["id"],
173
+ row: () => ({ message: "wrapped in branch" }),
174
+ }),
175
+ ],
176
+ }),
177
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
178
+ ]),
179
+ }),
180
+ );
181
+ });
182
+
183
+ expect(() => validateProjectionAllowlist([featureWithBranchedUnsafe])).toThrow(
184
+ /did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
185
+ );
186
+ });
187
+
188
+ it("walks recursively through nested sub-pipelines (forEach.do containing branch.onTrue containing unsafeProjection)", () => {
189
+ // Generator-depth coverage: if walkAllSteps' yield* gets removed
190
+ // in a future refactor, top-level + one-level tests stay green
191
+ // but nested patterns (very common: forEach with conditional
192
+ // upsert-or-delete) silently bypass the allowlist.
193
+ const featureWithNestedUnsafe = defineFeature("vproj-nested", (r) => {
194
+ r.writeHandler(
195
+ defineWriteHandler({
196
+ name: "nestedWrite",
197
+ schema: z.object({}),
198
+ access: { roles: ["User"] },
199
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
200
+ r.step.forEach({
201
+ over: () => [],
202
+ as: "item",
203
+ do: [
204
+ r.step.branch({
205
+ if: () => true,
206
+ onTrue: [
207
+ r.step.unsafeProjectionUpsert({
208
+ table: demoLogTable,
209
+ on: ["id"],
210
+ row: () => ({ message: "deeply nested" }),
211
+ }),
212
+ ],
213
+ }),
214
+ ],
215
+ }),
216
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
217
+ ]),
218
+ }),
219
+ );
220
+ });
221
+
222
+ expect(() => validateProjectionAllowlist([featureWithNestedUnsafe])).toThrow(
223
+ /did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
224
+ );
225
+ });
226
+
227
+ it("walks into forEach.do to find unsafeProjection-* (Q17 recursive)", () => {
228
+ const featureWithLoopedUnsafe = defineFeature("vproj-looped", (r) => {
229
+ r.writeHandler(
230
+ defineWriteHandler({
231
+ name: "loopedWrite",
232
+ schema: z.object({}),
233
+ access: { roles: ["User"] },
234
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
235
+ r.step.forEach({
236
+ over: () => [],
237
+ as: "x",
238
+ do: [
239
+ r.step.unsafeProjectionUpsert({
240
+ table: demoLogTable,
241
+ on: ["id"],
242
+ row: () => ({ message: "looped" }),
243
+ }),
244
+ ],
245
+ }),
246
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
247
+ ]),
248
+ }),
249
+ );
250
+ });
251
+
252
+ expect(() => validateProjectionAllowlist([featureWithLoopedUnsafe])).toThrow(
253
+ /did not declare it via r\.requires\.projection\("validate_demo_log"\)/,
254
+ );
255
+ });
256
+
257
+ it("does NOT recurse into unregistered step-kind sub-arrays — defineStep is the registration gate (#15)", () => {
258
+ // Pins the Followup #15 contract: walkAllSteps recurses ONLY into
259
+ // sub-arrays whose step-kind is registered with `subPaths` via
260
+ // defineStep. Hand-crafted instances with unregistered kinds are
261
+ // walked as a single node — their nested arrays are invisible.
262
+ // This is the SHAPE of the gate, demonstrated explicitly so the
263
+ // contract is testable, not accidental.
264
+ const featureWithUnknownSubBuilder = defineFeature("vproj-future-builder", (r) => {
265
+ r.requires.projection("validate_demo_log");
266
+ r.writeHandler(
267
+ defineWriteHandler({
268
+ name: "futureBuilder",
269
+ schema: z.object({}),
270
+ access: { roles: ["User"] },
271
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
272
+ // Hand-crafted StepInstance simulating a future builder
273
+ // whose kind isn't registered via defineStep yet.
274
+ {
275
+ kind: "future-sub-builder",
276
+ args: {
277
+ children: [
278
+ r.step.unsafeProjectionUpsert({
279
+ table: demoLogTable,
280
+ on: ["id"],
281
+ row: () => ({ message: "should escape allowlist" }),
282
+ }),
283
+ ],
284
+ },
285
+ },
286
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
287
+ ]),
288
+ }),
289
+ );
290
+ });
291
+
292
+ expect(() => validateProjectionAllowlist([featureWithUnknownSubBuilder])).not.toThrow();
293
+ });
294
+
295
+ it("DOES recurse into sub-arrays of step-kinds registered with subPaths via defineStep (#15)", () => {
296
+ // Inverse of the previous test: when a NEW sub-step-builder
297
+ // self-registers via defineStep({ subPaths: [...] }), the boot-
298
+ // validator picks up the recursion automatically — no central
299
+ // map to update. This is the value of self-registration.
300
+ const futureKind = `test:future-builder:${randomUUID()}`;
301
+ defineStep({
302
+ kind: futureKind,
303
+ defaultFailureStrategy: "throw",
304
+ subPaths: ["children"],
305
+ run: () => undefined,
306
+ });
307
+
308
+ const undeclaredTable = pgTable(`undeclared_${randomUUID().replace(/-/g, "")}`, {
309
+ id: uuid("id").primaryKey().defaultRandom(),
310
+ message: text("message").notNull(),
311
+ });
312
+
313
+ const feature = defineFeature(`vproj-future-builder-${randomUUID()}`, (r) => {
314
+ // Note: NO r.requires.projection for the undeclared table.
315
+ r.writeHandler(
316
+ defineWriteHandler({
317
+ name: "futureBuilder",
318
+ schema: z.object({}),
319
+ access: { roles: ["User"] },
320
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
321
+ {
322
+ kind: futureKind,
323
+ args: {
324
+ children: [
325
+ r.step.unsafeProjectionUpsert({
326
+ table: undeclaredTable,
327
+ on: ["id"],
328
+ row: () => ({ message: "deeply nested" }),
329
+ }),
330
+ ],
331
+ },
332
+ },
333
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
334
+ ]),
335
+ }),
336
+ );
337
+ });
338
+
339
+ // Walker descended into `children` because the registered StepDef
340
+ // declared it as a subPath, and the inner unsafeProjection trips
341
+ // the allowlist check.
342
+ expect(() => validateProjectionAllowlist([feature])).toThrow(
343
+ /did not declare it via r\.requires\.projection/,
344
+ );
345
+ });
346
+
347
+ it("rejects two features registering r.entity on the same table (#8)", () => {
348
+ // Without this guard, the second r.entity() silently overwrites the
349
+ // first in the validator's aggregate-tables map — and a later
350
+ // unsafeProjection error against that table would name the WRONG
351
+ // feature as owner. Surfacing the collision here names both parties.
352
+ const featureA = defineFeature("dup-aggregate-a", (r) => {
353
+ r.entity(
354
+ "thing",
355
+ createEntity({
356
+ table: "shared_table",
357
+ fields: { label: createTextField({ required: true }) },
358
+ }),
359
+ );
360
+ });
361
+ const featureB = defineFeature("dup-aggregate-b", (r) => {
362
+ r.entity(
363
+ "thing",
364
+ createEntity({
365
+ table: "shared_table",
366
+ fields: { label: createTextField({ required: true }) },
367
+ }),
368
+ );
369
+ });
370
+
371
+ expect(() => validateProjectionAllowlist([featureA, featureB])).toThrow(
372
+ /both feature "dup-aggregate-a" and feature "dup-aggregate-b"/,
373
+ );
374
+ });
375
+
376
+ it("rejects use of a tier-2 step without r.requires.step(...) declaration (Q9)", () => {
377
+ const sneakyFeature = defineFeature("step-discovery-missing", (r) => {
378
+ // Note: NO r.requires.step("webhook.send")
379
+ r.writeHandler(
380
+ defineWriteHandler({
381
+ name: "sneak",
382
+ schema: z.object({}),
383
+ access: { roles: ["Admin"] },
384
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
385
+ r.step.webhook.send({
386
+ url: "https://hooks.example/sneak",
387
+ mode: "deferred",
388
+ body: () => ({}),
389
+ }),
390
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
391
+ ]),
392
+ }),
393
+ );
394
+ });
395
+
396
+ expect(() => validateProjectionAllowlist([sneakyFeature])).toThrow(
397
+ /did not declare it via r\.requires\.step\("webhook\.send"\)/,
398
+ );
399
+ });
400
+
401
+ it("rejects mail.send without r.requires.step(...) declaration", () => {
402
+ const sneakyMail = defineFeature("step-discovery-mail-missing", (r) => {
403
+ r.writeHandler(
404
+ defineWriteHandler({
405
+ name: "sneak",
406
+ schema: z.object({}),
407
+ access: { roles: ["Admin"] },
408
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
409
+ r.step.mail.send({
410
+ to: "x@y.com",
411
+ subject: "hi",
412
+ body: "hello",
413
+ mode: "deferred",
414
+ }),
415
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
416
+ ]),
417
+ }),
418
+ );
419
+ });
420
+ expect(() => validateProjectionAllowlist([sneakyMail])).toThrow(
421
+ /did not declare it via r\.requires\.step\("mail\.send"\)/,
422
+ );
423
+ });
424
+
425
+ it("rejects callFeature without r.requires.step(...) declaration", () => {
426
+ const sneakyCall = defineFeature("step-discovery-call-missing", (r) => {
427
+ r.writeHandler(
428
+ defineWriteHandler({
429
+ name: "sneak",
430
+ schema: z.object({}),
431
+ access: { roles: ["Admin"] },
432
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
433
+ r.step.callFeature("subResult", {
434
+ handler: "other:write:do",
435
+ payload: () => ({}),
436
+ }),
437
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
438
+ ]),
439
+ }),
440
+ );
441
+ });
442
+ expect(() => validateProjectionAllowlist([sneakyCall])).toThrow(
443
+ /did not declare it via r\.requires\.step\("callFeature"\)/,
444
+ );
445
+ });
446
+
447
+ it("accepts a tier-2 step when r.requires.step(...) is declared", () => {
448
+ const happyFeature = defineFeature("step-discovery-happy", (r) => {
449
+ r.requires.step("webhook.send");
450
+ r.writeHandler(
451
+ defineWriteHandler({
452
+ name: "ok",
453
+ schema: z.object({}),
454
+ access: { roles: ["Admin"] },
455
+ perform: pipeline<Record<string, never>, { ok: true }>(({ r }) => [
456
+ r.step.webhook.send({
457
+ url: "https://hooks.example/ok",
458
+ mode: "deferred",
459
+ body: () => ({}),
460
+ }),
461
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
462
+ ]),
463
+ }),
464
+ );
465
+ });
466
+ expect(() => validateProjectionAllowlist([happyFeature])).not.toThrow();
467
+ });
468
+
469
+ it("accepts unsafeProjectionUpsert when the table is declared and not an aggregate", () => {
470
+ const happyFeature = defineFeature("vproj-happy", (r) => {
471
+ r.requires.projection("validate_demo_log");
472
+ r.writeHandler(
473
+ defineWriteHandler({
474
+ name: "log",
475
+ schema: z.object({ msg: z.string() }),
476
+ access: { roles: ["User"] },
477
+ perform: pipeline<{ msg: string }, { ok: true }>(({ event, r }) => [
478
+ r.step.unsafeProjectionUpsert({
479
+ table: demoLogTable,
480
+ on: ["id"],
481
+ row: () => ({ message: event.payload.msg }),
482
+ }),
483
+ r.step.return({ isSuccess: true as const, data: { ok: true } }),
484
+ ]),
485
+ }),
486
+ );
487
+ });
488
+
489
+ expect(() => validateProjectionAllowlist([happyFeature])).not.toThrow();
490
+ });
491
+ });