@ethlete/query 6.0.0-next.6 → 6.0.0-next.61

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 (54) hide show
  1. package/CHANGELOG.md +945 -0
  2. package/fesm2022/ethlete-query-devtools-contract.mjs +1 -0
  3. package/fesm2022/ethlete-query-ngrx-toolkit.mjs +411 -0
  4. package/fesm2022/ethlete-query-ngrx-toolkit.mjs.map +1 -0
  5. package/fesm2022/ethlete-query-testing.mjs +548 -169
  6. package/fesm2022/ethlete-query-testing.mjs.map +1 -1
  7. package/fesm2022/ethlete-query.mjs +14680 -7172
  8. package/fesm2022/ethlete-query.mjs.map +1 -1
  9. package/generators/deprecate-legacy-queries/migration.js +85 -0
  10. package/generators/deprecate-legacy-queries/schema.json +26 -0
  11. package/generators/generators.json +30 -0
  12. package/generators/migrate-from-ngrx-toolkit/args-assignability.js +140 -0
  13. package/generators/migrate-from-ngrx-toolkit/consumers.js +521 -0
  14. package/generators/migrate-from-ngrx-toolkit/creators.js +301 -0
  15. package/generators/migrate-from-ngrx-toolkit/facade.js +295 -0
  16. package/generators/migrate-from-ngrx-toolkit/feature.js +513 -0
  17. package/generators/migrate-from-ngrx-toolkit/migration.js +140 -0
  18. package/generators/migrate-from-ngrx-toolkit/report.js +154 -0
  19. package/generators/migrate-from-ngrx-toolkit/schema.json +42 -0
  20. package/generators/migrate-from-ngrx-toolkit/store-removal.js +633 -0
  21. package/generators/migrate-from-ngrx-toolkit/ts-edits.js +128 -0
  22. package/generators/migrate-query-client-features/migration.js +144 -0
  23. package/generators/migrate-query-client-features/report.js +136 -0
  24. package/generators/migrate-query-client-features/schema.json +26 -0
  25. package/generators/migrate-query-opt-in-features/migration.js +195 -0
  26. package/generators/migrate-query-opt-in-features/report.js +143 -0
  27. package/generators/migrate-query-opt-in-features/schema.json +31 -0
  28. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +160 -0
  29. package/generators/migrate-to-query-v3/cleanup-migration.js +247 -125
  30. package/generators/migrate-to-query-v3/http-client-check.js +93 -0
  31. package/generators/migrate-to-query-v3/legacy-prepare-migration.js +129 -40
  32. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +274 -152
  33. package/generators/migrate-to-query-v3/migration-scope.js +40 -0
  34. package/generators/migrate-to-query-v3/migration.js +19 -10
  35. package/generators/migrate-to-query-v3/module-graph.js +165 -0
  36. package/generators/migrate-to-query-v3/query-client-migration.js +342 -160
  37. package/generators/migrate-to-query-v3/rename-symbols.js +233 -0
  38. package/generators/migrate-to-query-v3/report.js +40 -7
  39. package/generators/migrate-to-query-v3/schema.json +12 -0
  40. package/generators/migrate-to-query-v3/shared.js +97 -3
  41. package/generators/move-devtools-contract-imports/migration.js +155 -0
  42. package/generators/move-devtools-contract-imports/moved-names.js +153 -0
  43. package/generators/move-devtools-contract-imports/schema.json +26 -0
  44. package/generators/prep-for-query-v3/migration.js +465 -65
  45. package/generators/report-legacy-query-apis/migration.js +256 -0
  46. package/generators/report-legacy-query-apis/schema.json +25 -0
  47. package/migrations/query-field-default-types.md +43 -0
  48. package/migrations/search-query-field-string.md +43 -0
  49. package/migrations.json +87 -0
  50. package/package.json +34 -14
  51. package/types/ethlete-query-devtools-contract.d.ts +2 -0
  52. package/types/ethlete-query-ngrx-toolkit.d.ts +131 -0
  53. package/types/ethlete-query-testing.d.ts +295 -160
  54. package/types/ethlete-query.d.ts +9755 -3682
@@ -0,0 +1,233 @@
1
+ import * as ts from 'typescript';
2
+ import { createSourceFile, printImportDeclaration } from './shared.js';
3
+ /**
4
+ * Whether an identifier is a *name* rather than a *reference* - a declaration, a property key, the
5
+ * right-hand side of a member access. Renaming those is what turned `PeopleDetailDataSource.getPerson`
6
+ * and the `postLogin` property of a config type into `legacy…` during the first migration run.
7
+ */
8
+ export const isNamePosition = (node) => {
9
+ const parent = node.parent;
10
+ if (!parent)
11
+ return false;
12
+ // `foo.bar` / `Foo.Bar` - the part after the dot is never the imported binding.
13
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node)
14
+ return true;
15
+ if (ts.isQualifiedName(parent) && parent.right === node)
16
+ return true;
17
+ // Object literal / type member keys, enum members, JSX-ish attribute names.
18
+ if (ts.isPropertyAssignment(parent) && parent.name === node)
19
+ return true;
20
+ if (ts.isPropertySignature(parent) && parent.name === node)
21
+ return true;
22
+ if (ts.isMethodSignature(parent) && parent.name === node)
23
+ return true;
24
+ if (ts.isEnumMember(parent) && parent.name === node)
25
+ return true;
26
+ if (ts.isBindingElement(parent) && parent.propertyName === node)
27
+ return true;
28
+ // Declarations of any kind.
29
+ if (ts.isVariableDeclaration(parent) && parent.name === node)
30
+ return true;
31
+ if (ts.isFunctionDeclaration(parent) && parent.name === node)
32
+ return true;
33
+ if (ts.isClassDeclaration(parent) && parent.name === node)
34
+ return true;
35
+ if (ts.isInterfaceDeclaration(parent) && parent.name === node)
36
+ return true;
37
+ if (ts.isTypeAliasDeclaration(parent) && parent.name === node)
38
+ return true;
39
+ if (ts.isEnumDeclaration(parent) && parent.name === node)
40
+ return true;
41
+ if (ts.isModuleDeclaration(parent) && parent.name === node)
42
+ return true;
43
+ if (ts.isMethodDeclaration(parent) && parent.name === node)
44
+ return true;
45
+ if (ts.isPropertyDeclaration(parent) && parent.name === node)
46
+ return true;
47
+ if (ts.isGetAccessorDeclaration(parent) && parent.name === node)
48
+ return true;
49
+ if (ts.isSetAccessorDeclaration(parent) && parent.name === node)
50
+ return true;
51
+ if (ts.isParameter(parent) && parent.name === node)
52
+ return true;
53
+ if (ts.isTypeParameterDeclaration(parent) && parent.name === node)
54
+ return true;
55
+ if (ts.isLabeledStatement(parent) && parent.label === node)
56
+ return true;
57
+ // Import / export clauses are rewritten by the caller, never by the reference pass.
58
+ if (ts.isImportSpecifier(parent))
59
+ return true;
60
+ if (ts.isExportSpecifier(parent) &&
61
+ (parent.parent.parent.moduleSpecifier || (parent.propertyName && parent.name === node))) {
62
+ return true;
63
+ }
64
+ if (ts.isImportClause(parent) || ts.isNamespaceImport(parent))
65
+ return true;
66
+ return false;
67
+ };
68
+ export const renamedReference = (node, nextName) => {
69
+ const parent = node.parent;
70
+ if (ts.isShorthandPropertyAssignment(parent))
71
+ return `${node.text}: ${nextName}`;
72
+ if (ts.isExportSpecifier(parent) && !parent.propertyName)
73
+ return `${nextName} as ${node.text}`;
74
+ return nextName;
75
+ };
76
+ const bindingDeclares = (name, text) => ts.isIdentifier(name)
77
+ ? name.text === text
78
+ : name.elements.some((element) => !ts.isOmittedExpression(element) && bindingDeclares(element.name, text));
79
+ const statementDeclares = (statement, text) => (ts.isVariableStatement(statement) &&
80
+ statement.declarationList.declarations.some((declaration) => bindingDeclares(declaration.name, text))) ||
81
+ ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement)) &&
82
+ statement.name?.text === text);
83
+ export const isShadowedInNestedScope = (identifier) => {
84
+ const text = identifier.text;
85
+ for (let scope = identifier.parent; scope && !ts.isSourceFile(scope); scope = scope.parent) {
86
+ if (ts.isFunctionLike(scope) && scope.parameters.some((parameter) => bindingDeclares(parameter.name, text))) {
87
+ return true;
88
+ }
89
+ if ((ts.isFunctionExpression(scope) || ts.isClassExpression(scope)) && scope.name?.text === text) {
90
+ return true;
91
+ }
92
+ if ((ts.isBlock(scope) || ts.isModuleBlock(scope) || ts.isCaseClause(scope) || ts.isDefaultClause(scope)) &&
93
+ scope.statements.some((statement) => statementDeclares(statement, text))) {
94
+ return true;
95
+ }
96
+ if ((ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope)) &&
97
+ scope.initializer &&
98
+ ts.isVariableDeclarationList(scope.initializer) &&
99
+ scope.initializer.declarations.some((declaration) => bindingDeclares(declaration.name, text))) {
100
+ return true;
101
+ }
102
+ if (ts.isCatchClause(scope) && scope.variableDeclaration && bindingDeclares(scope.variableDeclaration.name, text)) {
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ };
108
+ /** Every name the file declares itself - a local by the same name means the import is shadowed. */
109
+ const collectLocalDeclarations = (sourceFile) => {
110
+ const declared = new Set();
111
+ const add = (name) => {
112
+ if (name && ts.isIdentifier(name))
113
+ declared.add(name.text);
114
+ };
115
+ const visit = (node) => {
116
+ if (ts.isVariableDeclaration(node))
117
+ add(node.name);
118
+ if (ts.isFunctionDeclaration(node))
119
+ add(node.name);
120
+ if (ts.isClassDeclaration(node))
121
+ add(node.name);
122
+ if (ts.isInterfaceDeclaration(node))
123
+ add(node.name);
124
+ if (ts.isTypeAliasDeclaration(node))
125
+ add(node.name);
126
+ if (ts.isEnumDeclaration(node))
127
+ add(node.name);
128
+ if (ts.isParameter(node))
129
+ add(node.name);
130
+ if (ts.isBindingElement(node))
131
+ add(node.name);
132
+ ts.forEachChild(node, visit);
133
+ };
134
+ visit(sourceFile);
135
+ return declared;
136
+ };
137
+ /**
138
+ * Renames references to imported bindings, and only those.
139
+ *
140
+ * `renames` maps a *local binding name* to its new name. The caller is responsible for having
141
+ * verified that the binding really is the symbol being migrated (see `ModuleGraph`); this pass only
142
+ * makes sure the rewrite lands on references rather than on same-named properties, declarations or
143
+ * type members. A name the file also declares locally is skipped entirely and reported back, since
144
+ * a rename there would silently change which value the code reads.
145
+ */
146
+ export const renameImportedReferences = (content, renames) => {
147
+ if (renames.size === 0)
148
+ return { content, shadowed: [] };
149
+ const sourceFile = createSourceFile(content);
150
+ const declaredLocally = collectLocalDeclarations(sourceFile);
151
+ const shadowed = Array.from(renames.keys()).filter((name) => declaredLocally.has(name));
152
+ const applicable = new Map(Array.from(renames.entries()).filter(([name]) => !declaredLocally.has(name)));
153
+ if (applicable.size === 0)
154
+ return { content, shadowed };
155
+ const replacements = [];
156
+ const visit = (node) => {
157
+ if (ts.isIdentifier(node)) {
158
+ const nextName = applicable.get(node.text);
159
+ if (nextName && !isNamePosition(node)) {
160
+ replacements.push({
161
+ start: node.getStart(sourceFile),
162
+ end: node.getEnd(),
163
+ replacement: renamedReference(node, nextName),
164
+ });
165
+ }
166
+ }
167
+ ts.forEachChild(node, visit);
168
+ };
169
+ visit(sourceFile);
170
+ let result = content;
171
+ replacements.sort((left, right) => right.start - left.start);
172
+ replacements.forEach(({ start, end, replacement }) => {
173
+ result = result.slice(0, start) + replacement + result.slice(end);
174
+ });
175
+ return { content: result, shadowed };
176
+ };
177
+ /**
178
+ * Drops named imports whose local name no longer appears anywhere else in the file.
179
+ *
180
+ * The migration rewrites away the constructs that needed `def`, `AnyLegacyQueryCreator` and friends;
181
+ * leaving the imports behind turns every touched file into a lint error, and `formatFiles` runs in
182
+ * the same generator invocation so they land that way. Identifiers inside template strings count as
183
+ * usage too - a component referenced only from an inline template must not be pruned.
184
+ */
185
+ export const pruneUnusedNamedImports = (content) => {
186
+ const sourceFile = createSourceFile(content);
187
+ const used = new Set();
188
+ const addWordsFrom = (text) => {
189
+ for (const word of text.match(/[A-Za-z_$][\w$]*/g) ?? []) {
190
+ used.add(word);
191
+ }
192
+ };
193
+ const visit = (node) => {
194
+ if (ts.isIdentifier(node) && !ts.isImportSpecifier(node.parent) && !ts.isImportClause(node.parent)) {
195
+ used.add(node.text);
196
+ }
197
+ // A symbol referenced only from an inline template or a `styleUrl`-style string is still used.
198
+ if (ts.isStringLiteralLike(node) && !ts.isImportDeclaration(node.parent)) {
199
+ addWordsFrom(node.text);
200
+ }
201
+ if (ts.isTemplateLiteralToken(node)) {
202
+ addWordsFrom(node.text);
203
+ }
204
+ ts.forEachChild(node, visit);
205
+ };
206
+ visit(sourceFile);
207
+ const replacements = [];
208
+ for (const node of sourceFile.statements) {
209
+ if (!ts.isImportDeclaration(node) ||
210
+ !ts.isStringLiteral(node.moduleSpecifier) ||
211
+ !node.importClause?.namedBindings ||
212
+ !ts.isNamedImports(node.importClause.namedBindings)) {
213
+ continue;
214
+ }
215
+ const kept = node.importClause.namedBindings.elements.filter((element) => used.has(element.name.text));
216
+ if (kept.length === node.importClause.namedBindings.elements.length)
217
+ continue;
218
+ const nextImport = printImportDeclaration(node, sourceFile, kept.map((element) => element.getText(sourceFile)));
219
+ if (!nextImport) {
220
+ const end = content[node.getEnd()] === '\n' ? node.getEnd() + 1 : node.getEnd();
221
+ replacements.push({ start: node.getStart(sourceFile), end, replacement: '' });
222
+ continue;
223
+ }
224
+ replacements.push({ start: node.getStart(sourceFile), end: node.getEnd(), replacement: nextImport });
225
+ }
226
+ let result = content;
227
+ replacements.sort((left, right) => right.start - left.start);
228
+ replacements.forEach(({ start, end, replacement }) => {
229
+ result = result.slice(0, start) + replacement + result.slice(end);
230
+ });
231
+ return result;
232
+ };
233
+ //# sourceMappingURL=rename-symbols.js.map
@@ -25,7 +25,7 @@ export class QueryV3MigrationReport {
25
25
  return;
26
26
  }
27
27
  const nextTask = {
28
- id: `QV3-${String(this.tasks.length + 1).padStart(3, '0')}`,
28
+ id: formatTaskId(this.tasks.length + 1),
29
29
  kind: input.kind,
30
30
  title: input.title,
31
31
  summary: input.summary,
@@ -58,11 +58,37 @@ export class QueryV3MigrationReport {
58
58
  });
59
59
  console.warn(`\n📄 See ${QUERY_V3_MIGRATION_REPORT_PATH} for the AI-ready follow-up list.`);
60
60
  }
61
- writeToTree(tree) {
62
- tree.write(QUERY_V3_MIGRATION_REPORT_PATH, renderMigrationReportMarkdown(this.tasks));
61
+ /**
62
+ * Writes the report. A scoped run (`scopeLabel` set) replaces its own section of an existing report
63
+ * or appends one, so the tasks of earlier runs over other projects survive.
64
+ */
65
+ writeToTree(tree, scopeLabel) {
66
+ const existing = scopeLabel ? tree.read(QUERY_V3_MIGRATION_REPORT_PATH, 'utf-8') : null;
67
+ if (!scopeLabel || !existing) {
68
+ tree.write(QUERY_V3_MIGRATION_REPORT_PATH, renderMigrationReportMarkdown(this.tasks, scopeLabel));
69
+ return;
70
+ }
71
+ tree.write(QUERY_V3_MIGRATION_REPORT_PATH, mergeScopedSection(existing, this.tasks, scopeLabel));
63
72
  }
64
73
  }
65
- export const renderMigrationReportMarkdown = (tasks) => {
74
+ const sectionStart = (scopeLabel) => `<!-- migrate-to-query-v3 scope: ${scopeLabel} -->`;
75
+ const SECTION_END = '<!-- /migrate-to-query-v3 scope -->';
76
+ const mergeScopedSection = (existing, tasks, scopeLabel) => {
77
+ const start = sectionStart(scopeLabel);
78
+ const startIndex = existing.indexOf(start);
79
+ const endIndex = startIndex === -1 ? -1 : existing.indexOf(SECTION_END, startIndex);
80
+ const [before, after] = startIndex === -1 || endIndex === -1
81
+ ? [existing.trimEnd(), '']
82
+ : [existing.slice(0, startIndex).trimEnd(), existing.slice(endIndex + SECTION_END.length).trimStart()];
83
+ const highestId = Math.max(0, ...Array.from(`${before}\n${after}`.matchAll(/QV3-(\d+)/g), (match) => Number(match[1])));
84
+ const renumbered = tasks.map((task, index) => ({ ...task, id: formatTaskId(highestId + index + 1) }));
85
+ return [before, renderTasksSection(renumbered, scopeLabel), after]
86
+ .filter((part) => part.length > 0)
87
+ .join('\n\n')
88
+ .concat('\n');
89
+ };
90
+ const formatTaskId = (index) => `QV3-${String(index).padStart(3, '0')}`;
91
+ export const renderMigrationReportMarkdown = (tasks, scopeLabel) => {
66
92
  const header = [
67
93
  '# Query V3 Migration Follow-Up',
68
94
  '',
@@ -83,8 +109,16 @@ export const renderMigrationReportMarkdown = (tasks) => {
83
109
  '- `Source`: which migration phase raised the task.',
84
110
  '',
85
111
  ].join('\n');
112
+ return `${header}\n${renderTasksSection(tasks, scopeLabel)}\n`;
113
+ };
114
+ const renderTasksSection = (tasks, scopeLabel) => {
115
+ const heading = scopeLabel ? `## Tasks (scope: ${scopeLabel})` : '## Tasks';
116
+ const body = renderTasksBody(tasks);
117
+ return scopeLabel ? `${sectionStart(scopeLabel)}\n${heading}\n\n${body}\n${SECTION_END}` : `${heading}\n\n${body}`;
118
+ };
119
+ const renderTasksBody = (tasks) => {
86
120
  if (tasks.length === 0) {
87
- return `${header}## Tasks\n\nNo open follow-up tasks were recorded during this migration run.\n`;
121
+ return 'No open follow-up tasks were recorded during this migration run.';
88
122
  }
89
123
  const tasksSection = tasks
90
124
  .map((task) => {
@@ -129,8 +163,7 @@ export const renderMigrationReportMarkdown = (tasks) => {
129
163
  ];
130
164
  }),
131
165
  '```',
132
- '',
133
166
  ].join('\n');
134
- return `${header}## Tasks\n\n${tasksSection}${machineSection}`;
167
+ return `${tasksSection}${machineSection}`;
135
168
  };
136
169
  //# sourceMappingURL=report.js.map
@@ -4,6 +4,18 @@
4
4
  "title": "Migrate to Query v3",
5
5
  "type": "object",
6
6
  "properties": {
7
+ "projects": {
8
+ "type": "array",
9
+ "items": { "type": "string" },
10
+ "description": "Nx projects to migrate. Defaults to every project in the workspace. Use this to adopt v3 one app or library at a time instead of rewriting the whole monorepo in one run - but keep a query client and the creators built from it in the same run, since they are migrated together.",
11
+ "alias": "project"
12
+ },
13
+ "include": {
14
+ "type": "array",
15
+ "items": { "type": "string" },
16
+ "description": "Path prefixes to migrate, relative to the workspace root (e.g. 'libs/queries'). Unioned with `projects`. Defaults to the whole workspace.",
17
+ "alias": "includePath"
18
+ },
7
19
  "skipFormat": {
8
20
  "type": "boolean",
9
21
  "description": "Skip formatting files after migration",
@@ -13,13 +13,34 @@ export const ensureConfigSuffix = (name) => {
13
13
  }
14
14
  return `${name}Config`;
15
15
  };
16
+ const importSpecifierText = (element) => element.propertyName ? `${element.propertyName.text} as ${element.name.text}` : element.name.text;
17
+ /**
18
+ * Prints `node` with `namedElements` as its named bindings, keeping the default import, the
19
+ * `import type` modifier, the quote style and any import attributes. Returns an empty string when
20
+ * nothing would be left to import.
21
+ */
22
+ export const printImportDeclaration = (node, sourceFile, namedElements) => {
23
+ const clause = node.importClause;
24
+ const defaultName = clause?.name?.text;
25
+ const bindings = [defaultName, namedElements.length > 0 ? `{ ${namedElements.join(', ')} }` : undefined].filter((part) => !!part);
26
+ if (bindings.length === 0) {
27
+ return '';
28
+ }
29
+ const typePrefix = clause?.isTypeOnly ? 'type ' : '';
30
+ const attributes = node.attributes ? ` ${node.attributes.getText(sourceFile)}` : '';
31
+ return `import ${typePrefix}${bindings.join(', ')} from ${node.moduleSpecifier.getText(sourceFile)}${attributes};`;
32
+ };
16
33
  export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier }) => {
17
34
  const sourceFile = createSourceFile(content);
18
35
  let importNode;
19
36
  ts.forEachChild(sourceFile, (node) => {
20
- if (ts.isImportDeclaration(node) &&
37
+ if (!importNode &&
38
+ ts.isImportDeclaration(node) &&
21
39
  ts.isStringLiteral(node.moduleSpecifier) &&
22
- node.moduleSpecifier.text === moduleSpecifier) {
40
+ node.moduleSpecifier.text === moduleSpecifier &&
41
+ !node.importClause?.isTypeOnly &&
42
+ node.importClause?.namedBindings &&
43
+ ts.isNamedImports(node.importClause.namedBindings)) {
23
44
  importNode = node;
24
45
  }
25
46
  });
@@ -28,13 +49,14 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
28
49
  const namedBindings = importNode.importClause.namedBindings;
29
50
  namedBindings.elements.forEach((element) => {
30
51
  missingImports.delete(element.name.text);
52
+ missingImports.delete(importSpecifierText(element));
31
53
  });
32
54
  if (missingImports.size === 0) {
33
55
  return content;
34
56
  }
35
57
  const existingImports = namedBindings.elements.map((element) => element.getText(sourceFile));
36
58
  const nextImports = [...existingImports, ...Array.from(missingImports).sort()].sort();
37
- const nextImportStatement = `import { ${nextImports.join(', ')} } from '${moduleSpecifier}';`;
59
+ const nextImportStatement = printImportDeclaration(importNode, sourceFile, nextImports);
38
60
  return content.slice(0, importNode.getStart(sourceFile)) + nextImportStatement + content.slice(importNode.getEnd());
39
61
  }
40
62
  let lastImportEnd = 0;
@@ -55,6 +77,8 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
55
77
  };
56
78
  export const ensureImportFromQuery = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/query' });
57
79
  export const ensureAngularCoreImports = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@angular/core' });
80
+ /** `toProvideFn` / `toInjectFn` - the provider-definition extractors every generated ref pair needs. */
81
+ export const ensureImportFromEthleteCore = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/core' });
58
82
  export const getVariableStatementEnd = (sourceFile, variableName, initializerName) => {
59
83
  let variableStatementEnd;
60
84
  const visit = (node) => {
@@ -94,4 +118,74 @@ export const getLineNumber = (node, sourceFile) => {
94
118
  return line + 1;
95
119
  };
96
120
  export const getLineNumberFromPosition = (content, position) => content.slice(0, position).split('\n').length;
121
+ const MIGRATION_GUIDE_URL = 'https://ethlete-sdk-docs.web.app/query/migrating-from-v2';
122
+ /**
123
+ * The tag every `createLegacyQueryCreator(…)` wrapper carries. The wrapper is a migration
124
+ * scaffold, so marking it is what turns "which call sites are still on v2" from a grep into
125
+ * strikethrough in the editor.
126
+ */
127
+ export const legacyQueryDeprecationTag = (creatorName) => `@deprecated Legacy (v2) query wrapper. Migrate the call sites to ${creatorName ? `\`${creatorName}\`` : 'the query creator it wraps'} and delete this wrapper - see ${MIGRATION_GUIDE_URL}.`;
128
+ /**
129
+ * Adds a tag to `node`'s JSDoc, creating the block when there is none. Returns `null` when the
130
+ * tag is already there, so a caller can tell "nothing to do" from "rewritten".
131
+ *
132
+ * Leading `//` comments stay glued to the node - an `eslint-disable-next-line` only works from
133
+ * the line directly above.
134
+ */
135
+ export const addJsDocTag = (content, sourceFile, node, tag) => {
136
+ const jsDocs = node.jsDoc ?? [];
137
+ const existing = jsDocs[jsDocs.length - 1];
138
+ const tagName = /^@\w+/.exec(tag)?.[0];
139
+ if (existing && tagName && new RegExp(`${tagName}\\b`).test(existing.getText())) {
140
+ return null;
141
+ }
142
+ if (existing) {
143
+ const start = existing.getStart(sourceFile);
144
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
145
+ const text = existing.getText();
146
+ const body = text.slice(0, text.lastIndexOf('*/')).replace(/\s+$/, '');
147
+ // A one-line `/** … */` has to become a block before a tag line can be appended to it.
148
+ const opened = body.includes('\n') ? body : `/**\n${indentation} * ${body.slice(3).trim()}`;
149
+ return (content.slice(0, start) +
150
+ `${opened}\n${indentation} *\n${indentation} * ${tag}\n${indentation} */` +
151
+ content.slice(existing.getEnd()));
152
+ }
153
+ let start = node.getStart(sourceFile, false);
154
+ for (const comment of (ts.getLeadingCommentRanges(content, node.getFullStart()) ?? []).reverse()) {
155
+ if (comment.kind !== ts.SyntaxKind.SingleLineCommentTrivia)
156
+ break;
157
+ if (!/^\n[ \t]*$/.test(content.slice(comment.end, start)))
158
+ break;
159
+ start = comment.pos;
160
+ }
161
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
162
+ return (content.slice(0, start) + `/**\n${indentation} * ${tag}\n${indentation} */\n${indentation}` + content.slice(start));
163
+ };
164
+ const TUPLE_PROVIDER_ACTION = 'Delete the call: a v3 client, auth provider or socket is root-provided the moment something injects it. For a test or an override, provide it with `toProvideFn(ref)` from `@ethlete/core`.';
165
+ export const REMOVED_EXPERIMENTAL_QUERY_HELPERS = {
166
+ createQueryClientConfig: 'Replace it with `createQueryClient({ name, baseUrl })`, which returns the provider tuple, and derive `toProvideFn` / `toInjectFn` from it.',
167
+ createBearerAuthProviderConfig: 'Replace it with `createBearerAuthProvider({ name, queryClientRef, queries, features })`, which returns the provider tuple, and derive `toProvideFn` / `toInjectFn` from it.',
168
+ provideQueryClient: TUPLE_PROVIDER_ACTION,
169
+ provideBearerAuthProvider: TUPLE_PROVIDER_ACTION,
170
+ provideWebSocketClient: `Build the socket with \`createWebSocketClient({ name, url, io })\`. ${TUPLE_PROVIDER_ACTION}`,
171
+ };
172
+ export const findRemovedExperimentalQueryHelpers = (sourceFile) => {
173
+ const found = [];
174
+ sourceFile.statements.forEach((statement) => {
175
+ if (!ts.isImportDeclaration(statement) ||
176
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
177
+ statement.moduleSpecifier.text !== '@ethlete/query' ||
178
+ !statement.importClause?.namedBindings ||
179
+ !ts.isNamedImports(statement.importClause.namedBindings)) {
180
+ return;
181
+ }
182
+ statement.importClause.namedBindings.elements.forEach((element) => {
183
+ const name = (element.propertyName ?? element.name).text;
184
+ if (name in REMOVED_EXPERIMENTAL_QUERY_HELPERS) {
185
+ found.push({ name, line: getLineNumber(element, sourceFile) });
186
+ }
187
+ });
188
+ });
189
+ return found;
190
+ };
97
191
  //# sourceMappingURL=shared.js.map
@@ -0,0 +1,155 @@
1
+ import { formatFiles } from '@nx/devkit';
2
+ import * as ts from 'typescript';
3
+ import { createMigrationScope } from '../migrate-to-query-v3/migration-scope.js';
4
+ import { createSourceFile } from '../migrate-to-query-v3/shared.js';
5
+ import { MOVED_DEVTOOLS_CONTRACT_NAMES } from './moved-names.js';
6
+ const QUERY_MODULE = '@ethlete/query';
7
+ const CONTRACT_MODULE = '@ethlete/query/devtools-contract';
8
+ const isMigratableFile = (filePath) => /\.(ts|mts|cts|tsx)$/.test(filePath) && !filePath.endsWith('.d.ts');
9
+ const specifierName = (specifier) => (specifier.propertyName ?? specifier.name).text;
10
+ const isMoved = (specifier) => MOVED_DEVTOOLS_CONTRACT_NAMES.has(specifierName(specifier));
11
+ const moduleOf = (statement) => {
12
+ if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement))
13
+ return undefined;
14
+ const specifier = statement.moduleSpecifier;
15
+ return specifier && ts.isStringLiteral(specifier) ? specifier : undefined;
16
+ };
17
+ const namedBindingsOf = (statement) => {
18
+ if (ts.isExportDeclaration(statement)) {
19
+ return statement.exportClause && ts.isNamedExports(statement.exportClause) ? statement.exportClause : undefined;
20
+ }
21
+ const bindings = statement.importClause?.namedBindings;
22
+ return bindings && ts.isNamedImports(bindings) ? bindings : undefined;
23
+ };
24
+ const isTypeOnlyDeclaration = (statement) => ts.isExportDeclaration(statement) ? statement.isTypeOnly : !!statement.importClause?.isTypeOnly;
25
+ const declarationKey = (statement) => `${ts.isImportDeclaration(statement) ? 'import' : 'export'}${isTypeOnlyDeclaration(statement) ? ' type' : ''}`;
26
+ const findNamespaceUses = (sourceFile, content) => {
27
+ const uses = [];
28
+ for (const statement of sourceFile.statements) {
29
+ if (!ts.isImportDeclaration(statement) || moduleOf(statement)?.text !== QUERY_MODULE)
30
+ continue;
31
+ const bindings = statement.importClause?.namedBindings;
32
+ if (!bindings || !ts.isNamespaceImport(bindings))
33
+ continue;
34
+ for (const name of MOVED_DEVTOOLS_CONTRACT_NAMES) {
35
+ if (content.includes(`${bindings.name.text}.${name}`))
36
+ uses.push(`${bindings.name.text}.${name}`);
37
+ }
38
+ }
39
+ return uses;
40
+ };
41
+ export const moveDevtoolsContractImportsInFile = (content, filePath) => {
42
+ if (!content.includes(QUERY_MODULE))
43
+ return null;
44
+ const sourceFile = createSourceFile(content, filePath);
45
+ const edits = [];
46
+ const contractTargets = new Map();
47
+ for (const statement of sourceFile.statements) {
48
+ if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement))
49
+ continue;
50
+ if (moduleOf(statement)?.text !== CONTRACT_MODULE)
51
+ continue;
52
+ const bindings = namedBindingsOf(statement);
53
+ const key = declarationKey(statement);
54
+ if (bindings && !contractTargets.has(key))
55
+ contractTargets.set(key, { bindings, added: [] });
56
+ }
57
+ for (const statement of sourceFile.statements) {
58
+ if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement))
59
+ continue;
60
+ const moduleSpecifier = moduleOf(statement);
61
+ if (moduleSpecifier?.text !== QUERY_MODULE)
62
+ continue;
63
+ const bindings = namedBindingsOf(statement);
64
+ if (!bindings)
65
+ continue;
66
+ const elements = bindings.elements;
67
+ const moved = elements.filter(isMoved);
68
+ if (moved.length === 0)
69
+ continue;
70
+ const kept = elements.filter((specifier) => !isMoved(specifier));
71
+ const movedText = moved.map((specifier) => specifier.getText(sourceFile));
72
+ const hasDefaultImport = ts.isImportDeclaration(statement) && !!statement.importClause?.name;
73
+ const target = contractTargets.get(declarationKey(statement));
74
+ if (target) {
75
+ target.added.push(...movedText);
76
+ }
77
+ if (kept.length === 0 && !hasDefaultImport) {
78
+ if (target) {
79
+ edits.push({ start: statement.getFullStart(), end: statement.getEnd(), text: '' });
80
+ }
81
+ else {
82
+ const quote = moduleSpecifier.getText(sourceFile)[0];
83
+ edits.push({
84
+ start: moduleSpecifier.getStart(sourceFile),
85
+ end: moduleSpecifier.getEnd(),
86
+ text: `${quote}${CONTRACT_MODULE}${quote}`,
87
+ });
88
+ }
89
+ continue;
90
+ }
91
+ edits.push({
92
+ start: bindings.getStart(sourceFile),
93
+ end: bindings.getEnd(),
94
+ text: kept.length ? `{ ${kept.map((specifier) => specifier.getText(sourceFile)).join(', ')} }` : '{}',
95
+ });
96
+ if (!target) {
97
+ const quote = moduleSpecifier.getText(sourceFile)[0];
98
+ edits.push({
99
+ start: statement.getEnd(),
100
+ end: statement.getEnd(),
101
+ text: `\n${declarationKey(statement)} { ${movedText.join(', ')} } from ${quote}${CONTRACT_MODULE}${quote};`,
102
+ });
103
+ }
104
+ }
105
+ for (const { bindings, added } of contractTargets.values()) {
106
+ if (added.length === 0)
107
+ continue;
108
+ const last = bindings.elements.at(-1);
109
+ const position = last ? last.getEnd() : bindings.getStart(sourceFile) + 1;
110
+ edits.push({ start: position, end: position, text: last ? `, ${added.join(', ')}` : ` ${added.join(', ')} ` });
111
+ }
112
+ if (edits.length === 0)
113
+ return null;
114
+ return edits
115
+ .sort((a, b) => b.start - a.start)
116
+ .reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), content);
117
+ };
118
+ export const moveDevtoolsContractImports = (tree, scope) => {
119
+ const touchedFiles = [];
120
+ const manualUses = [];
121
+ scope.visit(tree, (filePath) => {
122
+ if (!isMigratableFile(filePath))
123
+ return;
124
+ const content = tree.read(filePath, 'utf-8');
125
+ if (!content?.includes(QUERY_MODULE))
126
+ return;
127
+ for (const use of findNamespaceUses(createSourceFile(content, filePath), content)) {
128
+ manualUses.push(`${filePath}: ${use}`);
129
+ }
130
+ const next = moveDevtoolsContractImportsInFile(content, filePath);
131
+ if (next === null)
132
+ return;
133
+ tree.write(filePath, next);
134
+ touchedFiles.push(filePath);
135
+ });
136
+ return { touchedFiles, manualUses };
137
+ };
138
+ export default async function migrate(tree, schema) {
139
+ console.log(`\n🔄 Moving devtools contract imports to ${CONTRACT_MODULE}...`);
140
+ const scope = createMigrationScope(tree, schema);
141
+ console.log(` Scope: ${scope.describe()}`);
142
+ const { touchedFiles, manualUses } = moveDevtoolsContractImports(tree, scope);
143
+ console.log(` Rewrote imports in ${touchedFiles.length} file${touchedFiles.length === 1 ? '' : 's'}.`);
144
+ if (manualUses.length > 0) {
145
+ console.log(`\n⚠️ Namespace imports of ${QUERY_MODULE} use names that moved to ${CONTRACT_MODULE}:`);
146
+ for (const use of manualUses) {
147
+ console.log(` ${use}`);
148
+ }
149
+ }
150
+ if (!schema.skipFormat) {
151
+ await formatFiles(tree);
152
+ }
153
+ console.log('\n✅ Devtools contract imports moved.');
154
+ }
155
+ //# sourceMappingURL=migration.js.map