@cosmicdrift/kumiko-framework 0.367.0 → 0.369.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 (240) hide show
  1. package/AGENTS.md +1 -7
  2. package/README.md +1 -1
  3. package/agents/examples/access-control.md +2 -2
  4. package/agents/examples/anonymous-access-multitenant.md +2 -2
  5. package/agents/examples/anonymous-access.md +3 -3
  6. package/agents/examples/apex-landing.md +1 -1
  7. package/agents/examples/apex-surface-auth.md +6 -7
  8. package/agents/examples/api-worker-split.md +7 -8
  9. package/agents/examples/auth-claims.md +2 -1
  10. package/agents/examples/auth-foundation-providers.md +3 -3
  11. package/agents/examples/auth-mfa.md +3 -4
  12. package/agents/examples/basic-entity.md +3 -4
  13. package/agents/examples/chat-channels.md +4 -4
  14. package/agents/examples/compliance-profiles-demo.md +2 -3
  15. package/agents/examples/cross-feature-events.md +4 -4
  16. package/agents/examples/crypto-shredding-hr.md +14 -9
  17. package/agents/examples/currencies-global.md +3 -4
  18. package/agents/examples/currencies-per-tenant.md +3 -4
  19. package/agents/examples/custom-fields-basic.md +2 -3
  20. package/agents/examples/custom-handlers.md +3 -5
  21. package/agents/examples/dashboard-stats.md +2 -3
  22. package/agents/examples/delivery-notifications.md +5 -6
  23. package/agents/examples/e2e-generator.md +1 -1
  24. package/agents/examples/embedded-entity-form.md +2 -3
  25. package/agents/examples/embedded.md +2 -3
  26. package/agents/examples/encrypted-tenant-config.md +5 -6
  27. package/agents/examples/error-contract.md +3 -4
  28. package/agents/examples/event-sourcing.md +19 -23
  29. package/agents/examples/feature-toggles.md +4 -4
  30. package/agents/examples/field-access.md +3 -4
  31. package/agents/examples/files-post-processing.md +12 -13
  32. package/agents/examples/golden-path.md +208 -0
  33. package/agents/examples/idempotency.md +2 -2
  34. package/agents/examples/inbound-mail-triage.md +7 -7
  35. package/agents/examples/lane-routing.md +3 -3
  36. package/agents/examples/ledger-banking.md +2 -3
  37. package/agents/examples/ledger-invoicing.md +2 -3
  38. package/agents/examples/legal-pages.md +1 -1
  39. package/agents/examples/lifecycle-hooks.md +3 -3
  40. package/agents/examples/managed-config.md +3 -4
  41. package/agents/examples/managed-pages.md +2 -2
  42. package/agents/examples/notes-history-basic.md +4 -5
  43. package/agents/examples/ownership.md +3 -5
  44. package/agents/examples/pdf-audit-export.md +2 -3
  45. package/agents/examples/pipeline-basics.md +2 -2
  46. package/agents/examples/public-share-token.md +3 -3
  47. package/agents/examples/rate-limiting.md +2 -1
  48. package/agents/examples/realtime-sse.md +2 -3
  49. package/agents/examples/record-detail-layout.md +1 -1
  50. package/agents/examples/reference-data.md +3 -3
  51. package/agents/examples/relations.md +2 -2
  52. package/agents/examples/screens-nav.md +1 -1
  53. package/agents/examples/search.md +2 -2
  54. package/agents/examples/secrets-demo.md +8 -8
  55. package/agents/examples/security-baseline.md +2 -2
  56. package/agents/examples/session-revocation.md +7 -8
  57. package/agents/examples/state-machine.md +181 -165
  58. package/agents/examples/tags-basic.md +2 -3
  59. package/agents/examples/tenant-isolation.md +2 -3
  60. package/agents/examples/tenant-settings.md +2 -2
  61. package/agents/examples/tier-admin.md +3 -4
  62. package/agents/examples/timezones.md +3 -3
  63. package/agents/examples/user-data-rights.md +12 -8
  64. package/agents/examples/user-profile.md +3 -3
  65. package/agents/examples/webhook-step.md +2 -3
  66. package/agents/examples/wizard-form.md +4 -5
  67. package/agents/examples/workflow-engine.md +7 -3
  68. package/agents/examples/writeform-section.md +1 -1
  69. package/agents/examples.md +1 -0
  70. package/agents/features.md +4 -4
  71. package/dist/consumer-cli.js +2 -3
  72. package/dist/crypto/index.d.ts +1 -1
  73. package/dist/crypto/index.js +1 -1
  74. package/dist/crypto/owner-transition-rekey.js +2 -0
  75. package/dist/crypto/pg-kms-adapter.js +1 -1
  76. package/dist/crypto/subject-resolver.d.ts +2 -0
  77. package/dist/crypto/subject-resolver.js +30 -15
  78. package/dist/db/bun-db-deprecated.d.ts +10 -0
  79. package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
  80. package/dist/db/config-seed.js +1 -1
  81. package/dist/db/connection.d.ts +23 -5
  82. package/dist/db/connection.js +10 -5
  83. package/dist/db/dialect.js +1 -1
  84. package/dist/db/entity-field-encryption.d.ts +5 -0
  85. package/dist/db/entity-field-encryption.js +29 -0
  86. package/dist/db/entity-table-meta.js +6 -5
  87. package/dist/db/event-store-executor-read.js +26 -5
  88. package/dist/db/index.d.ts +4 -4
  89. package/dist/db/index.js +1 -1
  90. package/dist/db/migrate-runner.js +1 -1
  91. package/dist/db/queries/backfill-pii.js +16 -9
  92. package/dist/db/queries/ddl.js +1 -1
  93. package/dist/db/queries/event-store.d.ts +2 -0
  94. package/dist/db/queries/event-store.js +26 -0
  95. package/dist/db/queries/test-stack.js +1 -1
  96. package/dist/db/query.d.ts +105 -1
  97. package/dist/db/query.js +1450 -3
  98. package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
  99. package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
  100. package/dist/db/tenant-db.d.ts +11 -0
  101. package/dist/db/tenant-db.js +51 -12
  102. package/dist/derivatives/derivatives-context.d.ts +1 -1
  103. package/dist/derivatives/derivatives-context.js +1 -1
  104. package/dist/engine/boot-validator/gdpr-storage.js +3 -3
  105. package/dist/engine/boot-validator/pii-retention.js +3 -3
  106. package/dist/engine/boot-validator/record-owned.js +3 -3
  107. package/dist/engine/boot-validator/screens.js +37 -1
  108. package/dist/engine/bridge-stub.d.ts +4 -0
  109. package/dist/engine/bridge-stub.js +62 -0
  110. package/dist/engine/build-config-feature-schema.js +197 -65
  111. package/dist/engine/event-helpers.d.ts +2 -1
  112. package/dist/engine/event-helpers.js +1 -0
  113. package/dist/engine/extension-selector-plugins.d.ts +3 -0
  114. package/dist/engine/extension-selector-plugins.js +14 -0
  115. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  116. package/dist/engine/feature-config-events-jobs.js +3 -2
  117. package/dist/engine/index.d.ts +1 -0
  118. package/dist/engine/index.js +1 -0
  119. package/dist/es-ops/operations-schema.js +1 -1
  120. package/dist/event-store/admin-api.js +1 -1
  121. package/dist/event-store/archive.js +1 -1
  122. package/dist/event-store/errors.d.ts +2 -1
  123. package/dist/event-store/errors.js +6 -2
  124. package/dist/event-store/event-store.js +17 -9
  125. package/dist/event-store/events-schema.js +1 -1
  126. package/dist/event-store/rebuild-dead-letter.js +3 -3
  127. package/dist/event-store/snapshot.js +1 -1
  128. package/dist/event-store/upcaster-dead-letter.js +3 -3
  129. package/dist/files/create-file-ref-from-bytes.js +1 -1
  130. package/dist/files/file-routes.js +1 -1
  131. package/dist/i18n/settings-hub-keys.js +1 -2
  132. package/dist/internal/stack-support.d.ts +12 -0
  133. package/dist/internal/stack-support.js +12 -0
  134. package/dist/internal/testing.d.ts +4 -0
  135. package/dist/internal/testing.js +5 -0
  136. package/dist/jobs/job-runner.js +23 -11
  137. package/dist/migrations/pending-rebuilds.js +1 -1
  138. package/dist/migrations/pii-event-backfill.js +2 -2
  139. package/dist/pipeline/dispatch-shared.js +43 -23
  140. package/dist/pipeline/dispatch-write.js +6 -2
  141. package/dist/pipeline/dispatcher-utils.js +6 -2
  142. package/dist/pipeline/event-consumer-state.js +1 -1
  143. package/dist/pipeline/projection-rebuild.d.ts +1 -0
  144. package/dist/pipeline/projection-rebuild.js +1 -1
  145. package/dist/pipeline/projection-state.js +1 -1
  146. package/dist/pipeline/system-hooks.js +1 -1
  147. package/dist/schema-cli.js +3 -3
  148. package/dist/search/purge-subject.js +20 -10
  149. package/dist/seeding/index.d.ts +1 -0
  150. package/dist/seeding/index.js +1 -0
  151. package/dist/stack/index.d.ts +10 -9
  152. package/dist/stack/index.js +11 -19
  153. package/dist/testing/e2e-generator.d.ts +5 -49
  154. package/dist/testing/e2e-generator.js +5 -382
  155. package/dist/testing/handler-context.d.ts +5 -4
  156. package/dist/testing/handler-context.js +5 -69
  157. package/dist/testing/index.d.ts +10 -26
  158. package/dist/testing/index.js +10 -34
  159. package/dist/testing/real-providers.d.ts +5 -3
  160. package/dist/testing/real-providers.js +5 -21
  161. package/dist/upgrade-cli.js +6 -4
  162. package/package.json +37 -9
  163. package/src/changes.json +64 -0
  164. package/src/scripts/codemod/README.md +43 -0
  165. package/src/scripts/codemod/codemod-support.ts +276 -0
  166. package/src/scripts/codemod/db-factory.ts +810 -0
  167. package/src/scripts/codemod/feature-factory.ts +429 -0
  168. package/src/scripts/codemod/s3-imports.ts +274 -0
  169. package/src/scripts/codemod/tenant-db-options.ts +454 -0
  170. package/src/scripts/codemod/testing-move-targets.ts +161 -0
  171. package/src/scripts/codemod/testing-move.ts +520 -0
  172. package/dist/bun-db/connection.d.ts +0 -17
  173. package/dist/bun-db/connection.js +0 -40
  174. package/dist/bun-db/index.d.ts +0 -4
  175. package/dist/bun-db/query.d.ts +0 -106
  176. package/dist/bun-db/query.js +0 -1457
  177. package/dist/db/api.d.ts +0 -19
  178. package/dist/db/api.js +0 -23
  179. package/dist/db/bun-provider.d.ts +0 -2
  180. package/dist/db/bun-provider.js +0 -28
  181. package/dist/db/postgres-provider.d.ts +0 -2
  182. package/dist/db/postgres-provider.js +0 -21
  183. package/dist/db/query-api.d.ts +0 -2
  184. package/dist/db/query-api.js +0 -6
  185. package/dist/stack/db.d.ts +0 -22
  186. package/dist/stack/db.js +0 -67
  187. package/dist/stack/drain-event-consumers.d.ts +0 -4
  188. package/dist/stack/drain-event-consumers.js +0 -51
  189. package/dist/stack/drain-jobs.d.ts +0 -22
  190. package/dist/stack/drain-jobs.js +0 -141
  191. package/dist/stack/event-collector.d.ts +0 -9
  192. package/dist/stack/event-collector.js +0 -12
  193. package/dist/stack/push-entity-projection-tables.d.ts +0 -24
  194. package/dist/stack/push-entity-projection-tables.js +0 -74
  195. package/dist/stack/redis.d.ts +0 -17
  196. package/dist/stack/redis.js +0 -52
  197. package/dist/stack/request-helper.d.ts +0 -35
  198. package/dist/stack/request-helper.js +0 -144
  199. package/dist/stack/test-stack.d.ts +0 -192
  200. package/dist/stack/test-stack.js +0 -429
  201. package/dist/testing/access-assertions.d.ts +0 -2
  202. package/dist/testing/access-assertions.js +0 -13
  203. package/dist/testing/assertions.d.ts +0 -10
  204. package/dist/testing/assertions.js +0 -22
  205. package/dist/testing/boot-validator-fixture.d.ts +0 -3
  206. package/dist/testing/boot-validator-fixture.js +0 -101
  207. package/dist/testing/closed-connection-error.d.ts +0 -7
  208. package/dist/testing/closed-connection-error.js +0 -46
  209. package/dist/testing/db-cleanup.d.ts +0 -9
  210. package/dist/testing/db-cleanup.js +0 -31
  211. package/dist/testing/expect-error.d.ts +0 -2
  212. package/dist/testing/expect-error.js +0 -22
  213. package/dist/testing/file-provider-contract.d.ts +0 -2
  214. package/dist/testing/file-provider-contract.js +0 -102
  215. package/dist/testing/http-cookies.d.ts +0 -7
  216. package/dist/testing/http-cookies.js +0 -47
  217. package/dist/testing/late-bound.d.ts +0 -9
  218. package/dist/testing/late-bound.js +0 -31
  219. package/dist/testing/manual-clock.d.ts +0 -14
  220. package/dist/testing/manual-clock.js +0 -12
  221. package/dist/testing/multipart-helper.d.ts +0 -22
  222. package/dist/testing/multipart-helper.js +0 -77
  223. package/dist/testing/mutable-master-key-provider.d.ts +0 -7
  224. package/dist/testing/mutable-master-key-provider.js +0 -35
  225. package/dist/testing/observability-recorder.d.ts +0 -9
  226. package/dist/testing/observability-recorder.js +0 -29
  227. package/dist/testing/seed.d.ts +0 -9
  228. package/dist/testing/seed.js +0 -21
  229. package/dist/testing/shared-entities.d.ts +0 -286
  230. package/dist/testing/shared-entities.js +0 -56
  231. package/dist/testing/utils.d.ts +0 -1
  232. package/dist/testing/utils.js +0 -1
  233. package/dist/testing/wait-for.d.ts +0 -20
  234. package/dist/testing/wait-for.js +0 -47
  235. package/dist/testing/without-ambient-temporal.d.ts +0 -5
  236. package/dist/testing/without-ambient-temporal.js +0 -17
  237. package/dist/testing/zip-fixture.d.ts +0 -1
  238. package/dist/testing/zip-fixture.js +0 -33
  239. /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
  240. /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
@@ -0,0 +1,454 @@
1
+ #!/usr/bin/env bun
2
+ // tenant-db-options — `createTenantDb(db, tenantId, mode, tracer, meter, signal, grants)` becomes
3
+ // `createTenantDb({ db, tenantId, mode, tracer, meter, signal, grants })`.
4
+ //
5
+ // Usage:
6
+ // bun scripts/codemod/tenant-db-options.ts <targetDir...> [--check | --dry-run | --write] [--no-format]
7
+ // Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1
8
+ // while any rewritable or manual site is left; --dry-run only reports.
9
+
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname, join, relative, resolve } from "node:path";
12
+ import { type CallExpression, Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
13
+ import { collectSourceFiles } from "./collect-source-files.js";
14
+
15
+ export interface TenantDbOptionsSite {
16
+ readonly file: string;
17
+ readonly line: number;
18
+ readonly description: string;
19
+ }
20
+
21
+ export interface TenantDbOptionsResult {
22
+ readonly output: string;
23
+ readonly rewrites: TenantDbOptionsSite[];
24
+ readonly manual: TenantDbOptionsSite[];
25
+ }
26
+
27
+ const FRAMEWORK_PACKAGE = "@cosmicdrift/kumiko-framework";
28
+ const FRAMEWORK_SRC_MARKER = "/packages/framework/src/";
29
+ const FACTORY_NAME = "createTenantDb";
30
+ const OPTION_NAMES = ["db", "tenantId", "mode", "tracer", "meter", "signal", "grants"] as const;
31
+ const FIRST_OPTIONAL_INDEX = 2;
32
+ const CHEAP_PREFILTER = /createTenantDb/;
33
+ const COMMENT_PATTERN = /\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
34
+
35
+ const SKIPPED_PATH_SUFFIXES: readonly string[] = [
36
+ "packages/framework/src/scripts/codemod/tenant-db-options.ts",
37
+ "packages/framework/src/__tests__/tenant-db-options-codemod.test.ts",
38
+ ];
39
+
40
+ function isFrameworkSpecifier(specifier: string, filePath: string): boolean {
41
+ if (specifier === FRAMEWORK_PACKAGE || specifier.startsWith(`${FRAMEWORK_PACKAGE}/`)) {
42
+ return true;
43
+ }
44
+ if (!specifier.startsWith(".")) return false;
45
+ return resolve(dirname(filePath), specifier).includes(FRAMEWORK_SRC_MARKER);
46
+ }
47
+
48
+ // Bindings are resolved through the checker's alias symbol, so a parameter or local that shadows
49
+ // the imported name is never mistaken for the framework export.
50
+ function isFrameworkModuleImportCall(node: Node | undefined, filePath: string): boolean {
51
+ const importCall = Node.isAwaitExpression(node) ? node.getExpression() : node;
52
+ if (!Node.isCallExpression(importCall)) return false;
53
+ if (importCall.getExpression().getKind() !== SyntaxKind.ImportKeyword) return false;
54
+ const argument = importCall.getArguments()[0];
55
+ return (
56
+ argument !== undefined &&
57
+ Node.isStringLiteral(argument) &&
58
+ isFrameworkSpecifier(argument.getLiteralValue(), filePath)
59
+ );
60
+ }
61
+
62
+ function resolvesToFrameworkExport(identifier: Node, filePath: string): boolean {
63
+ const declarations = identifier.getSymbol()?.getDeclarations() ?? [];
64
+ return declarations.some((declaration) => {
65
+ if (Node.isImportSpecifier(declaration)) {
66
+ return (
67
+ declaration.getName() === FACTORY_NAME &&
68
+ isFrameworkSpecifier(declaration.getImportDeclaration().getModuleSpecifierValue(), filePath)
69
+ );
70
+ }
71
+ if (Node.isBindingElement(declaration)) {
72
+ const importedName = declaration.getPropertyNameNode()?.getText() ?? declaration.getName();
73
+ const variable = declaration.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
74
+ return (
75
+ importedName === FACTORY_NAME &&
76
+ isFrameworkModuleImportCall(variable?.getInitializer(), filePath)
77
+ );
78
+ }
79
+ return false;
80
+ });
81
+ }
82
+
83
+ function resolvesToFrameworkNamespace(identifier: Node, filePath: string): boolean {
84
+ const declarations = identifier.getSymbol()?.getDeclarations() ?? [];
85
+ return declarations.some((declaration) => {
86
+ if (Node.isNamespaceImport(declaration)) {
87
+ return isFrameworkSpecifier(
88
+ declaration
89
+ .getFirstAncestorByKindOrThrow(SyntaxKind.ImportDeclaration)
90
+ .getModuleSpecifierValue(),
91
+ filePath,
92
+ );
93
+ }
94
+ return (
95
+ Node.isVariableDeclaration(declaration) &&
96
+ isFrameworkModuleImportCall(declaration.getInitializer(), filePath)
97
+ );
98
+ });
99
+ }
100
+
101
+ // The symbol lookup is the expensive part; names that cannot be the factory skip it.
102
+ function localFactoryNames(sourceFile: SourceFile): ReadonlySet<string> {
103
+ const names = new Set<string>([FACTORY_NAME]);
104
+ for (const specifier of sourceFile.getDescendantsOfKind(SyntaxKind.ImportSpecifier)) {
105
+ if (specifier.getName() === FACTORY_NAME)
106
+ names.add(specifier.getAliasNode()?.getText() ?? FACTORY_NAME);
107
+ }
108
+ for (const element of sourceFile.getDescendantsOfKind(SyntaxKind.BindingElement)) {
109
+ const importedName = element.getPropertyNameNode()?.getText() ?? element.getName();
110
+ if (importedName === FACTORY_NAME) names.add(element.getName());
111
+ }
112
+ return names;
113
+ }
114
+
115
+ function isFactoryReference(node: Node, names: ReadonlySet<string>, filePath: string): boolean {
116
+ if (Node.isIdentifier(node)) {
117
+ return names.has(node.getText()) && resolvesToFrameworkExport(node, filePath);
118
+ }
119
+ return (
120
+ Node.isPropertyAccessExpression(node) &&
121
+ node.getName() === FACTORY_NAME &&
122
+ resolvesToFrameworkNamespace(node.getExpression(), filePath)
123
+ );
124
+ }
125
+
126
+ function isCalleeOf(node: Node): boolean {
127
+ const parent = node.getParent();
128
+ return Node.isCallExpression(parent) && parent.getExpression() === node;
129
+ }
130
+
131
+ function isObjectFormCall(call: CallExpression): boolean {
132
+ const args = call.getArguments();
133
+ return args.length === 1 && Node.isObjectLiteralExpression(args[0]);
134
+ }
135
+
136
+ interface GapComment {
137
+ readonly text: string;
138
+ // Same line as the preceding argument: stays attached to it.
139
+ readonly trailing: boolean;
140
+ }
141
+
142
+ function commentsOfGap(gap: string): GapComment[] {
143
+ const comments: GapComment[] = [];
144
+ for (const match of gap.matchAll(COMMENT_PATTERN)) {
145
+ const before = gap.slice(0, match.index);
146
+ comments.push({ text: match[0], trailing: !before.includes("\n") });
147
+ }
148
+ return comments;
149
+ }
150
+
151
+ function isUndefinedLiteral(argument: Node): boolean {
152
+ return Node.isIdentifier(argument) && argument.getText() === "undefined";
153
+ }
154
+
155
+ function propertyText(name: string, argument: Node): string {
156
+ return Node.isIdentifier(argument) && argument.getText() === name
157
+ ? name
158
+ : `${name}: ${argument.getText()}`;
159
+ }
160
+
161
+ // Text between the parentheses and around each argument holds only commas and comments.
162
+ function gapsAround(call: CallExpression, args: readonly Node[]): string[] {
163
+ const text = call.getSourceFile().getFullText();
164
+ const openParen = call.getFirstChildByKindOrThrow(SyntaxKind.OpenParenToken);
165
+ const closeParen = call.getLastChildByKindOrThrow(SyntaxKind.CloseParenToken);
166
+ const boundaries = [openParen.getEnd(), ...args.flatMap((a) => [a.getStart(), a.getEnd()])];
167
+ boundaries.push(closeParen.getStart());
168
+ const gaps: string[] = [];
169
+ for (let i = 0; i < boundaries.length; i += 2) {
170
+ gaps.push(text.slice(boundaries[i], boundaries[i + 1]));
171
+ }
172
+ return gaps;
173
+ }
174
+
175
+ function optionsObjectText(call: CallExpression, args: readonly Node[]): string {
176
+ const gaps = gapsAround(call, args).map(commentsOfGap);
177
+ const lines: string[] = [];
178
+ let lastLineIsProperty = false;
179
+ const emitComments = (comments: readonly GapComment[]): void => {
180
+ for (const comment of comments) {
181
+ if (comment.trailing && lastLineIsProperty) {
182
+ lines[lines.length - 1] += ` ${comment.text}`;
183
+ } else {
184
+ lines.push(comment.text);
185
+ lastLineIsProperty = false;
186
+ }
187
+ }
188
+ };
189
+ for (const [index, argument] of args.entries()) {
190
+ emitComments(gaps[index] ?? []);
191
+ const name = OPTION_NAMES[index];
192
+ const isDropped = index >= FIRST_OPTIONAL_INDEX && isUndefinedLiteral(argument);
193
+ if (name === undefined || isDropped) continue;
194
+ lines.push(`${propertyText(name, argument)},`);
195
+ lastLineIsProperty = true;
196
+ }
197
+ emitComments(gaps[args.length] ?? []);
198
+ const hasComment = gaps.some((gap) => gap.length > 0);
199
+ if (!hasComment) return `{ ${lines.map((line) => line.replace(/,$/, "")).join(", ")} }`;
200
+ return `{\n${lines.join("\n")}\n}`;
201
+ }
202
+
203
+ function unmappableReason(args: readonly Node[]): string | undefined {
204
+ if (args.some((argument) => Node.isSpreadElement(argument))) {
205
+ return "spread argument, map it to { db, tenantId, ... } by hand";
206
+ }
207
+ if (args.length > OPTION_NAMES.length) {
208
+ return `${args.length} arguments, createTenantDb takes at most ${OPTION_NAMES.length}`;
209
+ }
210
+ if (args.length < 2) {
211
+ return `${args.length} argument(s) that are not an options object literal`;
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ function rewriteCalls(
217
+ sourceFile: SourceFile,
218
+ names: ReadonlySet<string>,
219
+ filePath: string,
220
+ result: Sites,
221
+ ): void {
222
+ const calls = sourceFile
223
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
224
+ .filter((call) => isFactoryReference(call.getExpression(), names, filePath))
225
+ .sort((a, b) => b.getStart() - a.getStart());
226
+
227
+ for (const call of calls) {
228
+ if (isObjectFormCall(call)) continue;
229
+ const line = call.getStartLineNumber();
230
+ const args = call.getArguments();
231
+ const reason = unmappableReason(args);
232
+ if (reason !== undefined) {
233
+ result.manual.push({ file: filePath, line, description: `${FACTORY_NAME}(...): ${reason}` });
234
+ continue;
235
+ }
236
+ call.replaceWithText(`${call.getExpression().getText()}(${optionsObjectText(call, args)})`);
237
+ result.rewrites.push({
238
+ file: filePath,
239
+ line,
240
+ description: `${FACTORY_NAME}(db, tenantId, ...) -> ${FACTORY_NAME}({ db, tenantId, ... })`,
241
+ });
242
+ }
243
+ }
244
+
245
+ function isDeclarationPosition(node: Node): boolean {
246
+ const parent = node.getParent();
247
+ return (
248
+ Node.isImportSpecifier(parent) ||
249
+ Node.isExportSpecifier(parent) ||
250
+ Node.isTypeQuery(parent) ||
251
+ Node.isBindingElement(parent)
252
+ );
253
+ }
254
+
255
+ // A value use cannot be mapped to the options object: the reference may be called anywhere.
256
+ function reportValueUses(
257
+ sourceFile: SourceFile,
258
+ names: ReadonlySet<string>,
259
+ filePath: string,
260
+ result: Sites,
261
+ ): void {
262
+ const candidates: Node[] = [
263
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.Identifier),
264
+ ...sourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression),
265
+ ];
266
+ for (const node of candidates) {
267
+ if (isCalleeOf(node) || isDeclarationPosition(node)) continue;
268
+ const isShorthand = Node.isShorthandPropertyAssignment(node.getParent());
269
+ const matches = isShorthand
270
+ ? Node.isIdentifier(node) && names.has(node.getText())
271
+ : isFactoryReference(node, names, filePath);
272
+ if (!matches) continue;
273
+ result.manual.push({
274
+ file: filePath,
275
+ line: node.getStartLineNumber(),
276
+ description: `${FACTORY_NAME} used as a value, wrap the call site and use the options object`,
277
+ });
278
+ }
279
+ }
280
+
281
+ type Sites = { rewrites: TenantDbOptionsSite[]; manual: TenantDbOptionsSite[] };
282
+
283
+ export function transformTenantDbOptionsSource(
284
+ source: string,
285
+ filePath = "source.ts",
286
+ ): TenantDbOptionsResult {
287
+ const result: TenantDbOptionsResult = { output: source, rewrites: [], manual: [] };
288
+ if (!CHEAP_PREFILTER.test(source)) return result;
289
+ const project = new Project({
290
+ useInMemoryFileSystem: true,
291
+ skipAddingFilesFromTsConfig: true,
292
+ skipFileDependencyResolution: true,
293
+ });
294
+ const absolutePath = resolve(filePath);
295
+ const sourceFile = project.createSourceFile(absolutePath, source);
296
+ const names = localFactoryNames(sourceFile);
297
+ reportValueUses(sourceFile, names, absolutePath, result);
298
+ rewriteCalls(sourceFile, names, absolutePath, result);
299
+ const changed = result.rewrites.length > 0;
300
+ return { ...result, output: changed ? sourceFile.getFullText() : source };
301
+ }
302
+
303
+ type Mode = "write" | "dry-run" | "check";
304
+
305
+ interface CliOptions {
306
+ readonly mode: Mode;
307
+ readonly format: boolean;
308
+ readonly paths: string[];
309
+ }
310
+
311
+ class CliError extends Error {}
312
+
313
+ function parseArgs(argv: readonly string[]): CliOptions {
314
+ let mode: Mode | undefined;
315
+ let format = true;
316
+ const paths: string[] = [];
317
+ const setMode = (next: Mode): void => {
318
+ if (mode !== undefined && mode !== next) {
319
+ throw new CliError("--check, --dry-run and --write are mutually exclusive");
320
+ }
321
+ mode = next;
322
+ };
323
+ for (const arg of argv) {
324
+ if (arg === "--check") setMode("check");
325
+ else if (arg === "--dry-run") setMode("dry-run");
326
+ else if (arg === "--write") setMode("write");
327
+ else if (arg === "--no-format") format = false;
328
+ else if (arg.startsWith("--")) throw new CliError(`unknown flag: ${arg}`);
329
+ else paths.push(arg);
330
+ }
331
+ if (paths.length === 0) throw new CliError("at least one target directory is required");
332
+ return { mode: mode ?? "write", format, paths };
333
+ }
334
+
335
+ function isMigratableFile(name: string): boolean {
336
+ return /\.(ts|tsx)$/.test(name) && !name.endsWith(".d.ts");
337
+ }
338
+
339
+ function isSkippedPath(file: string): boolean {
340
+ return (
341
+ file.includes("/__tests__/fixtures/") ||
342
+ SKIPPED_PATH_SUFFIXES.some((suffix) => file.endsWith(suffix))
343
+ );
344
+ }
345
+
346
+ function findBiomeBinary(startDir: string): string | undefined {
347
+ let dir = resolve(startDir);
348
+ for (;;) {
349
+ const candidate = join(dir, "node_modules", ".bin", "biome");
350
+ if (existsSync(candidate)) return candidate;
351
+ const parent = dirname(dir);
352
+ if (parent === dir) return undefined;
353
+ dir = parent;
354
+ }
355
+ }
356
+
357
+ const FORMAT_BATCH_SIZE = 50;
358
+
359
+ async function formatWithBiome(files: readonly string[], cwd: string): Promise<void> {
360
+ const biome = findBiomeBinary(cwd);
361
+ if (biome === undefined) {
362
+ console.log(
363
+ "[tenant-db-options] biome not found in node_modules/.bin — format and sort imports of the changed files yourself",
364
+ );
365
+ // skip: formatting is a convenience, the rewrite itself is already on disk
366
+ return;
367
+ }
368
+ for (let i = 0; i < files.length; i += FORMAT_BATCH_SIZE) {
369
+ const batch = files.slice(i, i + FORMAT_BATCH_SIZE);
370
+ const proc = Bun.spawn({
371
+ cmd: [
372
+ biome,
373
+ "check",
374
+ "--write",
375
+ "--linter-enabled=false",
376
+ "--no-errors-on-unmatched",
377
+ ...batch,
378
+ ],
379
+ cwd,
380
+ stdout: "pipe",
381
+ stderr: "pipe",
382
+ });
383
+ const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
384
+ if (exitCode !== 0) {
385
+ console.log(
386
+ `[tenant-db-options] biome exited ${exitCode}; run it on the changed files yourself\n${stderr.trim()}`,
387
+ );
388
+ }
389
+ }
390
+ }
391
+
392
+ function displayPath(file: string): string {
393
+ const fromCwd = relative(process.cwd(), file);
394
+ return fromCwd.startsWith("..") ? file : fromCwd;
395
+ }
396
+
397
+ interface RunSummary extends Sites {
398
+ readonly changedFiles: string[];
399
+ }
400
+
401
+ function transformFiles(files: readonly string[], mode: Mode): RunSummary {
402
+ const summary: RunSummary = { rewrites: [], manual: [], changedFiles: [] };
403
+ for (const file of files) {
404
+ const before = readFileSync(file, "utf8");
405
+ const result = transformTenantDbOptionsSource(before, file);
406
+ const shown = displayPath(file);
407
+ for (const site of result.rewrites)
408
+ console.log(`${shown}:${site.line} rewritten ${site.description}`);
409
+ for (const site of result.manual)
410
+ console.log(`${shown}:${site.line} manual ${site.description}`);
411
+ summary.rewrites.push(...result.rewrites);
412
+ summary.manual.push(...result.manual);
413
+ if (mode === "write" && result.output !== before) {
414
+ writeFileSync(file, result.output);
415
+ summary.changedFiles.push(file);
416
+ }
417
+ }
418
+ return summary;
419
+ }
420
+
421
+ function parseArgsOrExit(argv: readonly string[]): CliOptions {
422
+ try {
423
+ return parseArgs(argv);
424
+ } catch (err) {
425
+ if (err instanceof CliError) {
426
+ console.error(`[tenant-db-options] ${err.message}`);
427
+ process.exit(2);
428
+ }
429
+ throw err;
430
+ }
431
+ }
432
+
433
+ async function main(): Promise<void> {
434
+ const options = parseArgsOrExit(process.argv.slice(2));
435
+ const files = collectSourceFiles(options.paths, { isMigratableFile }).filter(
436
+ (file) => !isSkippedPath(file),
437
+ );
438
+ const { rewrites, manual, changedFiles } = transformFiles(files, options.mode);
439
+
440
+ console.log(
441
+ `\n[tenant-db-options] ${options.mode === "write" ? "rewritten" : "rewritable"}: ${rewrites.length} call(s) in ${new Set(rewrites.map((site) => site.file)).size} file(s); manual: ${manual.length}`,
442
+ );
443
+
444
+ if (options.mode === "write" && options.format && changedFiles.length > 0) {
445
+ await formatWithBiome(changedFiles, resolve(options.paths[0] ?? "."));
446
+ }
447
+
448
+ const hasOpenSites = rewrites.length + manual.length > 0;
449
+ process.exit(options.mode === "check" && hasOpenSites ? 1 : 0);
450
+ }
451
+
452
+ if (import.meta.main) {
453
+ await main();
454
+ }
@@ -0,0 +1,161 @@
1
+ import { dirname, relative, resolve } from "node:path";
2
+
3
+ export type OldModule =
4
+ | "stack"
5
+ | "stack-test-users"
6
+ | "stack-other"
7
+ | "testing"
8
+ | "testing-handler-context"
9
+ | "testing-real-providers"
10
+ | "testing-e2e-generator"
11
+ | "testing-other";
12
+
13
+ export type MoveTargetId =
14
+ | "testing-stack"
15
+ | "testing-helpers"
16
+ | "testing-real-providers"
17
+ | "testing-e2e-generator"
18
+ | "framework-seeding"
19
+ | "framework-engine";
20
+
21
+ interface MoveTarget {
22
+ readonly packageSpecifier: string;
23
+ readonly sourcePath: string;
24
+ readonly owner: "framework" | "testing";
25
+ }
26
+
27
+ const FRAMEWORK_PACKAGE = "@cosmicdrift/kumiko-framework";
28
+ const TESTING_PACKAGE = "@cosmicdrift/kumiko-testing";
29
+
30
+ const TARGETS: Readonly<Record<MoveTargetId, MoveTarget>> = {
31
+ "testing-stack": {
32
+ packageSpecifier: `${TESTING_PACKAGE}/stack`,
33
+ sourcePath: "packages/testing/src/stack/index",
34
+ owner: "testing",
35
+ },
36
+ "testing-helpers": {
37
+ packageSpecifier: `${TESTING_PACKAGE}/helpers`,
38
+ sourcePath: "packages/testing/src/helpers/index",
39
+ owner: "testing",
40
+ },
41
+ "testing-real-providers": {
42
+ packageSpecifier: `${TESTING_PACKAGE}/helpers/real-providers`,
43
+ sourcePath: "packages/testing/src/helpers/real-providers",
44
+ owner: "testing",
45
+ },
46
+ "testing-e2e-generator": {
47
+ packageSpecifier: `${TESTING_PACKAGE}/helpers/e2e-generator`,
48
+ sourcePath: "packages/testing/src/helpers/e2e-generator",
49
+ owner: "testing",
50
+ },
51
+ "framework-seeding": {
52
+ packageSpecifier: `${FRAMEWORK_PACKAGE}/seeding`,
53
+ sourcePath: "packages/framework/src/seeding/seed-users",
54
+ owner: "framework",
55
+ },
56
+ "framework-engine": {
57
+ packageSpecifier: `${FRAMEWORK_PACKAGE}/engine`,
58
+ sourcePath: "packages/framework/src/engine/bridge-stub",
59
+ owner: "framework",
60
+ },
61
+ };
62
+
63
+ const SEEDING_NAMES: ReadonlySet<string> = new Set([
64
+ "TestUsers",
65
+ "testTenantId",
66
+ "testUserId",
67
+ "createTestUser",
68
+ ]);
69
+ const BRIDGE_STUB_NAME = "bridgeStub";
70
+
71
+ const PACKAGE_SPECIFIER_MODULES: Readonly<Record<string, OldModule>> = {
72
+ [`${FRAMEWORK_PACKAGE}/stack`]: "stack",
73
+ [`${FRAMEWORK_PACKAGE}/testing`]: "testing",
74
+ [`${FRAMEWORK_PACKAGE}/testing/handler-context`]: "testing-handler-context",
75
+ [`${FRAMEWORK_PACKAGE}/testing/real-providers`]: "testing-real-providers",
76
+ [`${FRAMEWORK_PACKAGE}/testing/e2e-generator`]: "testing-e2e-generator",
77
+ };
78
+
79
+ const FRAMEWORK_SOURCE_DIRS = /\/packages\/framework\/src\/(stack|testing)(?:\/([^/]+))?$/;
80
+ const SCRIPT_EXTENSION = /\.(?:js|ts|tsx)$/;
81
+
82
+ export function classifyPackageSpecifier(specifier: string): OldModule | undefined {
83
+ return PACKAGE_SPECIFIER_MODULES[specifier];
84
+ }
85
+
86
+ function oldTestingModule(file: string | undefined): OldModule {
87
+ if (file === "handler-context") return "testing-handler-context";
88
+ if (file === "real-providers") return "testing-real-providers";
89
+ if (file === "e2e-generator") return "testing-e2e-generator";
90
+ return file === undefined ? "testing" : "testing-other";
91
+ }
92
+
93
+ function oldStackModule(file: string | undefined): OldModule {
94
+ if (file === "test-users") return "stack-test-users";
95
+ return file === undefined ? "stack" : "stack-other";
96
+ }
97
+
98
+ // Path-based on purpose: the old directories no longer exist after the move, so the
99
+ // specifier cannot be resolved against the file system.
100
+ export function classifyRelativeSpecifier(
101
+ specifier: string,
102
+ importingFile: string,
103
+ ): OldModule | undefined {
104
+ const resolved = resolve(dirname(importingFile), specifier)
105
+ .replace(SCRIPT_EXTENSION, "")
106
+ .replace(/\/index$/, "");
107
+ const match = FRAMEWORK_SOURCE_DIRS.exec(resolved);
108
+ const directory = match?.[1];
109
+ if (directory === undefined) return undefined;
110
+ const file = match?.[2];
111
+ return directory === "stack" ? oldStackModule(file) : oldTestingModule(file);
112
+ }
113
+
114
+ export function hasSplitNames(old: OldModule): boolean {
115
+ return old === "stack" || old === "testing";
116
+ }
117
+
118
+ export function targetForName(old: OldModule, name: string): MoveTargetId {
119
+ switch (old) {
120
+ case "stack":
121
+ return SEEDING_NAMES.has(name) ? "framework-seeding" : "testing-stack";
122
+ case "stack-test-users":
123
+ return "framework-seeding";
124
+ case "stack-other":
125
+ return "testing-stack";
126
+ case "testing":
127
+ return name === BRIDGE_STUB_NAME ? "framework-engine" : "testing-helpers";
128
+ case "testing-handler-context":
129
+ return "framework-engine";
130
+ case "testing-real-providers":
131
+ return "testing-real-providers";
132
+ case "testing-e2e-generator":
133
+ return "testing-e2e-generator";
134
+ case "testing-other":
135
+ return "testing-helpers";
136
+ }
137
+ }
138
+
139
+ export function mainTarget(old: OldModule): MoveTargetId {
140
+ return targetForName(old, "");
141
+ }
142
+
143
+ const SOURCE_ROOT_MARKERS = {
144
+ framework: "/packages/framework/src/",
145
+ testing: "/packages/testing/src/",
146
+ } as const;
147
+
148
+ function repoRootOf(importingFile: string, owner: MoveTarget["owner"]): string | undefined {
149
+ const index = importingFile.indexOf(SOURCE_ROOT_MARKERS[owner]);
150
+ return index === -1 ? undefined : importingFile.slice(0, index);
151
+ }
152
+
153
+ // Files inside the package that owns the target import it relatively, everyone else
154
+ // goes through the package specifier.
155
+ export function moduleSpecifierFor(target: MoveTargetId, importingFile: string): string {
156
+ const { owner, sourcePath, packageSpecifier } = TARGETS[target];
157
+ const root = repoRootOf(importingFile, owner);
158
+ if (root === undefined) return packageSpecifier;
159
+ const relativePath = relative(dirname(importingFile), resolve(root, sourcePath));
160
+ return `${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}.js`;
161
+ }