@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,551 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
4
+
5
+ // Bun.Glob is only used by scanForCandidates / runCodemod, not by
6
+ // analyzeFile or convertFile. Those higher-level functions are tested
7
+ // in the integration suite; unit tests here focus on file-level
8
+ // operations which don't need Glob. A stub class prevents import
9
+ // errors in vitest (which runs on Node, not Bun).
10
+ vi.mock("bun", () => {
11
+ class StubGlob {
12
+ scanSync(): never {
13
+ throw new Error("Bun.Glob is not available in unit-test mode");
14
+ }
15
+ }
16
+ return { Glob: StubGlob };
17
+ });
18
+
19
+ import {
20
+ analyzeFile,
21
+ analyzeHandlerArrow,
22
+ convertFile,
23
+ generatePerformBlock,
24
+ } from "../codemod/index";
25
+
26
+ const tmpDir = join(__dirname, "__codemod_fixtures__");
27
+
28
+ function writeFixture(name: string, content: string): string {
29
+ const p = join(tmpDir, name);
30
+ writeFileSync(p, content, "utf8");
31
+ return p;
32
+ }
33
+
34
+ beforeAll(() => {
35
+ rmSync(tmpDir, { recursive: true, force: true });
36
+ mkdirSync(tmpDir, { recursive: true });
37
+ });
38
+
39
+ afterAll(() => {
40
+ rmSync(tmpDir, { recursive: true, force: true });
41
+ });
42
+
43
+ // ── Fixture helpers ──────────────────────────────────────────────
44
+
45
+ const staticReturnContent = `\
46
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
47
+ import { z } from "zod";
48
+
49
+ export const myHandler = defineWriteHandler({
50
+ name: "test:static",
51
+ schema: z.object({}),
52
+ access: { roles: access.authenticated },
53
+ handler: async () => ({ isSuccess: true, data: { ok: true } }),
54
+ });
55
+ `;
56
+
57
+ const executorCreateContent = `\
58
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
59
+ import { z } from "zod";
60
+
61
+ export const myHandler = defineWriteHandler({
62
+ name: "test:create",
63
+ schema: z.object({ label: z.string() }),
64
+ access: { roles: access.authenticated },
65
+ handler: async (event, ctx) => {
66
+ const result = await invoiceExecutor.create({ label: event.payload.label });
67
+ return { isSuccess: true, data: result };
68
+ },
69
+ });
70
+ `;
71
+
72
+ const executorUpdateContent = `\
73
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
74
+ import { z } from "zod";
75
+
76
+ export const myHandler = defineWriteHandler({
77
+ name: "test:update",
78
+ schema: z.object({ id: z.string(), changes: z.record(z.unknown()) }),
79
+ access: { roles: access.authenticated },
80
+ handler: async (event, ctx) => {
81
+ const result = await invoiceExecutor.update({ id: event.payload.id, changes: event.payload.changes });
82
+ return { isSuccess: true, data: result };
83
+ },
84
+ });
85
+ `;
86
+
87
+ const complexContent = `\
88
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
89
+ import { z } from "zod";
90
+
91
+ export const myHandler = defineWriteHandler({
92
+ name: "test:complex",
93
+ schema: z.object({ value: z.number() }),
94
+ access: { roles: access.authenticated },
95
+ handler: async (event, ctx) => {
96
+ if (event.payload.value < 0) {
97
+ return { isSuccess: false, error: "negative" };
98
+ }
99
+ const result = await someExecutor.create({ value: event.payload.value });
100
+ return { isSuccess: true, data: result };
101
+ },
102
+ });
103
+ `;
104
+
105
+ const alreadyPipelineContent = `\
106
+ import { access, defineWriteHandler, pipeline } from "@cosmicdrift/kumiko-framework/engine";
107
+ import { z } from "zod";
108
+
109
+ export const myHandler = defineWriteHandler({
110
+ name: "test:pipeline",
111
+ schema: z.object({}),
112
+ access: { roles: access.authenticated },
113
+ perform: pipeline(({ event, r }) => [
114
+ r.step.return((ctx) => ({ isSuccess: true, data: { ok: true } })),
115
+ ]),
116
+ });
117
+ `;
118
+
119
+ const queryHandlerContent = `\
120
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
121
+ import { z } from "zod";
122
+
123
+ export const myQuery = defineQueryHandler({
124
+ name: "test:query",
125
+ schema: z.object({}),
126
+ handler: async (query, ctx) => ({ items: [] }),
127
+ });
128
+ `;
129
+
130
+ const noHandlerContent = `\
131
+ export const irrelevant = 42;
132
+ `;
133
+
134
+ // ── Tests ───────────────────────────────────────────────────────
135
+
136
+ describe("analyzeFile", () => {
137
+ it("returns null for non-existent file", () => {
138
+ expect(analyzeFile("/nonexistent/file.ts")).toBeNull();
139
+ });
140
+
141
+ it("returns null for file without defineWriteHandler/defineQueryHandler", () => {
142
+ const p = writeFixture("no-handler.ts", noHandlerContent);
143
+ expect(analyzeFile(p)).toBeNull();
144
+ });
145
+
146
+ it("detects free-form write handler", () => {
147
+ const p = writeFixture("freeform.write.ts", staticReturnContent);
148
+ const result = analyzeFile(p);
149
+ expect(result).not.toBeNull();
150
+ expect(result!.pattern).toBe("free-form-write");
151
+ expect(result!.convertible).toBe(true);
152
+ expect(result!.reason).toBe("free_form_write_handler");
153
+ });
154
+
155
+ it("detects already-converted pipeline write handler", () => {
156
+ const p = writeFixture("already-pipeline.write.ts", alreadyPipelineContent);
157
+ const result = analyzeFile(p);
158
+ expect(result).not.toBeNull();
159
+ expect(result!.pattern).toBe("pipeline-write");
160
+ expect(result!.convertible).toBe(false);
161
+ expect(result!.reason).toBe("already_uses_pipeline_form");
162
+ });
163
+
164
+ it("detects query handler as non-convertible", () => {
165
+ const p = writeFixture("query-handler.write.ts", queryHandlerContent);
166
+ const result = analyzeFile(p);
167
+ expect(result).not.toBeNull();
168
+ expect(result!.pattern).toBe("query-handler");
169
+ expect(result!.convertible).toBe(false);
170
+ expect(result!.reason).toBe("query_handlers_not_convertible");
171
+ });
172
+ });
173
+
174
+ describe("convertFile", () => {
175
+ describe("static return handler", () => {
176
+ it("converts handler to perform: pipeline(...)", async () => {
177
+ const p = writeFixture("static-convert.write.ts", staticReturnContent);
178
+ const result = await convertFile(p);
179
+ expect(result.status).toBe("converted");
180
+ const content = readFileSync(p, "utf8");
181
+ expect(content).toContain("perform: pipeline");
182
+ expect(content).toContain("r.step.return");
183
+ expect(content).toContain("import { access, defineWriteHandler, pipeline }");
184
+ expect(content).not.toContain("handler:");
185
+ });
186
+
187
+ it("preserves schema reference in type parameter", async () => {
188
+ const content = `\
189
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
190
+ import { z } from "zod";
191
+ const MySchema = z.object({ x: z.number() });
192
+ export const h = defineWriteHandler({
193
+ name: "test",
194
+ schema: MySchema,
195
+ handler: async () => ({ isSuccess: true, data: { x: 1 } }),
196
+ });`;
197
+ const p = writeFixture("schema-ref.write.ts", content);
198
+ const result = await convertFile(p);
199
+ expect(result.status).toBe("converted");
200
+ const converted = readFileSync(p, "utf8");
201
+ expect(converted).toContain("pipeline<typeof MySchema, unknown>");
202
+ });
203
+ });
204
+
205
+ describe("executor.create handler", () => {
206
+ it("converts executor.create + return to pipeline", async () => {
207
+ const p = writeFixture("executor-create-convert.write.ts", executorCreateContent);
208
+ const result = await convertFile(p);
209
+ expect(result.status).toBe("converted");
210
+ const content = readFileSync(p, "utf8");
211
+ expect(content).toContain("r.step.aggregate.create");
212
+ expect(content).toContain("r.step.return");
213
+ expect(content).not.toContain("handler:");
214
+ });
215
+
216
+ it("skips executor.create when return is multi-line (known limitation)", async () => {
217
+ const content = `\
218
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
219
+ import { z } from "zod";
220
+
221
+ export const h = defineWriteHandler({
222
+ name: "test",
223
+ schema: z.object({}),
224
+ access: { roles: access.authenticated },
225
+ handler: async (event, ctx) => {
226
+ const result = await invoiceExecutor.create({ label: "x" });
227
+ return {
228
+ isSuccess: true,
229
+ data: result,
230
+ };
231
+ },
232
+ });`;
233
+ const p = writeFixture("multi-line-ret.write.ts", content);
234
+ const result = await convertFile(p);
235
+ expect(result.status).toBe("converted");
236
+ const converted = readFileSync(p, "utf8");
237
+ expect(converted).toContain("r.step.aggregate.create");
238
+ expect(converted).toContain("r.step.return");
239
+ });
240
+ });
241
+
242
+ describe("executor.update handler", () => {
243
+ it("converts executor.update + return to pipeline", async () => {
244
+ const p = writeFixture("executor-update-convert.write.ts", executorUpdateContent);
245
+ const result = await convertFile(p);
246
+ expect(result.status).toBe("converted");
247
+ const content = readFileSync(p, "utf8");
248
+ expect(content).toContain("r.step.aggregate.update");
249
+ expect(content).toContain("r.step.return");
250
+ });
251
+ });
252
+
253
+ describe("non-convertible handlers", () => {
254
+ it("skips handlers with conditional logic", async () => {
255
+ const p = writeFixture("complex-skip.write.ts", complexContent);
256
+ const result = await convertFile(p);
257
+ expect(result.status).toBe("skipped");
258
+ });
259
+
260
+ it("skips already-converted pipeline handlers", async () => {
261
+ const p = writeFixture("already-pipeline-convert.write.ts", alreadyPipelineContent);
262
+ const result = await convertFile(p);
263
+ expect(result.status).toBe("skipped");
264
+ expect(result.reason).toContain("no convertible handler");
265
+ });
266
+ });
267
+
268
+ describe("import injection", () => {
269
+ it("adds pipeline import when not present", async () => {
270
+ const p = writeFixture("import-add.write.ts", staticReturnContent);
271
+ const result = await convertFile(p);
272
+ expect(result.status).toBe("converted");
273
+ const content = readFileSync(p, "utf8");
274
+ expect(content).toContain("import { access, defineWriteHandler, pipeline }");
275
+ });
276
+
277
+ it("does not duplicate pipeline import when already present", async () => {
278
+ const content = `\
279
+ import { access, defineWriteHandler, pipeline } from "@cosmicdrift/kumiko-framework/engine";
280
+ import { z } from "zod";
281
+
282
+ export const h = defineWriteHandler({
283
+ name: "test:dup",
284
+ schema: z.object({}),
285
+ handler: async () => ({ isSuccess: true, data: { ok: true } }),
286
+ });`;
287
+ const p = writeFixture("import-already.write.ts", content);
288
+ const result = await convertFile(p);
289
+ expect(result.status).toBe("converted");
290
+ const final = readFileSync(p, "utf8").split("\n");
291
+ const pipelineImports = final.filter((l) => l.includes("import") && l.includes("pipeline"));
292
+ // Only one import line should contain "pipeline"
293
+ expect(pipelineImports.length).toBe(1);
294
+ });
295
+ });
296
+
297
+ describe("edge cases", () => {
298
+ it("handles multiple defineWriteHandler calls in one file", async () => {
299
+ const content = `\
300
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
301
+ import { z } from "zod";
302
+ export const h1 = defineWriteHandler({
303
+ name: "test:a",
304
+ schema: z.object({}),
305
+ handler: async () => ({ isSuccess: true, data: { a: 1 } }),
306
+ });
307
+ export const h2 = defineWriteHandler({
308
+ name: "test:b",
309
+ schema: z.object({}),
310
+ handler: async () => ({ isSuccess: true, data: { b: 2 } }),
311
+ });`;
312
+ const p = writeFixture("multi-handler.write.ts", content);
313
+ const result = await convertFile(p);
314
+ expect(result.status).toBe("converted");
315
+ const final = readFileSync(p, "utf8");
316
+ // Both handlers should be converted
317
+ expect(final.match(/perform:/g)?.length).toBe(2);
318
+ expect(final.match(/r\.step\.return/g)?.length).toBe(2);
319
+ });
320
+
321
+ it("dry-run does not modify the file", async () => {
322
+ const p = writeFixture("dryrun.write.ts", staticReturnContent);
323
+ const original = readFileSync(p, "utf8");
324
+ const result = await convertFile(p, undefined, { dryRun: true });
325
+ expect(result.status).toBe("converted");
326
+ // File should be unchanged
327
+ expect(readFileSync(p, "utf8")).toBe(original);
328
+ });
329
+
330
+ it("converts expression-body executor.create handler", async () => {
331
+ const content = `\
332
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
333
+ import { z } from "zod";
334
+
335
+ export const h = defineWriteHandler({
336
+ name: "test:expr-create",
337
+ schema: z.object({ label: z.string() }),
338
+ handler: async (event, ctx) => invoiceExecutor.create(event.payload),
339
+ });`;
340
+ const p = writeFixture("expr-create.write.ts", content);
341
+ const result = await convertFile(p);
342
+ expect(result.status).toBe("converted");
343
+ const converted = readFileSync(p, "utf8");
344
+ expect(converted).toContain("r.step.aggregate.create");
345
+ expect(converted).toContain("ctx.event.payload");
346
+ expect(converted).toContain("r.step.return");
347
+ });
348
+
349
+ it("converts guarded executor.create handler with if-guard", async () => {
350
+ const content = `\
351
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
352
+ import { z } from "zod";
353
+
354
+ export const h = defineWriteHandler({
355
+ name: "test:guarded",
356
+ schema: z.object({ label: z.string() }),
357
+ handler: async (event, ctx) => {
358
+ const result = await invoiceExecutor.create({ label: event.payload.label });
359
+ if (!result.isSuccess) {
360
+ return { isSuccess: false, error: result.error };
361
+ }
362
+ return { isSuccess: true, data: result.data };
363
+ },
364
+ });`;
365
+ const p = writeFixture("guarded-create.write.ts", content);
366
+ const result = await convertFile(p);
367
+ expect(result.status).toBe("converted");
368
+ const converted = readFileSync(p, "utf8");
369
+ expect(converted).toContain("r.step.aggregate.create");
370
+ expect(converted).toContain("r.step.compute");
371
+ expect(converted).toContain("ctx.steps.result");
372
+ expect(converted).toContain("r.step.return((ctx) => ctx.steps.outcome)");
373
+ });
374
+
375
+ it("converts expression-body executor.update handler", async () => {
376
+ const content = `\
377
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
378
+ import { z } from "zod";
379
+
380
+ export const h = defineWriteHandler({
381
+ name: "test:expr-update",
382
+ schema: z.object({ id: z.string(), changes: z.record(z.unknown()) }),
383
+ handler: async (event, ctx) => invoiceExecutor.update({ id: event.payload.id, changes: event.payload.changes }),
384
+ });`;
385
+ const p = writeFixture("expr-update.write.ts", content);
386
+ const result = await convertFile(p);
387
+ expect(result.status).toBe("converted");
388
+ const converted = readFileSync(p, "utf8");
389
+ expect(converted).toContain("r.step.compute");
390
+ expect(converted).toContain("ctx.event.payload");
391
+ expect(converted).toContain("r.step.return");
392
+ });
393
+
394
+ it("returns error for malformed file", async () => {
395
+ const p = writeFixture("malformed.write.ts", "this is not valid ts @@@@");
396
+ const result = await convertFile(p);
397
+ // ts-morph should handle this gracefully (parse error → no call found)
398
+ expect(result.status).toBe("skipped");
399
+ });
400
+ });
401
+ });
402
+
403
+ describe("analyzeHandlerArrow", () => {
404
+ it("detects static return handler", () => {
405
+ const result = analyzeHandlerArrow("async () => ({ isSuccess: true, data: { ok: true } })");
406
+ expect(result.isStaticReturn).toBe(true);
407
+ expect(result.isSimpleExecutorCreate).toBe(false);
408
+ expect(result.isSimpleExecutorUpdate).toBe(false);
409
+ expect(result.hasConditionalLogic).toBe(false);
410
+ });
411
+
412
+ it("detects executor.create + return handler", () => {
413
+ const result = analyzeHandlerArrow(`async (event, ctx) => {
414
+ const result = await invoiceExecutor.create({ label: event.payload.label });
415
+ return { isSuccess: true, data: result };
416
+ }`);
417
+ expect(result.isStaticReturn).toBe(false);
418
+ expect(result.isSimpleExecutorCreate).toBe(true);
419
+ expect(result.executorName).toBe("invoiceExecutor");
420
+ expect(result.executorCreateVar).toBe("result");
421
+ expect(result.hasConditionalLogic).toBe(false);
422
+ });
423
+
424
+ it("detects executor.create with multi-line return", () => {
425
+ const result = analyzeHandlerArrow(`async (event, ctx) => {
426
+ const result = await invoiceExecutor.create({ label: event.payload.label });
427
+ return {
428
+ isSuccess: true,
429
+ data: result,
430
+ };
431
+ }`);
432
+ expect(result.isSimpleExecutorCreate).toBe(true);
433
+ });
434
+
435
+ it("detects executor.update + return handler", () => {
436
+ const result = analyzeHandlerArrow(`async (event, ctx) => {
437
+ const result = await invoiceExecutor.update({ id: event.payload.id, changes: event.payload.changes });
438
+ return { isSuccess: true, data: result };
439
+ }`);
440
+ expect(result.isSimpleExecutorUpdate).toBe(true);
441
+ expect(result.executorName).toBe("invoiceExecutor");
442
+ expect(result.hasConditionalLogic).toBe(false);
443
+ });
444
+
445
+ it("flags handlers with conditional logic", () => {
446
+ const result = analyzeHandlerArrow(`async (event, ctx) => {
447
+ if (event.payload.value < 0) {
448
+ return { isSuccess: false, error: "negative" };
449
+ }
450
+ const r = await executor.create({ value: event.payload.value });
451
+ return { isSuccess: true, data: r };
452
+ }`);
453
+ expect(result.hasConditionalLogic).toBe(true);
454
+ expect(result.isSimpleExecutorCreate).toBe(false);
455
+ });
456
+
457
+ it("ignores expression-body handlers (non-object)", () => {
458
+ const result = analyzeHandlerArrow("async (event, ctx) => crud.create(event.payload)");
459
+ expect(result.isStaticReturn).toBe(false);
460
+ expect(result.isSimpleExecutorCreate).toBe(false);
461
+ });
462
+
463
+ it("detects expression-body executor.create", () => {
464
+ const result = analyzeHandlerArrow(
465
+ "async (event, ctx) => invoiceExecutor.create(event.payload)",
466
+ );
467
+ expect(result.isExpressionBodyCreate).toBe(true);
468
+ expect(result.executorName).toBe("invoiceExecutor");
469
+ expect(result.expressionBodyArgs).toEqual(["event.payload"]);
470
+ });
471
+
472
+ it("detects expression-body executor.update", () => {
473
+ const result = analyzeHandlerArrow(
474
+ "async (event, ctx) => invoiceExecutor.update({ id: event.payload.id, changes: event.payload.changes })",
475
+ );
476
+ expect(result.isExpressionBodyUpdate).toBe(true);
477
+ expect(result.executorName).toBe("invoiceExecutor");
478
+ expect(result.expressionBodyArgs).toHaveLength(1);
479
+ });
480
+
481
+ it("detects guarded executor.create with if-guard", () => {
482
+ const result = analyzeHandlerArrow(`async (event, ctx) => {
483
+ const result = await invoiceExecutor.create({ label: event.payload.label });
484
+ if (!result.isSuccess) {
485
+ return { isSuccess: false, error: result.error };
486
+ }
487
+ return { isSuccess: true, data: result.data };
488
+ }`);
489
+ expect(result.isGuardedCreate).toBe(true);
490
+ expect(result.executorName).toBe("invoiceExecutor");
491
+ expect(result.guardConfig).toBeDefined();
492
+ expect(result.guardConfig!.condition).toBe("!result.isSuccess");
493
+ expect(result.guardConfig!.failureReturn).toContain("isSuccess: false");
494
+ expect(result.guardConfig!.successReturn).toContain("isSuccess: true");
495
+ expect(result.hasConditionalLogic).toBe(true);
496
+ });
497
+
498
+ it("detects guarded handler correctly, not as simple create", () => {
499
+ // A handler with if-guard should NOT be classified as simple create
500
+ const result = analyzeHandlerArrow(`async (event, ctx) => {
501
+ const result = await invoiceExecutor.create({ label: event.payload.label });
502
+ if (!result.isSuccess) {
503
+ return { isSuccess: false, error: result.error };
504
+ }
505
+ return { isSuccess: true, data: result.data };
506
+ }`);
507
+ expect(result.isGuardedCreate).toBe(true);
508
+ expect(result.isSimpleExecutorCreate).toBe(false);
509
+ });
510
+ });
511
+
512
+ describe("generatePerformBlock", () => {
513
+ it("generates pipeline block for static return", () => {
514
+ const analysis = analyzeHandlerArrow("async () => ({ isSuccess: true, data: { ok: true } })");
515
+ const block = generatePerformBlock(analysis, "", " ");
516
+ expect(block).toContain("perform: pipeline(");
517
+ expect(block).toContain("r.step.return((ctx) => ({ isSuccess: true, data: { ok: true } })");
518
+ });
519
+
520
+ it("generates pipeline block with schema type parameter", () => {
521
+ const analysis = analyzeHandlerArrow("async () => ({ isSuccess: true, data: { ok: true } })");
522
+ const block = generatePerformBlock(analysis, "typeof InvoiceSchema", " ");
523
+ expect(block).toContain("pipeline<typeof InvoiceSchema, unknown>");
524
+ });
525
+
526
+ it("returns null for non-convertible analysis", () => {
527
+ const analysis = analyzeHandlerArrow(`async (event, ctx) => {
528
+ if (event.payload.x) { return { isSuccess: true, data: null }; }
529
+ return { isSuccess: false, error: "no" };
530
+ }`);
531
+ const block = generatePerformBlock(analysis, "", " ");
532
+ expect(block).toBeNull();
533
+ });
534
+
535
+ it("generates pipeline block for guarded executor.create", () => {
536
+ const analysis = analyzeHandlerArrow(`async (event, ctx) => {
537
+ const result = await invoiceExecutor.create({ label: event.payload.label });
538
+ if (!result.isSuccess) {
539
+ return { isSuccess: false, error: result.error };
540
+ }
541
+ return { isSuccess: true, data: result.data };
542
+ }`);
543
+ const block = generatePerformBlock(analysis, "", " ");
544
+ expect(block).toContain("r.step.aggregate.create");
545
+ expect(block).toContain('r.step.compute("outcome"');
546
+ expect(block).toContain("ctx.steps.result.isSuccess");
547
+ expect(block).toContain("return { isSuccess: false, error: ctx.steps.result.error }");
548
+ expect(block).toContain("return { isSuccess: true, data: ctx.steps.result.data }");
549
+ expect(block).toContain("r.step.return((ctx) => ctx.steps.outcome)");
550
+ });
551
+ });
@@ -185,11 +185,11 @@ describe("defineProjectionQueryHandler", () => {
185
185
  expect(result).toBe(fakeRows);
186
186
  });
187
187
 
188
- test("allTenants: true forwards the option to ctx.queryProjection", async () => {
188
+ test("unsafeAllTenants: true forwards the option to ctx.queryProjection", async () => {
189
189
  const def = defineProjectionQueryHandler(
190
190
  "revenue:list",
191
191
  "showcase:projection:customer-revenue",
192
- { allTenants: true },
192
+ { unsafeAllTenants: true },
193
193
  );
194
194
  const ctx = { queryProjection: vi.fn().mockResolvedValue([]) };
195
195
  await def.handler(
@@ -199,7 +199,7 @@ describe("defineProjectionQueryHandler", () => {
199
199
  ctx as any,
200
200
  );
201
201
  expect(ctx.queryProjection).toHaveBeenCalledWith("showcase:projection:customer-revenue", {
202
- allTenants: true,
202
+ unsafeAllTenants: true,
203
203
  });
204
204
  });
205
205
 
@@ -11,13 +11,13 @@ describe("emitEvent", () => {
11
11
  };
12
12
 
13
13
  test("delegates to ctx.appendEvent with eventDef.name as the type", async () => {
14
- const ctx = { appendEventUnsafe: vi.fn().mockResolvedValue(undefined) };
14
+ const ctx = { unsafeAppendEvent: vi.fn().mockResolvedValue(undefined) };
15
15
  await emitEvent(ctx, orderPlaced, {
16
16
  aggregateId: "agg-1",
17
17
  aggregateType: "pubsub-order",
18
18
  payload: { id: "agg-1", customer: "alice" },
19
19
  });
20
- expect(ctx.appendEventUnsafe).toHaveBeenCalledWith({
20
+ expect(ctx.unsafeAppendEvent).toHaveBeenCalledWith({
21
21
  aggregateId: "agg-1",
22
22
  aggregateType: "pubsub-order",
23
23
  type: "pubsub-orders:event:order-placed",
@@ -26,7 +26,7 @@ describe("emitEvent", () => {
26
26
  });
27
27
 
28
28
  test("payload type is inferred from the eventDef — wrong shape is a compile error", async () => {
29
- const ctx = { appendEventUnsafe: vi.fn().mockResolvedValue(undefined) };
29
+ const ctx = { unsafeAppendEvent: vi.fn().mockResolvedValue(undefined) };
30
30
  // Runtime check: compile-time narrowing is the real win, but we also
31
31
  // make sure the value flows through unchanged.
32
32
  await emitEvent(ctx, orderPlaced, {
@@ -34,7 +34,7 @@ describe("emitEvent", () => {
34
34
  aggregateType: "pubsub-order",
35
35
  payload: { id: "a", customer: "bob" },
36
36
  });
37
- const call = ctx.appendEventUnsafe.mock.calls[0]?.[0] as { payload: unknown };
37
+ const call = ctx.unsafeAppendEvent.mock.calls[0]?.[0] as { payload: unknown };
38
38
  expect(call.payload).toEqual({ id: "a", customer: "bob" });
39
39
  });
40
40
  });