@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,641 @@
1
+ // biome-ignore-all lint/suspicious/noConsole: CLI tool — console.log is the output
2
+ // biome-ignore-all lint/style/noNonNullAssertion: regex capture-group indexing — match-success implies non-null capture
3
+
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join, relative } from "node:path";
6
+ import { Glob } from "bun";
7
+ import { type CallExpression, Project, type SourceFile, SyntaxKind } from "ts-morph";
8
+
9
+ export type CodemodResult = {
10
+ readonly filePath: string;
11
+ readonly status: "converted" | "skipped" | "error";
12
+ readonly reason?: string;
13
+ };
14
+
15
+ export type CodemodReport = {
16
+ readonly results: readonly CodemodResult[];
17
+ readonly converted: number;
18
+ readonly skipped: number;
19
+ readonly errors: number;
20
+ };
21
+
22
+ export type CodemodOptions = {
23
+ readonly dryRun?: boolean;
24
+ readonly verbose?: boolean;
25
+ };
26
+
27
+ export type GuardConfig = {
28
+ readonly condition: string;
29
+ readonly failureReturn: string;
30
+ readonly successReturn: string;
31
+ };
32
+
33
+ export type ParsedHandlerInfo = {
34
+ readonly handlerBodyText: string;
35
+ readonly isStaticReturn: boolean;
36
+ readonly isSimpleExecutorCreate: boolean;
37
+ readonly isSimpleExecutorUpdate: boolean;
38
+ readonly hasConditionalLogic: boolean;
39
+ readonly executorName?: string;
40
+ readonly executorCreateVar?: string;
41
+ readonly executorCreateArgs?: string[];
42
+ readonly executorUpdateVar?: string;
43
+ readonly isExpressionBodyCreate: boolean;
44
+ readonly isExpressionBodyUpdate: boolean;
45
+ readonly expressionBodyArgs: string[] | undefined;
46
+ readonly isGuardedCreate: boolean;
47
+ readonly guardConfig: GuardConfig | undefined;
48
+ };
49
+
50
+ export function analyzeHandlerArrow(arrowText: string): ParsedHandlerInfo {
51
+ const text = arrowText.trim();
52
+
53
+ let handlerBodyText: string;
54
+ let isStaticReturn = false;
55
+ let isSimpleExecutorCreate = false;
56
+ let isSimpleExecutorUpdate = false;
57
+ let hasConditionalLogic = false;
58
+ let executorName: string | undefined;
59
+ let executorCreateVar: string | undefined;
60
+ let executorCreateArgs: string[] | undefined;
61
+ let executorUpdateVar: string | undefined;
62
+ let isExpressionBodyCreate = false;
63
+ let isExpressionBodyUpdate = false;
64
+ let expressionBodyArgs: string[] | undefined;
65
+ let isGuardedCreate = false;
66
+ let guardConfig: GuardConfig | undefined;
67
+
68
+ if (text.startsWith("async (")) {
69
+ const arrowIdx = text.indexOf("=>");
70
+ if (arrowIdx === -1) {
71
+ return {
72
+ handlerBodyText: text,
73
+ isStaticReturn: false,
74
+ isSimpleExecutorCreate: false,
75
+ isSimpleExecutorUpdate: false,
76
+ hasConditionalLogic: false,
77
+ isExpressionBodyCreate: false,
78
+ isExpressionBodyUpdate: false,
79
+ expressionBodyArgs: undefined,
80
+ isGuardedCreate: false,
81
+ guardConfig: undefined,
82
+ };
83
+ }
84
+ const afterArrow = text.slice(arrowIdx + 2).trim();
85
+
86
+ if (afterArrow.startsWith("(")) {
87
+ isStaticReturn = true;
88
+ handlerBodyText = afterArrow;
89
+ } else if (afterArrow.startsWith("{")) {
90
+ handlerBodyText = afterArrow;
91
+
92
+ const lines = handlerBodyText
93
+ .split("\n")
94
+ .map((l) => l.trim())
95
+ .filter(Boolean);
96
+
97
+ // Detect conditional logic (if/try/for/while/catch)
98
+ hasConditionalLogic = lines.some(
99
+ (l) =>
100
+ l.startsWith("if ") ||
101
+ l.startsWith("try ") ||
102
+ l.startsWith("for ") ||
103
+ l.startsWith("while ") ||
104
+ l.startsWith("} else"),
105
+ );
106
+
107
+ const createMatch = handlerBodyText.match(/const\s+(\w+)\s*=\s*await\s+(\w+)\.create\s*\(/);
108
+ const updateMatch = handlerBodyText.match(/const\s+(\w+)\s*=\s*await\s+(\w+)\.update\s*\(/);
109
+ const hasSimpleReturn = /return\s*\{[^}]*isSuccess:\s*true/.test(handlerBodyText);
110
+
111
+ if (createMatch && hasSimpleReturn && !hasConditionalLogic) {
112
+ isSimpleExecutorCreate = true;
113
+ executorName = createMatch[2];
114
+ executorCreateVar = createMatch[1];
115
+ executorCreateArgs = extractArgs(handlerBodyText, createMatch[2] as string, "create");
116
+ } else if (createMatch && hasSimpleReturn && hasConditionalLogic) {
117
+ // Try to match the guard pattern: if (!<var>.isSuccess) { return fail } return success
118
+ const guard = extractGuardedPattern(handlerBodyText, createMatch[1] as string);
119
+ if (guard) {
120
+ isGuardedCreate = true;
121
+ guardConfig = guard;
122
+ executorName = createMatch[2];
123
+ executorCreateVar = createMatch[1];
124
+ executorCreateArgs = extractArgs(handlerBodyText, createMatch[2] as string, "create");
125
+ }
126
+ }
127
+
128
+ if (
129
+ updateMatch &&
130
+ hasSimpleReturn &&
131
+ !hasConditionalLogic &&
132
+ !isSimpleExecutorCreate &&
133
+ !isGuardedCreate
134
+ ) {
135
+ isSimpleExecutorUpdate = true;
136
+ executorName = updateMatch[2];
137
+ executorUpdateVar = updateMatch[1];
138
+ }
139
+ } else {
140
+ handlerBodyText = afterArrow;
141
+ // Expression body — check for member-call patterns like
142
+ // `crud.create(event.payload)` or `crud.update({ id, changes })`.
143
+ const exprCallMatch = afterArrow.match(/^(\w+)\.(create|update)\s*\(/);
144
+ if (exprCallMatch) {
145
+ executorName = exprCallMatch[1]!;
146
+ const method = exprCallMatch[2]!;
147
+ expressionBodyArgs = extractExprArgs(afterArrow, executorName, method);
148
+ if (method === "create") {
149
+ isExpressionBodyCreate = true;
150
+ } else {
151
+ isExpressionBodyUpdate = true;
152
+ }
153
+ } else if (afterArrow.startsWith("(") && afterArrow.endsWith(")")) {
154
+ isStaticReturn = true;
155
+ }
156
+ }
157
+ } else {
158
+ handlerBodyText = text;
159
+ }
160
+
161
+ return {
162
+ handlerBodyText,
163
+ isStaticReturn,
164
+ isSimpleExecutorCreate,
165
+ isSimpleExecutorUpdate,
166
+ hasConditionalLogic,
167
+ executorName,
168
+ executorCreateVar,
169
+ executorCreateArgs,
170
+ executorUpdateVar,
171
+ isExpressionBodyCreate,
172
+ isExpressionBodyUpdate,
173
+ expressionBodyArgs,
174
+ isGuardedCreate,
175
+ guardConfig,
176
+ };
177
+ }
178
+
179
+ function extractArgs(body: string, executorVar: string, method: string): string[] | undefined {
180
+ const pattern = new RegExp(
181
+ `const\\s+\\w+\\s*=\\s*await\\s+${escapeRegex(executorVar)}\\.${method}\\s*\\(`,
182
+ );
183
+ const match = body.match(pattern);
184
+ if (!match) return undefined;
185
+
186
+ const startIdx = (match.index ?? 0) + match[0].length;
187
+ let depth = 1;
188
+ let endIdx = startIdx;
189
+ while (endIdx < body.length && depth > 0) {
190
+ if (body[endIdx] === "(") depth++;
191
+ else if (body[endIdx] === ")") depth--;
192
+ if (depth > 0) endIdx++;
193
+ }
194
+
195
+ const argsStr = body.slice(startIdx, endIdx);
196
+ return splitTopLevel(argsStr);
197
+ }
198
+
199
+ function escapeRegex(str: string): string {
200
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
201
+ }
202
+
203
+ function splitTopLevel(args: string): string[] {
204
+ const result: string[] = [];
205
+ let depth = 0;
206
+ let current = "";
207
+ for (const ch of args) {
208
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
209
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
210
+ if (ch === "," && depth === 0) {
211
+ result.push(current.trim());
212
+ current = "";
213
+ } else {
214
+ current += ch;
215
+ }
216
+ }
217
+ if (current.trim()) result.push(current.trim());
218
+ return result;
219
+ }
220
+
221
+ function extractExprArgs(body: string, executorVar: string, method: string): string[] | undefined {
222
+ const escaped = executorVar.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
223
+ const pattern = new RegExp(`${escaped}\\.${method}\\s*\\(`);
224
+ const match = body.match(pattern);
225
+ if (!match) return undefined;
226
+
227
+ const startIdx = (match.index ?? 0) + match[0].length;
228
+ let depth = 1;
229
+ let endIdx = startIdx;
230
+ while (endIdx < body.length && depth > 0) {
231
+ if (body[endIdx] === "(") depth++;
232
+ else if (body[endIdx] === ")") depth--;
233
+ if (depth > 0) endIdx++;
234
+ }
235
+
236
+ const argsStr = body.slice(startIdx, endIdx);
237
+ return splitTopLevel(argsStr);
238
+ }
239
+
240
+ function transpileEventRefs(argsStr: string): string {
241
+ // Transform `event.<prop>` → `ctx.event.<prop>` inside a resolver fn.
242
+ // The original handler has `event` as a parameter; inside the pipeline
243
+ // resolver, `event` lives on `ctx`, not as a standalone binding.
244
+ // `ctx` references stay as-is (the resolver also receives ctx).
245
+ return argsStr.replace(/\bevent\./g, "ctx.event.");
246
+ }
247
+
248
+ function transpileGuardRefs(body: string, varName: string): string {
249
+ // Transform `<var>` → `ctx.steps.result` in guard body text.
250
+ // The original handler references the executor result via
251
+ // `result.isSuccess`, `result.error`, `result.data`, or standalone
252
+ // `return result`. Inside the compute step, executor result lives at
253
+ // `ctx.steps.result`. `\b` word boundary ensures we only match the
254
+ // exact variable name.
255
+ return body.replace(new RegExp(`\\b${escapeRegex(varName)}\\b`, "g"), "ctx.steps.result");
256
+ }
257
+
258
+ function extractGuardedPattern(body: string, varName: string): GuardConfig | undefined {
259
+ const escaped = escapeRegex(varName);
260
+ // Match: if (!<varName>.isSuccess) { return ... }
261
+ const ifOpenPattern = new RegExp(`if\\s*\\(!${escaped}\\.isSuccess\\)\\s*\\{`);
262
+ const ifOpenMatch = body.match(ifOpenPattern);
263
+ if (!ifOpenMatch) return undefined;
264
+
265
+ // Extract condition
266
+ const condPattern = new RegExp(`if\\s*\\((!${escaped}\\.isSuccess)\\)`);
267
+ const condMatch = body.match(condPattern);
268
+ if (!condMatch) return undefined;
269
+ const condition = condMatch[1]!;
270
+
271
+ // Extract if body (between { and matching })
272
+ const ifBodyStart = (ifOpenMatch.index ?? 0) + ifOpenMatch[0].length;
273
+ let depth = 1;
274
+ let ifBodyEnd = ifBodyStart;
275
+ while (ifBodyEnd < body.length && depth > 0) {
276
+ if (body[ifBodyEnd] === "{") depth++;
277
+ else if (body[ifBodyEnd] === "}") depth--;
278
+ if (depth > 0) ifBodyEnd++;
279
+ }
280
+ const ifBodyContent = body.slice(ifBodyStart, ifBodyEnd).trim();
281
+ const failRetMatch = ifBodyContent.match(/return\s*([^;]+)\s*;/);
282
+ if (!failRetMatch) return undefined;
283
+ const failureReturn = failRetMatch[1]!.trim();
284
+
285
+ // Extract trailing return after the if block
286
+ const afterIf = body.slice(ifBodyEnd + 1).trim();
287
+ const succRetMatch = afterIf.match(/return\s*([^;]+)\s*;/);
288
+ if (!succRetMatch) return undefined;
289
+ const successReturn = succRetMatch[1]!.trim();
290
+
291
+ return { condition, failureReturn, successReturn };
292
+ }
293
+
294
+ export function generatePerformBlock(
295
+ analysis: ParsedHandlerInfo,
296
+ schemaType: string,
297
+ indent: string,
298
+ ): string | null {
299
+ const steps: string[] = [];
300
+
301
+ if (analysis.isStaticReturn) {
302
+ let body = analysis.handlerBodyText.trim();
303
+ if (body.startsWith("(") && body.endsWith(")")) {
304
+ body = body.slice(1, -1);
305
+ }
306
+ steps.push(`r.step.return((ctx) => (${body}))`);
307
+ } else if (
308
+ analysis.isSimpleExecutorCreate &&
309
+ analysis.executorName &&
310
+ analysis.executorCreateArgs
311
+ ) {
312
+ const dataArg = analysis.executorCreateArgs[0] ?? "{}";
313
+ steps.push(
314
+ `r.step.aggregate.create("result", { executor: ${analysis.executorName}, data: (ctx) => ${dataArg} })`,
315
+ );
316
+ steps.push(`r.step.return((ctx) => ({ isSuccess: true, data: ctx.steps.result }))`);
317
+ } else if (analysis.isSimpleExecutorUpdate && analysis.executorName) {
318
+ steps.push(
319
+ `r.step.aggregate.update("result", { executor: ${analysis.executorName}, id: event.payload.id, changes: event.payload.changes })`,
320
+ );
321
+ steps.push(`r.step.return((ctx) => ({ isSuccess: true, data: ctx.steps.result }))`);
322
+ } else if (
323
+ analysis.isExpressionBodyCreate &&
324
+ analysis.executorName &&
325
+ analysis.expressionBodyArgs
326
+ ) {
327
+ const dataArg = transpileEventRefs(analysis.expressionBodyArgs[0] ?? "{}");
328
+ steps.push(
329
+ `r.step.aggregate.create("result", { executor: ${analysis.executorName}, data: (ctx) => ${dataArg} })`,
330
+ );
331
+ steps.push(`r.step.return((ctx) => ({ isSuccess: true, data: ctx.steps.result }))`);
332
+ } else if (
333
+ analysis.isExpressionBodyUpdate &&
334
+ analysis.executorName &&
335
+ analysis.expressionBodyArgs
336
+ ) {
337
+ const argStr = transpileEventRefs(analysis.expressionBodyArgs[0] ?? "{}");
338
+ steps.push(`r.step.compute("result", (ctx) => ${analysis.executorName}.update(${argStr}))`);
339
+ steps.push(`r.step.return((ctx) => ({ isSuccess: true, data: ctx.steps.result }))`);
340
+ } else if (
341
+ analysis.isGuardedCreate &&
342
+ analysis.executorName &&
343
+ analysis.executorCreateArgs &&
344
+ analysis.guardConfig
345
+ ) {
346
+ const varName = analysis.executorCreateVar ?? "result";
347
+ const dataArg = analysis.executorCreateArgs[0] ?? "{}";
348
+ const guard = analysis.guardConfig;
349
+ const condition = guard.condition; // already references varName
350
+ const failureReturn = transpileGuardRefs(guard.failureReturn, varName);
351
+ const successReturn = transpileGuardRefs(guard.successReturn, varName);
352
+ // The condition references the original var name — transpile it too
353
+ const condTranspiled = transpileGuardRefs(condition, varName);
354
+ steps.push(
355
+ `r.step.aggregate.create("result", { executor: ${analysis.executorName}, data: (ctx) => ${dataArg} })`,
356
+ );
357
+ steps.push(`r.step.compute("outcome", (ctx) => {
358
+ if (${condTranspiled}) {
359
+ return ${failureReturn};
360
+ }
361
+ return ${successReturn};
362
+ })`);
363
+ steps.push(`r.step.return((ctx) => ctx.steps.outcome)`);
364
+ } else {
365
+ return null;
366
+ }
367
+
368
+ const stepIndent = indent;
369
+ const stepsStr = steps.map((s) => `${stepIndent} ${s}`).join(",\n");
370
+ const pipelineType = schemaType ? `<${schemaType}, unknown>` : "";
371
+
372
+ return [
373
+ `perform: pipeline${pipelineType}(({ event, r }) => [`,
374
+ stepsStr,
375
+ ` ${stepIndent}]),`,
376
+ ].join("\n");
377
+ }
378
+
379
+ function inferSchemaType(objLiteral: import("ts-morph").ObjectLiteralExpression): string {
380
+ const schemaProp = objLiteral.getProperty("schema");
381
+ if (!schemaProp) return "unknown";
382
+ const init = (schemaProp as import("ts-morph").PropertyAssignment).getInitializer();
383
+ if (!init) return "unknown";
384
+ const text = init.getText();
385
+ // Only return simple names (like `InvoiceSchema`), not inline definitions
386
+ if (/^[A-Za-z_$][\w$.]*$/.test(text)) {
387
+ return `typeof ${text}`;
388
+ }
389
+ return ""; // Empty = omit type params
390
+ }
391
+
392
+ function findFiles(rootDir: string): string[] {
393
+ const glob = new Glob("**/*.write.ts");
394
+ const files: string[] = [];
395
+ for (const file of glob.scanSync(rootDir)) {
396
+ files.push(join(rootDir, file));
397
+ }
398
+ return files.sort();
399
+ }
400
+
401
+ export function scanForCandidates(rootDir: string): FileAnalysis[] {
402
+ const files = findFiles(rootDir);
403
+ const results: FileAnalysis[] = [];
404
+
405
+ for (const file of files) {
406
+ const analysis = analyzeFile(file);
407
+ if (analysis) results.push(analysis);
408
+ }
409
+
410
+ return results;
411
+ }
412
+
413
+ export type FileAnalysis = {
414
+ readonly filePath: string;
415
+ readonly pattern: "free-form-write" | "pipeline-write" | "query-handler" | "other";
416
+ readonly convertible: boolean;
417
+ readonly reason: string;
418
+ readonly handlerLine?: number;
419
+ };
420
+
421
+ export function analyzeFile(filePath: string): FileAnalysis | null {
422
+ if (!existsSync(filePath)) return null;
423
+ try {
424
+ const content = readFileSync(filePath, "utf8");
425
+ if (!content.includes("defineWriteHandler") && !content.includes("defineQueryHandler")) {
426
+ return null;
427
+ }
428
+
429
+ if (content.includes("defineWriteHandler") && content.includes("perform:")) {
430
+ return {
431
+ filePath,
432
+ pattern: "pipeline-write",
433
+ convertible: false,
434
+ reason: "already_uses_pipeline_form",
435
+ };
436
+ }
437
+
438
+ if (content.includes("defineWriteHandler")) {
439
+ return {
440
+ filePath,
441
+ pattern: "free-form-write",
442
+ convertible: true,
443
+ reason: "free_form_write_handler",
444
+ };
445
+ }
446
+
447
+ if (content.includes("defineQueryHandler")) {
448
+ return {
449
+ filePath,
450
+ pattern: "query-handler",
451
+ convertible: false,
452
+ reason: "query_handlers_not_convertible",
453
+ };
454
+ }
455
+
456
+ return null;
457
+ } catch {
458
+ return null;
459
+ }
460
+ }
461
+
462
+ export async function runCodemod(
463
+ rootDir: string,
464
+ options: CodemodOptions = {},
465
+ ): Promise<CodemodReport> {
466
+ const project = new Project({
467
+ skipAddingFilesFromTsConfig: true,
468
+ skipFileDependencyResolution: true,
469
+ });
470
+
471
+ const candidates = scanForCandidates(rootDir);
472
+ const writeHandlers = candidates.filter((c) => c.pattern === "free-form-write");
473
+
474
+ console.log(`\n Scanning ${rootDir}...`);
475
+ console.log(` Found ${writeHandlers.length} file(s) with free-form write handlers.\n`);
476
+
477
+ if (writeHandlers.length === 0) {
478
+ return { results: [], converted: 0, skipped: 0, errors: 0 };
479
+ }
480
+
481
+ const results: CodemodResult[] = [];
482
+
483
+ for (const candidate of writeHandlers) {
484
+ const result = await convertFile(candidate.filePath, project, options);
485
+ results.push(result);
486
+ }
487
+
488
+ const converted = results.filter((r) => r.status === "converted").length;
489
+ const skipped = results.filter((r) => r.status === "skipped").length;
490
+ const errors = results.filter((r) => r.status === "error").length;
491
+
492
+ console.log(`\n Results: ${converted} converted, ${skipped} skipped, ${errors} errors\n`);
493
+
494
+ if (options.verbose) {
495
+ for (const r of results) {
496
+ if (r.status === "error") {
497
+ console.log(` ✗ ${relative(process.cwd(), r.filePath)}: ${r.reason}`);
498
+ } else if (r.status === "converted") {
499
+ console.log(` ✓ ${relative(process.cwd(), r.filePath)}`);
500
+ } else {
501
+ console.log(` - ${relative(process.cwd(), r.filePath)}: ${r.reason}`);
502
+ }
503
+ }
504
+ }
505
+
506
+ return { results, converted, skipped, errors };
507
+ }
508
+
509
+ function contentHasPipelineImport(content: string): boolean {
510
+ // Check the actual import line, not later usage of the word `pipeline`
511
+ // in the perform block. Single-line import matching only (standard
512
+ // formatting in this codebase — never multi-line).
513
+ const importLine = content
514
+ .split("\n")
515
+ .find((l) => l.includes("import") && l.includes("@cosmicdrift/kumiko-framework/engine"));
516
+ return !!importLine && importLine.includes("pipeline");
517
+ }
518
+
519
+ function ensurePipelineImport(content: string): string | null {
520
+ if (contentHasPipelineImport(content)) return null;
521
+ // Single-line regex is safe — imports in this codebase are always
522
+ // `import { ... } from "@cosmicdrift/kumiko-framework/engine"`.
523
+ const importRegex =
524
+ /import\s*\{([^}]*)\}\s*from\s*["']@cosmicdrift\/kumiko-framework\/engine["']/;
525
+ const match = content.match(importRegex);
526
+ if (match) {
527
+ const existingImports = (match[1] as string).trim();
528
+ const newImports = existingImports ? `${existingImports}, pipeline` : "pipeline";
529
+ return content.replace(
530
+ importRegex,
531
+ `import { ${newImports} } from "@cosmicdrift/kumiko-framework/engine"`,
532
+ );
533
+ }
534
+
535
+ return null;
536
+ }
537
+
538
+ export async function convertFile(
539
+ filePath: string,
540
+ project?: Project,
541
+ options: CodemodOptions = {},
542
+ ): Promise<CodemodResult> {
543
+ try {
544
+ const contentBefore = readFileSync(filePath, "utf8");
545
+ const proj =
546
+ project ??
547
+ new Project({
548
+ skipAddingFilesFromTsConfig: true,
549
+ skipFileDependencyResolution: true,
550
+ });
551
+ const sourceFile = proj.addSourceFileAtPath(filePath);
552
+
553
+ const handlerCalls = findDefineWriteHandlerCalls(sourceFile);
554
+ if (handlerCalls.length === 0) {
555
+ return { filePath, status: "skipped", reason: "no_define_write_handler_calls" };
556
+ }
557
+
558
+ let content = contentBefore;
559
+ let hadChanges = false;
560
+
561
+ for (const call of handlerCalls) {
562
+ const arg = call.getArguments()[0];
563
+ if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) {
564
+ continue;
565
+ }
566
+
567
+ const objLiteral = arg.asKind(SyntaxKind.ObjectLiteralExpression);
568
+ if (!objLiteral) continue;
569
+
570
+ const handlerProp = objLiteral.getProperty("handler");
571
+ if (!handlerProp) continue;
572
+
573
+ const propAssign = handlerProp.asKind(SyntaxKind.PropertyAssignment);
574
+ if (!propAssign) continue;
575
+
576
+ const handlerInit = propAssign.getInitializer();
577
+ if (!handlerInit) continue;
578
+
579
+ const analysis = analyzeHandlerArrow(handlerInit.getText());
580
+ if (
581
+ !analysis.isStaticReturn &&
582
+ !analysis.isSimpleExecutorCreate &&
583
+ !analysis.isSimpleExecutorUpdate &&
584
+ !analysis.isExpressionBodyCreate &&
585
+ !analysis.isExpressionBodyUpdate &&
586
+ !analysis.isGuardedCreate
587
+ ) {
588
+ if (options.verbose) {
589
+ console.log(` ~ ${relative(process.cwd(), filePath)}: non-trivial handler, skipping`);
590
+ }
591
+ continue;
592
+ }
593
+
594
+ const schemaType = inferSchemaType(objLiteral);
595
+ const indent = propAssign.getIndentationText() ?? " ";
596
+ const performBlock = generatePerformBlock(analysis, schemaType, indent);
597
+ if (!performBlock) continue;
598
+
599
+ const start = propAssign.getStart();
600
+ let end = propAssign.getEnd();
601
+ // Consume trailing comma after the property assignment
602
+ if (end < content.length && content[end] === ",") {
603
+ end++;
604
+ }
605
+ content = content.slice(0, start) + performBlock + content.slice(end);
606
+ hadChanges = true;
607
+ }
608
+
609
+ if (hadChanges) {
610
+ const importResult = ensurePipelineImport(content);
611
+ if (importResult) {
612
+ content = importResult;
613
+ } else if (options.verbose) {
614
+ console.log(` ~ ${filePath}: pipeline import not needed or already present`);
615
+ }
616
+ }
617
+
618
+ if (hadChanges && !options.dryRun) {
619
+ writeFileSync(filePath, content, "utf8");
620
+ }
621
+
622
+ const status = hadChanges ? "converted" : "skipped";
623
+ const reason = hadChanges
624
+ ? "handler replaced with perform: pipeline(...)"
625
+ : "no convertible handler";
626
+ return { filePath, status, reason };
627
+ } catch (err) {
628
+ return {
629
+ filePath,
630
+ status: "error",
631
+ reason: err instanceof Error ? err.message : String(err),
632
+ };
633
+ }
634
+ }
635
+
636
+ function findDefineWriteHandlerCalls(sourceFile: SourceFile): CallExpression[] {
637
+ return sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter((call) => {
638
+ const expr = call.getExpression();
639
+ return expr.getText() === "defineWriteHandler";
640
+ });
641
+ }
@@ -10,23 +10,13 @@ import type {
10
10
  // --- Access Presets ---
11
11
 
12
12
  export const access = {
13
- all: ["all"] as readonly string[],
14
- admin: ["Admin", "SystemAdmin"] as readonly string[],
15
- systemAdmin: ["SystemAdmin"] as readonly string[],
16
- system: ["system"] as readonly string[],
17
- // system + SystemAdmin use for field-access on identity columns that
18
- // framework auth code (SYSTEM_USER) writes during login/registration, but
19
- // that a SystemAdmin should also be able to fix manually.
20
- privileged: ["system", "SystemAdmin"] as readonly string[],
21
- // Any signed-in user role. Use on authenticated-but-not-privileged handlers
22
- // (change-password, logout, me-style queries). Does NOT include "system"
23
- // since an unauthenticated system call shouldn't be able to hit these.
24
- authenticated: ["User", "Admin", "SystemAdmin"] as readonly string[],
25
- // Unauthenticated callers reaching public endpoints (server must opt in
26
- // via `anonymousAccess`). Combine with authenticated roles when an
27
- // endpoint should serve both — e.g. `roles: ["anonymous", "customer"]`
28
- // for a product-listing that personalises when a session is present.
29
- anonymous: ["anonymous"] as readonly string[],
13
+ all: ["all"] as readonly string[], // @cast-boundary schema-walk
14
+ admin: ["Admin", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
15
+ systemAdmin: ["SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
16
+ system: ["system"] as readonly string[], // @cast-boundary schema-walk
17
+ privileged: ["system", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
18
+ authenticated: ["User", "Admin", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
19
+ anonymous: ["anonymous"] as readonly string[], // @cast-boundary schema-walk
30
20
  roles: (...roles: string[]): readonly string[] => roles,
31
21
  } as const;
32
22
 
@@ -63,7 +53,7 @@ const SCOPE_DEFAULTS: Record<ConfigScope, { write: readonly string[]; read: read
63
53
  tenant: { write: access.admin, read: access.all },
64
54
  system: { write: access.system, read: access.admin },
65
55
  user: { write: access.all, read: access.all },
66
- };
56
+ } satisfies Record<ConfigScope, { write: readonly string[]; read: readonly string[] }>;
67
57
 
68
58
  // --- Factory ---
69
59
 
@@ -83,7 +73,7 @@ function createConfigKey<T extends ConfigKeyType>(
83
73
  default: opts.default,
84
74
  ...(opts.encrypted ? { encrypted: true } : {}),
85
75
  ...(opts.options ? { options: opts.options } : {}),
86
- bounds: opts.bounds as ConfigBounds | undefined,
76
+ bounds: opts.bounds as ConfigBounds | undefined, // @cast-boundary schema-walk
87
77
  computed: opts.computed,
88
78
  ...(opts.allowPerRequest === true ? { allowPerRequest: true } : {}),
89
79
  };
@@ -1,4 +1,4 @@
1
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { TenantId } from "./types/identifiers";
2
2
 
3
3
  // All framework constants as `as const` objects with inferred union types.
4
4
  // No enums — only const objects + typeof inference.