@cosmicdrift/kumiko-framework 0.269.1 → 0.270.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 (99) hide show
  1. package/README.md +5 -1
  2. package/package.json +3 -3
  3. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  4. package/src/__tests__/error-contract.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-db-raw/global-fetch.expected.ts +9 -0
  6. package/src/__tests__/fixtures/migrate-db-raw/global-fetch.input.ts +10 -0
  7. package/src/__tests__/fixtures/migrate-db-raw/global-select.expected.ts +6 -0
  8. package/src/__tests__/fixtures/migrate-db-raw/global-select.input.ts +12 -0
  9. package/src/__tests__/fixtures/migrate-db-raw/import-pruning.expected.ts +12 -0
  10. package/src/__tests__/fixtures/migrate-db-raw/import-pruning.input.ts +12 -0
  11. package/src/__tests__/fixtures/migrate-db-raw/manual-only.expected.ts +26 -0
  12. package/src/__tests__/fixtures/migrate-db-raw/manual-only.input.ts +26 -0
  13. package/src/__tests__/fixtures/migrate-db-raw/own-tenant-const-shorthand.expected.ts +10 -0
  14. package/src/__tests__/fixtures/migrate-db-raw/own-tenant-const-shorthand.input.ts +11 -0
  15. package/src/__tests__/fixtures/migrate-db-raw/own-tenant-event.expected.ts +9 -0
  16. package/src/__tests__/fixtures/migrate-db-raw/own-tenant-event.input.ts +15 -0
  17. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
  18. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
  19. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
  20. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
  21. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
  22. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
  23. package/src/__tests__/full-stack.integration.test.ts +2 -2
  24. package/src/__tests__/migrate-db-raw-codemod.test.ts +132 -0
  25. package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
  26. package/src/__tests__/transition-guard.integration.test.ts +7 -7
  27. package/src/__tests__/upgrade-cli.test.ts +54 -0
  28. package/src/api/__tests__/api.test.ts +2 -2
  29. package/src/api/__tests__/body-limit.test.ts +1 -1
  30. package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
  31. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  32. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  33. package/src/changes.json +10 -2
  34. package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
  35. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
  36. package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
  37. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  38. package/src/engine/__tests__/boot-validator-projection-list.test.ts +17 -17
  39. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +18 -18
  40. package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
  41. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  42. package/src/engine/__tests__/boot-validator.test.ts +18 -18
  43. package/src/engine/__tests__/build-app-schema.test.ts +10 -6
  44. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
  45. package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
  46. package/src/engine/__tests__/engine.test.ts +39 -25
  47. package/src/engine/__tests__/entity-handlers.test.ts +19 -8
  48. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
  49. package/src/engine/__tests__/extends-registrar.test.ts +3 -3
  50. package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
  51. package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
  52. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  53. package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
  54. package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
  55. package/src/engine/__tests__/post-query-hook.test.ts +6 -2
  56. package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
  57. package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
  58. package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
  59. package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
  60. package/src/engine/__tests__/registry.test.ts +19 -13
  61. package/src/engine/__tests__/screen.test.ts +26 -20
  62. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
  63. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
  64. package/src/engine/boot-validator/access-declarations.ts +3 -5
  65. package/src/engine/boot-validator/nav.ts +4 -3
  66. package/src/engine/build-config-feature-schema.ts +9 -2
  67. package/src/engine/entity-handlers.ts +1 -1
  68. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
  69. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
  70. package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
  71. package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
  72. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
  73. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
  74. package/src/engine/feature-ast/extractors/handlers.ts +16 -2
  75. package/src/engine/feature-ast/extractors/hooks.ts +25 -4
  76. package/src/engine/feature-ast/patcher.ts +15 -3
  77. package/src/engine/feature-ast/patterns.ts +4 -0
  78. package/src/engine/feature-ast/render.ts +3 -0
  79. package/src/engine/pattern-library/shared-fields.ts +7 -3
  80. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  81. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
  82. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  83. package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
  84. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
  85. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
  86. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  87. package/src/observability/__tests__/observability.integration.test.ts +7 -7
  88. package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
  89. package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
  90. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
  91. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  92. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  93. package/src/pipeline/__tests__/query-projection.integration.test.ts +3 -3
  94. package/src/scripts/codemod/migrate-db-raw.ts +397 -0
  95. package/src/scripts/codemod/migrate-open-to-all.ts +248 -0
  96. package/src/secrets/__tests__/contains-secret.test.ts +5 -5
  97. package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
  98. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  99. package/src/testing/__tests__/e2e-generator.test.ts +19 -5
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env bun
2
+ // migrate-db-raw — rewrites standalone bun-db helper calls that manually pass
3
+ // ctx.db.raw to their TenantDb method-form equivalent (db.global(table)... /
4
+ // db.selectMany|fetchOne|insertOne(...)) wherever the rewrite is provably
5
+ // safe, and reports every remaining ctx.db.raw-ish access for manual review.
6
+ //
7
+ // Usage:
8
+ // bun scripts/codemod/migrate-db-raw.ts [--dry-run] [--global-tables a,b] <path...>
9
+
10
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { Node, Project, type SourceFile, SyntaxKind, VariableDeclarationKind } from "ts-morph";
13
+
14
+ const DEFAULT_GLOBAL_TABLES: ReadonlySet<string> = new Set([
15
+ "userTable",
16
+ "globalFeatureStateTable",
17
+ ]);
18
+ const PRUNABLE_FN_NAMES: ReadonlySet<string> = new Set(["selectMany", "fetchOne", "insertOne"]);
19
+ const OWN_TENANT_FN_NAMES: ReadonlySet<string> = new Set(["selectMany", "fetchOne", "insertOne"]);
20
+ const GLOBAL_TABLE_FN_NAMES: ReadonlySet<string> = new Set(["selectMany", "fetchOne"]);
21
+ const TENANT_ID_SOURCES: ReadonlySet<string> = new Set([
22
+ "event.user.tenantId",
23
+ "query.user.tenantId",
24
+ "ctx.user.tenantId",
25
+ ]);
26
+ const RAW_RECEIVER_IDENTIFIERS: ReadonlySet<string> = new Set([
27
+ "db",
28
+ "tdb",
29
+ "scopedDb",
30
+ "outsideTx",
31
+ ]);
32
+ const MANUAL_TEXT_LIMIT = 100;
33
+
34
+ export type RewriteRule = "global-table" | "own-tenant";
35
+
36
+ export interface Rewrite {
37
+ readonly file: string;
38
+ readonly line: number;
39
+ readonly rule: RewriteRule;
40
+ readonly before: string;
41
+ readonly after: string;
42
+ }
43
+
44
+ export interface ManualSite {
45
+ readonly file: string;
46
+ readonly line: number;
47
+ readonly enclosing: string;
48
+ readonly text: string;
49
+ }
50
+
51
+ export interface MigrateDbRawResult {
52
+ readonly output: string;
53
+ readonly rewrites: Rewrite[];
54
+ readonly manual: ManualSite[];
55
+ }
56
+
57
+ function isRawOnCtxDb(argExpr: Node): boolean {
58
+ return argExpr.getText() === "ctx.db.raw";
59
+ }
60
+
61
+ function enclosingFunctionScope(node: Node): Node {
62
+ return (
63
+ node.getFirstAncestor(
64
+ (n) =>
65
+ Node.isFunctionDeclaration(n) ||
66
+ Node.isArrowFunction(n) ||
67
+ Node.isFunctionExpression(n) ||
68
+ Node.isMethodDeclaration(n),
69
+ ) ?? node.getSourceFile()
70
+ );
71
+ }
72
+
73
+ function isConstTenantIdBinding(scope: Node, varName: string): boolean {
74
+ return scope.getDescendantsOfKind(SyntaxKind.VariableDeclaration).some((decl) => {
75
+ if (decl.getName() !== varName) return false;
76
+ const statement = decl.getVariableStatement();
77
+ if (!statement || statement.getDeclarationKind() !== VariableDeclarationKind.Const)
78
+ return false;
79
+ const initializer = decl.getInitializer();
80
+ return initializer !== undefined && TENANT_ID_SOURCES.has(initializer.getText());
81
+ });
82
+ }
83
+
84
+ function isValidOwnTenantIdValue(valueNode: Node): boolean {
85
+ if (TENANT_ID_SOURCES.has(valueNode.getText())) return true;
86
+ if (!Node.isIdentifier(valueNode)) return false;
87
+ return isConstTenantIdBinding(enclosingFunctionScope(valueNode), valueNode.getText());
88
+ }
89
+
90
+ function objectHasValidOwnTenantId(obj: Node): boolean {
91
+ if (!Node.isObjectLiteralExpression(obj)) return false;
92
+ for (const prop of obj.getProperties()) {
93
+ if (Node.isPropertyAssignment(prop) && prop.getName() === "tenantId") {
94
+ const init = prop.getInitializer();
95
+ return init !== undefined && isValidOwnTenantIdValue(init);
96
+ }
97
+ if (Node.isShorthandPropertyAssignment(prop) && prop.getName() === "tenantId") {
98
+ return isConstTenantIdBinding(enclosingFunctionScope(prop), "tenantId");
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+
104
+ interface PendingRewrite {
105
+ readonly callExpr: Node;
106
+ readonly rule: RewriteRule;
107
+ readonly before: string;
108
+ readonly after: string;
109
+ readonly line: number;
110
+ }
111
+
112
+ function planRewrite(
113
+ callExpr: Node,
114
+ globalTables: ReadonlySet<string>,
115
+ ): PendingRewrite | undefined {
116
+ if (!Node.isCallExpression(callExpr)) return undefined;
117
+ const calleeExpr = callExpr.getExpression();
118
+ if (!Node.isIdentifier(calleeExpr)) return undefined;
119
+ const fnName = calleeExpr.getText();
120
+ const args = callExpr.getArguments();
121
+ if (args.length === 0 || !isRawOnCtxDb(args[0]!)) return undefined;
122
+
123
+ const typeArgs = callExpr.getTypeArguments();
124
+ const typeArgsText =
125
+ typeArgs.length > 0 ? `<${typeArgs.map((t) => t.getText()).join(", ")}>` : "";
126
+
127
+ if (GLOBAL_TABLE_FN_NAMES.has(fnName) && args.length >= 2) {
128
+ const tableArg = args[1]!;
129
+ if (Node.isIdentifier(tableArg) && globalTables.has(tableArg.getText())) {
130
+ const rest = args.slice(2).map((a) => a.getText());
131
+ const after = `ctx.db.global(${tableArg.getText()}).${fnName}${typeArgsText}(${rest.join(", ")})`;
132
+ return {
133
+ callExpr,
134
+ rule: "global-table",
135
+ before: callExpr.getText(),
136
+ after,
137
+ line: callExpr.getStartLineNumber(),
138
+ };
139
+ }
140
+ }
141
+
142
+ if (OWN_TENANT_FN_NAMES.has(fnName) && args.length >= 3) {
143
+ const objArg = args[2]!;
144
+ if (objectHasValidOwnTenantId(objArg)) {
145
+ const rest = [args[1]!, objArg, ...args.slice(3)].map((a) => a.getText());
146
+ const after = `ctx.db.${fnName}${typeArgsText}(${rest.join(", ")})`;
147
+ return {
148
+ callExpr,
149
+ rule: "own-tenant",
150
+ before: callExpr.getText(),
151
+ after,
152
+ line: callExpr.getStartLineNumber(),
153
+ };
154
+ }
155
+ }
156
+
157
+ return undefined;
158
+ }
159
+
160
+ function isTenantDbRawAccess(pae: Node): boolean {
161
+ if (!Node.isPropertyAccessExpression(pae)) return false;
162
+ if (pae.getName() !== "raw") return false;
163
+ const receiver = pae.getExpression();
164
+ if (receiver.getText().endsWith(".db")) return true;
165
+ if (Node.isIdentifier(receiver) && RAW_RECEIVER_IDENTIFIERS.has(receiver.getText())) return true;
166
+ if (
167
+ Node.isCallExpression(receiver) &&
168
+ receiver.getExpression().getText().startsWith("ctx.systemDb.")
169
+ )
170
+ return true;
171
+ return false;
172
+ }
173
+
174
+ function enclosingName(node: Node): string {
175
+ let current: Node | undefined = node.getParent();
176
+ while (current) {
177
+ if (Node.isFunctionDeclaration(current) && current.getName()) return current.getName()!;
178
+ if (Node.isMethodDeclaration(current)) return current.getName();
179
+ if (Node.isPropertyAssignment(current)) return current.getName();
180
+ if (Node.isVariableDeclaration(current)) return current.getName();
181
+ current = current.getParent();
182
+ }
183
+ return "<module>";
184
+ }
185
+
186
+ function truncate(text: string, limit: number): string {
187
+ const collapsed = text.replace(/\s+/g, " ").trim();
188
+ return collapsed.length > limit ? `${collapsed.slice(0, limit)}…` : collapsed;
189
+ }
190
+
191
+ function directArgumentCall(pae: Node): Node | undefined {
192
+ const parent = pae.getParent();
193
+ if (parent && Node.isCallExpression(parent) && parent.getArguments().some((a) => a === pae)) {
194
+ return parent;
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ function collectManualSite(pae: Node, fileName: string): ManualSite {
200
+ const reportNode = directArgumentCall(pae) ?? pae;
201
+ return {
202
+ file: fileName,
203
+ line: reportNode.getStartLineNumber(),
204
+ enclosing: enclosingName(pae),
205
+ text: truncate(reportNode.getText(), MANUAL_TEXT_LIMIT),
206
+ };
207
+ }
208
+
209
+ function isPropertyAccessName(id: Node): boolean {
210
+ const parent = id.getParent();
211
+ return Node.isPropertyAccessExpression(parent) && parent.getNameNode() === id;
212
+ }
213
+
214
+ function pruneUnusedDbImports(sourceFile: SourceFile): void {
215
+ for (const importDecl of sourceFile.getImportDeclarations()) {
216
+ for (const spec of [...importDecl.getNamedImports()]) {
217
+ if (!PRUNABLE_FN_NAMES.has(spec.getName())) continue;
218
+ const localName = spec.getAliasNode()?.getText() ?? spec.getName();
219
+ const nameNode = spec.getAliasNode() ?? spec.getNameNode();
220
+ const stillUsed = sourceFile
221
+ .getDescendantsOfKind(SyntaxKind.Identifier)
222
+ .some((id) => id !== nameNode && id.getText() === localName && !isPropertyAccessName(id));
223
+ if (!stillUsed) spec.remove();
224
+ }
225
+ if (
226
+ importDecl.getNamedImports().length === 0 &&
227
+ !importDecl.getDefaultImport() &&
228
+ !importDecl.getNamespaceImport()
229
+ ) {
230
+ importDecl.remove();
231
+ }
232
+ }
233
+ }
234
+
235
+ export function migrateDbRawSource(
236
+ source: string,
237
+ fileName: string,
238
+ opts: { globalTables: ReadonlySet<string> },
239
+ ): MigrateDbRawResult {
240
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
241
+ const sourceFile = project.createSourceFile(fileName, source);
242
+
243
+ const pending: PendingRewrite[] = [];
244
+ for (const callExpr of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
245
+ const plan = planRewrite(callExpr, opts.globalTables);
246
+ if (plan) pending.push(plan);
247
+ }
248
+
249
+ const rewrites: Rewrite[] = [];
250
+ for (const plan of pending) {
251
+ if (plan.callExpr.wasForgotten()) continue;
252
+ plan.callExpr.replaceWithText(plan.after);
253
+ rewrites.push({
254
+ file: fileName,
255
+ line: plan.line,
256
+ rule: plan.rule,
257
+ before: plan.before,
258
+ after: plan.after,
259
+ });
260
+ }
261
+
262
+ pruneUnusedDbImports(sourceFile);
263
+
264
+ const manual: ManualSite[] = [];
265
+ for (const pae of sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
266
+ if (isTenantDbRawAccess(pae)) manual.push(collectManualSite(pae, fileName));
267
+ }
268
+
269
+ return { output: sourceFile.getFullText(), rewrites, manual };
270
+ }
271
+
272
+ class CliError extends Error {}
273
+
274
+ interface CliOptions {
275
+ readonly dryRun: boolean;
276
+ readonly globalTables: ReadonlySet<string>;
277
+ readonly paths: string[];
278
+ }
279
+
280
+ function parseArgs(argv: string[]): CliOptions {
281
+ let dryRun = false;
282
+ let globalTables: ReadonlySet<string> = DEFAULT_GLOBAL_TABLES;
283
+ const paths: string[] = [];
284
+
285
+ for (let i = 0; i < argv.length; i++) {
286
+ const arg = argv[i]!;
287
+ if (arg === "--dry-run") {
288
+ dryRun = true;
289
+ continue;
290
+ }
291
+ if (arg === "--global-tables") {
292
+ const value = argv[++i];
293
+ if (!value) throw new CliError("--global-tables requires a comma-separated value");
294
+ globalTables = new Set(
295
+ value
296
+ .split(",")
297
+ .map((s) => s.trim())
298
+ .filter((s) => s.length > 0),
299
+ );
300
+ continue;
301
+ }
302
+ if (arg.startsWith("--")) throw new CliError(`unknown flag: ${arg}`);
303
+ paths.push(arg);
304
+ }
305
+
306
+ if (paths.length === 0) throw new CliError("at least one path is required");
307
+ return { dryRun, globalTables, paths };
308
+ }
309
+
310
+ function isMigratableFile(name: string): boolean {
311
+ if (!/\.(ts|tsx)$/.test(name)) return false;
312
+ if (name.endsWith(".test.ts") || name.endsWith(".test.tsx")) return false;
313
+ return true;
314
+ }
315
+
316
+ function walkDir(dir: string, out: string[]): void {
317
+ for (const name of readdirSync(dir)) {
318
+ if (name === "node_modules" || name === "__tests__") continue;
319
+ const full = join(dir, name);
320
+ const stat = statSync(full);
321
+ if (stat.isDirectory()) {
322
+ walkDir(full, out);
323
+ continue;
324
+ }
325
+ if (isMigratableFile(name)) out.push(full);
326
+ }
327
+ }
328
+
329
+ function collectFiles(paths: string[]): string[] {
330
+ const files: string[] = [];
331
+ for (const p of paths) {
332
+ const abs = resolve(p);
333
+ const stat = statSync(abs);
334
+ if (stat.isDirectory()) walkDir(abs, files);
335
+ else if (isMigratableFile(abs)) files.push(abs);
336
+ }
337
+ return files;
338
+ }
339
+
340
+ async function main(): Promise<void> {
341
+ let options: CliOptions;
342
+ try {
343
+ options = parseArgs(process.argv.slice(2));
344
+ } catch (err) {
345
+ if (err instanceof CliError) {
346
+ console.error(`[migrate-db-raw] ${err.message}`);
347
+ process.exit(2);
348
+ }
349
+ throw err;
350
+ }
351
+
352
+ const files = collectFiles(options.paths);
353
+ const allRewrites: Rewrite[] = [];
354
+ const allManual: ManualSite[] = [];
355
+ let filesChanged = 0;
356
+
357
+ for (const file of files) {
358
+ const before = readFileSync(file, "utf8");
359
+ const result = migrateDbRawSource(before, file, { globalTables: options.globalTables });
360
+ allRewrites.push(...result.rewrites);
361
+ allManual.push(...result.manual);
362
+
363
+ if (result.rewrites.length === 0) continue;
364
+ filesChanged += 1;
365
+
366
+ if (options.dryRun) {
367
+ console.log(`\n=== ${file} (${result.rewrites.length} rewrite(s)) ===`);
368
+ for (const r of result.rewrites) {
369
+ console.log(`${file}:${r.line} [${r.rule}]`);
370
+ console.log(`- ${r.before}`);
371
+ console.log(`+ ${r.after}`);
372
+ }
373
+ } else {
374
+ writeFileSync(file, result.output);
375
+ }
376
+ }
377
+
378
+ const globalCount = allRewrites.filter((r) => r.rule === "global-table").length;
379
+ const ownTenantCount = allRewrites.filter((r) => r.rule === "own-tenant").length;
380
+ console.log(
381
+ `\n[migrate-db-raw] ${options.dryRun ? "would change" : "changed"} ${filesChanged} file(s); ` +
382
+ `rewrites: global-table=${globalCount}, own-tenant=${ownTenantCount}`,
383
+ );
384
+
385
+ if (allManual.length > 0) {
386
+ console.log(`\n[migrate-db-raw] manual review needed (${allManual.length}):`);
387
+ for (const m of allManual) {
388
+ console.log(`${m.file}:${m.line} ${m.enclosing} ${m.text}`);
389
+ }
390
+ }
391
+
392
+ process.exit(0);
393
+ }
394
+
395
+ if (import.meta.main) {
396
+ await main();
397
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bun
2
+ // migrate-open-to-all — rewrites deprecated `openToAll: true` to the required
3
+ // `{ reason }` form in test files, and reports every remaining non-test site
4
+ // for a human to pick a reason (fw#2858).
5
+ //
6
+ // Usage:
7
+ // bun scripts/codemod/migrate-open-to-all.ts [--dry-run] [--test-reason "<text>"] <path...>
8
+
9
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import {
12
+ Node,
13
+ type ObjectLiteralExpression,
14
+ Project,
15
+ type PropertyAssignment,
16
+ SyntaxKind,
17
+ } from "ts-morph";
18
+
19
+ const DEFAULT_TEST_REASON = "test handler callable by any signed-in test user";
20
+
21
+ export interface Rewrite {
22
+ readonly file: string;
23
+ readonly line: number;
24
+ readonly before: string;
25
+ readonly after: string;
26
+ }
27
+
28
+ export interface ManualSite {
29
+ readonly file: string;
30
+ readonly line: number;
31
+ readonly handlerName?: string;
32
+ readonly description?: string;
33
+ }
34
+
35
+ export interface MigrateOpenToAllResult {
36
+ readonly output: string;
37
+ readonly rewrites: Rewrite[];
38
+ readonly manual: ManualSite[];
39
+ }
40
+
41
+ export interface MigrateOpenToAllOptions {
42
+ readonly testReason: string;
43
+ }
44
+
45
+ function isTestFile(fileName: string): boolean {
46
+ return /(^|\/)__tests__\//.test(fileName) || /\.test\.(ts|tsx)$/.test(fileName);
47
+ }
48
+
49
+ function isOpenToAllTrueAssignment(node: Node): node is PropertyAssignment {
50
+ if (!Node.isPropertyAssignment(node)) return false;
51
+ if (node.getName() !== "openToAll") return false;
52
+ return node.getInitializer()?.getKind() === SyntaxKind.TrueKeyword;
53
+ }
54
+
55
+ function readStringLiteralProperty(obj: ObjectLiteralExpression, name: string): string | undefined {
56
+ const prop = obj.getProperty(name);
57
+ if (!prop || !Node.isPropertyAssignment(prop)) return undefined;
58
+ const init = prop.getInitializer();
59
+ return init && Node.isStringLiteral(init) ? init.getLiteralValue() : undefined;
60
+ }
61
+
62
+ function enclosingObjectLiterals(node: Node): ObjectLiteralExpression[] {
63
+ const result: ObjectLiteralExpression[] = [];
64
+ let current: Node | undefined = node.getParent();
65
+ while (current) {
66
+ if (Node.isObjectLiteralExpression(current)) result.push(current);
67
+ current = current.getParent();
68
+ }
69
+ return result;
70
+ }
71
+
72
+ // Best-effort human-triage hint (not the rewrite's source of truth): derive a handler name/description from the enclosing object literal, or the enclosing call's first string-literal argument for the positional form.
73
+ function deriveHandlerContext(assignment: PropertyAssignment): {
74
+ readonly handlerName?: string;
75
+ readonly description?: string;
76
+ } {
77
+ let handlerName: string | undefined;
78
+ let description: string | undefined;
79
+ for (const obj of enclosingObjectLiterals(assignment)) {
80
+ if (handlerName === undefined) handlerName = readStringLiteralProperty(obj, "name");
81
+ if (description === undefined) description = readStringLiteralProperty(obj, "description");
82
+ if (handlerName !== undefined && description !== undefined) break;
83
+ }
84
+ if (handlerName === undefined) {
85
+ const call = assignment.getFirstAncestorByKind(SyntaxKind.CallExpression);
86
+ const firstArg = call?.getArguments()[0];
87
+ if (firstArg && Node.isStringLiteral(firstArg)) handlerName = firstArg.getLiteralValue();
88
+ }
89
+ return {
90
+ ...(handlerName !== undefined && { handlerName }),
91
+ ...(description !== undefined && { description }),
92
+ };
93
+ }
94
+
95
+ export function migrateOpenToAllSource(
96
+ source: string,
97
+ fileName: string,
98
+ opts: MigrateOpenToAllOptions,
99
+ ): MigrateOpenToAllResult {
100
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
101
+ const sourceFile = project.createSourceFile(fileName, source);
102
+ const testFile = isTestFile(fileName);
103
+
104
+ const targets = sourceFile
105
+ .getDescendantsOfKind(SyntaxKind.PropertyAssignment)
106
+ .filter(isOpenToAllTrueAssignment);
107
+
108
+ const rewrites: Rewrite[] = [];
109
+ const manual: ManualSite[] = [];
110
+
111
+ for (const assignment of targets) {
112
+ if (assignment.wasForgotten()) continue;
113
+ const line = assignment.getStartLineNumber();
114
+
115
+ if (testFile) {
116
+ const before = assignment.getText();
117
+ const after = `openToAll: { reason: ${JSON.stringify(opts.testReason)} }`;
118
+ assignment.replaceWithText(after);
119
+ rewrites.push({ file: fileName, line, before, after });
120
+ continue;
121
+ }
122
+
123
+ manual.push({ file: fileName, line, ...deriveHandlerContext(assignment) });
124
+ }
125
+
126
+ return { output: sourceFile.getFullText(), rewrites, manual };
127
+ }
128
+
129
+ class CliError extends Error {}
130
+
131
+ interface CliOptions {
132
+ readonly dryRun: boolean;
133
+ readonly testReason: string;
134
+ readonly paths: string[];
135
+ }
136
+
137
+ function parseArgs(argv: string[]): CliOptions {
138
+ let dryRun = false;
139
+ let testReason = DEFAULT_TEST_REASON;
140
+ const paths: string[] = [];
141
+
142
+ for (let i = 0; i < argv.length; i++) {
143
+ const arg = argv[i]!;
144
+ if (arg === "--dry-run") {
145
+ dryRun = true;
146
+ continue;
147
+ }
148
+ if (arg === "--test-reason") {
149
+ const value = argv[++i];
150
+ if (!value) throw new CliError("--test-reason requires a value");
151
+ testReason = value;
152
+ continue;
153
+ }
154
+ if (arg.startsWith("--")) throw new CliError(`unknown flag: ${arg}`);
155
+ paths.push(arg);
156
+ }
157
+
158
+ if (paths.length === 0) throw new CliError("at least one path is required");
159
+ return { dryRun, testReason, paths };
160
+ }
161
+
162
+ function isMigratableFile(name: string): boolean {
163
+ return /\.(ts|tsx)$/.test(name);
164
+ }
165
+
166
+ function walkDir(dir: string, out: string[]): void {
167
+ for (const name of readdirSync(dir)) {
168
+ if (name === "node_modules") continue;
169
+ const full = join(dir, name);
170
+ const stat = statSync(full);
171
+ if (stat.isDirectory()) {
172
+ walkDir(full, out);
173
+ continue;
174
+ }
175
+ if (isMigratableFile(name)) out.push(full);
176
+ }
177
+ }
178
+
179
+ function collectFiles(paths: string[]): string[] {
180
+ const files: string[] = [];
181
+ for (const p of paths) {
182
+ const abs = resolve(p);
183
+ const stat = statSync(abs);
184
+ if (stat.isDirectory()) walkDir(abs, files);
185
+ else if (isMigratableFile(abs)) files.push(abs);
186
+ }
187
+ return files;
188
+ }
189
+
190
+ async function main(): Promise<void> {
191
+ let options: CliOptions;
192
+ try {
193
+ options = parseArgs(process.argv.slice(2));
194
+ } catch (err) {
195
+ if (err instanceof CliError) {
196
+ console.error(`[migrate-open-to-all] ${err.message}`);
197
+ process.exit(2);
198
+ }
199
+ throw err;
200
+ }
201
+
202
+ const files = collectFiles(options.paths);
203
+ const allManual: ManualSite[] = [];
204
+ let filesChanged = 0;
205
+ let totalRewrites = 0;
206
+
207
+ for (const file of files) {
208
+ const before = readFileSync(file, "utf8");
209
+ if (!before.includes("openToAll")) continue;
210
+ const result = migrateOpenToAllSource(before, file, { testReason: options.testReason });
211
+ allManual.push(...result.manual);
212
+
213
+ if (result.rewrites.length === 0) continue;
214
+ filesChanged += 1;
215
+ totalRewrites += result.rewrites.length;
216
+
217
+ if (options.dryRun) {
218
+ console.log(`\n=== ${file} (${result.rewrites.length} rewrite(s)) ===`);
219
+ for (const r of result.rewrites) {
220
+ console.log(`${file}:${r.line}`);
221
+ console.log(`- ${r.before}`);
222
+ console.log(`+ ${r.after}`);
223
+ }
224
+ } else {
225
+ writeFileSync(file, result.output);
226
+ }
227
+ }
228
+
229
+ console.log(
230
+ `\n[migrate-open-to-all] ${options.dryRun ? "would change" : "changed"} ${filesChanged} file(s); ` +
231
+ `rewrites: ${totalRewrites}`,
232
+ );
233
+
234
+ if (allManual.length > 0) {
235
+ console.log(`\n[migrate-open-to-all] manual review needed (${allManual.length}):`);
236
+ for (const m of allManual) {
237
+ console.log(
238
+ `${m.file}:${m.line} ${m.handlerName ?? "<unknown>"} ${m.description ?? "<no description>"}`,
239
+ );
240
+ }
241
+ }
242
+
243
+ process.exit(0);
244
+ }
245
+
246
+ if (import.meta.main) {
247
+ await main();
248
+ }
@@ -54,7 +54,7 @@ declare const aSecret: Secret<string>;
54
54
  defineQueryHandler({
55
55
  name: "t:query:leak",
56
56
  schema,
57
- access: { openToAll: true },
57
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
58
58
  handler: async () => ({ apiKey: aSecret }),
59
59
  });
60
60
 
@@ -65,7 +65,7 @@ function genericResponseHandler<K extends string>(kind: K) {
65
65
  return defineQueryHandler({
66
66
  name: "t:query:generic",
67
67
  schema,
68
- access: { openToAll: true },
68
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
69
69
  handler: async () => ({ kind, ok: true }) as { kind: K; ok: boolean },
70
70
  });
71
71
  }
@@ -83,7 +83,7 @@ declare const leakingUnion: { ok: true } | { apiKey: Secret<string> };
83
83
  defineQueryHandler({
84
84
  name: "t:query:union-leak",
85
85
  schema,
86
- access: { openToAll: true },
86
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
87
87
  handler: async () => leakingUnion,
88
88
  });
89
89
 
@@ -91,7 +91,7 @@ declare const cleanUnion: { ok: true } | { id: string };
91
91
  defineQueryHandler({
92
92
  name: "t:query:union-clean",
93
93
  schema,
94
- access: { openToAll: true },
94
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
95
95
  handler: async () => cleanUnion,
96
96
  });
97
97
 
@@ -100,7 +100,7 @@ describe("R6 ContainsSecret", () => {
100
100
  const def = defineQueryHandler({
101
101
  name: "t:query:clean",
102
102
  schema,
103
- access: { openToAll: true },
103
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
104
104
  handler: async () => ({ ok: true, value: createSecret("x").reveal() }),
105
105
  });
106
106
  expect(def.name).toBe("t:query:clean");