@ethlete/query 6.0.0-next.51 → 6.0.0-next.53

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 (37) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/fesm2022/ethlete-query-ngrx-toolkit.mjs +393 -0
  3. package/fesm2022/ethlete-query-ngrx-toolkit.mjs.map +1 -0
  4. package/fesm2022/ethlete-query-testing.mjs +67 -25
  5. package/fesm2022/ethlete-query-testing.mjs.map +1 -1
  6. package/fesm2022/ethlete-query.mjs +7895 -7226
  7. package/fesm2022/ethlete-query.mjs.map +1 -1
  8. package/generators/generators.json +5 -0
  9. package/generators/migrate-from-ngrx-toolkit/args-assignability.js +140 -0
  10. package/generators/migrate-from-ngrx-toolkit/consumers.js +521 -0
  11. package/generators/migrate-from-ngrx-toolkit/creators.js +301 -0
  12. package/generators/migrate-from-ngrx-toolkit/facade.js +295 -0
  13. package/generators/migrate-from-ngrx-toolkit/feature.js +513 -0
  14. package/generators/migrate-from-ngrx-toolkit/migration.js +140 -0
  15. package/generators/migrate-from-ngrx-toolkit/report.js +154 -0
  16. package/generators/migrate-from-ngrx-toolkit/schema.json +42 -0
  17. package/generators/migrate-from-ngrx-toolkit/store-removal.js +633 -0
  18. package/generators/migrate-from-ngrx-toolkit/ts-edits.js +128 -0
  19. package/generators/migrate-query-opt-in-features/migration.js +10 -9
  20. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +29 -2
  21. package/generators/migrate-to-query-v3/cleanup-migration.js +163 -53
  22. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +39 -2
  23. package/generators/migrate-to-query-v3/migration-scope.js +2 -0
  24. package/generators/migrate-to-query-v3/migration.js +5 -4
  25. package/generators/migrate-to-query-v3/module-graph.js +41 -29
  26. package/generators/migrate-to-query-v3/query-client-migration.js +209 -126
  27. package/generators/migrate-to-query-v3/rename-symbols.js +38 -11
  28. package/generators/migrate-to-query-v3/report.js +40 -7
  29. package/generators/migrate-to-query-v3/shared.js +46 -1
  30. package/generators/prep-for-query-v3/migration.js +400 -57
  31. package/migrations/query-field-default-types.md +43 -0
  32. package/migrations/search-query-field-string.md +43 -0
  33. package/migrations.json +16 -0
  34. package/package.json +5 -1
  35. package/types/ethlete-query-ngrx-toolkit.d.ts +143 -0
  36. package/types/ethlete-query-testing.d.ts +28 -11
  37. package/types/ethlete-query.d.ts +789 -584
@@ -0,0 +1,128 @@
1
+ import * as ts from 'typescript';
2
+ import { pruneUnusedNamedImports } from '../migrate-to-query-v3/rename-symbols.js';
3
+ import { createSourceFile } from '../migrate-to-query-v3/shared.js';
4
+ /** Applies edits back to front. An edit nested inside an already applied one is dropped. */
5
+ export const applyEdits = (content, edits) => {
6
+ const sorted = [...edits].sort((left, right) => right.start - left.start || right.end - left.end);
7
+ let result = content;
8
+ let floor = Number.POSITIVE_INFINITY;
9
+ for (const edit of sorted) {
10
+ if (edit.end > floor)
11
+ continue;
12
+ result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
13
+ floor = edit.start;
14
+ }
15
+ return result;
16
+ };
17
+ /** Removes `nodes[index]` from a comma separated list, together with the comma that belongs to it. */
18
+ export const removeListElement = (nodes, index, sourceFile) => {
19
+ const node = nodes[index];
20
+ if (index < nodes.length - 1) {
21
+ return { start: node.getStart(sourceFile), end: nodes[index + 1].getStart(sourceFile), text: '' };
22
+ }
23
+ if (index > 0) {
24
+ return { start: nodes[index - 1].getEnd(), end: node.getEnd(), text: '' };
25
+ }
26
+ return { start: node.getStart(sourceFile), end: node.getEnd(), text: '' };
27
+ };
28
+ /** Removes a whole statement or class member, including its leading comments and the line break after it. */
29
+ export const removeStatement = (node, content) => {
30
+ const comments = ts.getLeadingCommentRanges(content, node.getFullStart()) ?? [];
31
+ let start = comments[0]?.pos ?? node.getStart();
32
+ while (start > 0 && (content[start - 1] === ' ' || content[start - 1] === '\t'))
33
+ start -= 1;
34
+ let end = node.getEnd();
35
+ while (content[end] === ' ' || content[end] === '\t')
36
+ end += 1;
37
+ if (content[end] === '\r')
38
+ end += 1;
39
+ if (content[end] === '\n')
40
+ end += 1;
41
+ return { start, end, text: '' };
42
+ };
43
+ export const lineOf = (sourceFile, position) => sourceFile.getLineAndCharacterOfPosition(position).line + 1;
44
+ export const collectUsedIdentifiers = (sourceFile) => {
45
+ const used = new Set();
46
+ const visit = (node) => {
47
+ if (ts.isImportDeclaration(node))
48
+ return;
49
+ if (ts.isIdentifier(node))
50
+ used.add(node.text);
51
+ ts.forEachChild(node, visit);
52
+ };
53
+ visit(sourceFile);
54
+ return used;
55
+ };
56
+ /** Collapses runs of blank lines left behind by removed statements, outside template literals. */
57
+ export const collapseBlankLines = (content) => {
58
+ const sourceFile = createSourceFile(content);
59
+ const templates = [];
60
+ const visit = (node) => {
61
+ if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
62
+ templates.push([node.getStart(sourceFile), node.getEnd()]);
63
+ return;
64
+ }
65
+ ts.forEachChild(node, visit);
66
+ };
67
+ visit(sourceFile);
68
+ return content.replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, (match, offset) => templates.some(([start, end]) => offset >= start && offset < end) ? match : '\n\n');
69
+ };
70
+ /** Drops unused namespace, default and named imports. Side-effect imports stay. */
71
+ export const pruneUnusedImports = (content) => {
72
+ const sourceFile = createSourceFile(content);
73
+ const used = collectUsedIdentifiers(sourceFile);
74
+ const edits = [];
75
+ for (const statement of sourceFile.statements) {
76
+ if (!ts.isImportDeclaration(statement) || !statement.importClause)
77
+ continue;
78
+ const { name, namedBindings } = statement.importClause;
79
+ if (namedBindings && ts.isNamespaceImport(namedBindings) && !used.has(namedBindings.name.text)) {
80
+ if (!name || !used.has(name.text))
81
+ edits.push(removeStatement(statement, content));
82
+ continue;
83
+ }
84
+ if (!namedBindings && name && !used.has(name.text)) {
85
+ edits.push(removeStatement(statement, content));
86
+ }
87
+ }
88
+ return pruneUnusedNamedImports(applyEdits(content, edits));
89
+ };
90
+ /** A relative module specifier from one workspace file to another, without the `.ts` extension. */
91
+ export const relativeSpecifier = (fromFile, toFile) => {
92
+ const fromSegments = fromFile.split('/').slice(0, -1);
93
+ const toSegments = toFile.replace(/\.ts$/, '').split('/');
94
+ let shared = 0;
95
+ while (shared < fromSegments.length &&
96
+ shared < toSegments.length - 1 &&
97
+ fromSegments[shared] === toSegments[shared]) {
98
+ shared += 1;
99
+ }
100
+ const ups = fromSegments.length - shared;
101
+ const rest = toSegments.slice(shared).join('/');
102
+ return ups === 0 ? `./${rest}` : `${'../'.repeat(ups)}${rest}`;
103
+ };
104
+ export const hasExportModifier = (node) => ts.canHaveModifiers(node) &&
105
+ !!ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
106
+ /** The names a top-level statement declares. */
107
+ export const declaredNames = (statement) => {
108
+ if (ts.isVariableStatement(statement)) {
109
+ return statement.declarationList.declarations.flatMap((declaration) => {
110
+ if (ts.isIdentifier(declaration.name))
111
+ return [declaration.name.text];
112
+ if (ts.isObjectBindingPattern(declaration.name)) {
113
+ return declaration.name.elements.flatMap((element) => ts.isIdentifier(element.name) ? [element.name.text] : []);
114
+ }
115
+ return [];
116
+ });
117
+ }
118
+ if ((ts.isFunctionDeclaration(statement) ||
119
+ ts.isClassDeclaration(statement) ||
120
+ ts.isInterfaceDeclaration(statement) ||
121
+ ts.isTypeAliasDeclaration(statement) ||
122
+ ts.isEnumDeclaration(statement)) &&
123
+ statement.name) {
124
+ return [statement.name.text];
125
+ }
126
+ return [];
127
+ };
128
+ //# sourceMappingURL=ts-edits.js.map
@@ -4,8 +4,16 @@ import { createMigrationScope } from '../migrate-to-query-v3/migration-scope.js'
4
4
  import { createSourceFile, ensureImportFromQuery, getLineNumber } from '../migrate-to-query-v3/shared.js';
5
5
  import { QueryOptInFeaturesMigrationReport } from './report.js';
6
6
  const SOURCE = 'query-opt-in-features';
7
- const CLIENT_FEATURE = 'withEthleteApiErrors';
7
+ export const CLIENT_FEATURE = 'withEthleteApiErrors';
8
8
  const AUTH_FEATURE = 'withBearerAuthMultiTabSync';
9
+ export const reportClientErrorPipelineAdded = (report, location, source, summary = `\`${CLIENT_FEATURE}()\` was added so error parsing and retrying keep working exactly as before.`) => report.addFollowUp({
10
+ title: 'Client kept the full error pipeline',
11
+ summary,
12
+ action: 'Narrow it: `withHtmlErrorParsing()` only for an API behind a proxy that answers HTML, `withSymfonyErrors()` only for violation-list responses, `withDefaultRetry()` only if failed requests should retry themselves.',
13
+ locations: [location],
14
+ source,
15
+ dedupeKey: 'client-error-pipeline',
16
+ });
9
17
  const isMigratableFile = (filePath) => /\.(ts|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts');
10
18
  const propertyNamed = (config, sourceFile, name) => config.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText(sourceFile) === name);
11
19
  const hasFeature = (config, sourceFile, feature) => {
@@ -87,14 +95,7 @@ const collectEdits = (filePath, sourceFile, report) => {
87
95
  }
88
96
  else if (isClient) {
89
97
  const feature = `${CLIENT_FEATURE}()`;
90
- report.addFollowUp({
91
- title: 'Client kept the full error pipeline',
92
- summary: `\`${CLIENT_FEATURE}()\` was added so error parsing and retrying keep working exactly as before.`,
93
- action: 'Narrow it: `withHtmlErrorParsing()` only for an API behind a proxy that answers HTML, `withSymfonyErrors()` only for violation-list responses, `withDefaultRetry()` only if failed requests should retry themselves.',
94
- locations: [{ filePath, line }],
95
- source: SOURCE,
96
- dedupeKey: 'client-error-pipeline',
97
- });
98
+ reportClientErrorPipelineAdded(report, { filePath, line }, SOURCE);
98
99
  edits.push({
99
100
  start: config.getStart(sourceFile),
100
101
  end: config.getEnd(),
@@ -8,6 +8,10 @@ const REFRESH_CONFIG_KEYS = new Set([
8
8
  'cookieDomain',
9
9
  'cookieExpiresInDays',
10
10
  'expiresInPropertyName',
11
+ 'cookiePath',
12
+ 'cookieSameSite',
13
+ 'cookieEnabled',
14
+ 'refreshBuffer',
11
15
  ]);
12
16
  export const collectV2BearerAuthConfigs = (tree, scope) => {
13
17
  const configs = [];
@@ -77,12 +81,14 @@ const REFRESH_QUERY_KEY = 'tokenRefresh';
77
81
  * than presented as finished.
78
82
  */
79
83
  export const renderAuthProviderBody = (config) => {
84
+ const warnings = [];
80
85
  if (!config?.refreshCreatorName) {
81
86
  return {
82
87
  queries: ' queries: [],',
83
88
  features: '',
84
89
  importsNeeded: [],
85
90
  isScaffolded: false,
91
+ warnings,
86
92
  };
87
93
  }
88
94
  const refreshEntries = [` queryCreator: ${config.refreshCreatorName},`];
@@ -95,6 +101,16 @@ export const renderAuthProviderBody = (config) => {
95
101
  if (config.expiresInPropertyName) {
96
102
  refreshEntries.push(` expiresInPropertyName: ${config.expiresInPropertyName},`);
97
103
  }
104
+ if (config.refreshBuffer) {
105
+ refreshEntries.push(` refreshStrategy: ${renderRefreshStrategy(config.refreshBuffer)},`);
106
+ }
107
+ if (config.cookieName && config.cookieEnabled && config.cookieEnabled !== 'true') {
108
+ warnings.push({
109
+ title: 'Replace the v2 cookieEnabled switch',
110
+ summary: `The v2 provider set \`cookieEnabled: ${config.cookieEnabled}\`. \`withPersistentAuth\` has no switch for it: it always keeps the refresh token in the cookie, and \`setRememberMe(false)\` only turns that cookie into a session cookie. v2's \`enableCookie()\` / \`disableCookie()\` have no v3 counterpart either.`,
111
+ action: 'If the cookie was only written after an opt-in (a "remember me" box), drive `setRememberMe()` from it. If it was never written, remove `withPersistentAuth` from the scaffold.',
112
+ });
113
+ }
98
114
  const queries = [
99
115
  ' queries: [',
100
116
  ` // Derived from the v2 BearerAuthProvider in ${config.filePath}:${config.line}. Verify before shipping.`,
@@ -106,13 +122,17 @@ export const renderAuthProviderBody = (config) => {
106
122
  ].join('\n');
107
123
  const importsNeeded = ['withRefreshQuery'];
108
124
  if (!config.cookieName) {
109
- return { queries, features: '', importsNeeded, isScaffolded: true };
125
+ return { queries, features: '', importsNeeded, isScaffolded: true, warnings };
110
126
  }
111
127
  const cookieEntries = [` name: ${config.cookieName},`];
112
128
  if (config.cookieDomain)
113
129
  cookieEntries.push(` domain: ${config.cookieDomain},`);
114
130
  if (config.cookieExpiresInDays)
115
131
  cookieEntries.push(` expiresInDays: ${config.cookieExpiresInDays},`);
132
+ if (config.cookiePath)
133
+ cookieEntries.push(` path: ${config.cookiePath},`);
134
+ if (config.cookieSameSite)
135
+ cookieEntries.push(` sameSite: ${config.cookieSameSite},`);
116
136
  const features = [
117
137
  ' features: [',
118
138
  ' withPersistentAuth({',
@@ -128,6 +148,13 @@ export const renderAuthProviderBody = (config) => {
128
148
  ' ],',
129
149
  ].join('\n');
130
150
  importsNeeded.push('withPersistentAuth');
131
- return { queries, features, importsNeeded, isScaffolded: true };
151
+ return { queries, features, importsNeeded, isScaffolded: true, warnings };
152
+ };
153
+ // A v3 `refreshStrategy` number from 0 to 1 is a fraction of the token lifetime, not milliseconds.
154
+ const renderRefreshStrategy = (refreshBuffer) => {
155
+ const value = Number(refreshBuffer.replace(/_/g, ''));
156
+ return Number.isFinite(value) && value > 1
157
+ ? refreshBuffer
158
+ : `{ minBufferMs: ${refreshBuffer}, maxBufferMs: ${refreshBuffer} }`;
132
159
  };
133
160
  //# sourceMappingURL=auth-provider-scaffold.js.map
@@ -1,5 +1,28 @@
1
1
  import * as ts from 'typescript';
2
- import { createSourceFile, ensureImportFromQuery, ensureNamedImports } from './shared.js';
2
+ import { createModuleGraph } from './module-graph.js';
3
+ import { isShadowedInNestedScope } from './rename-symbols.js';
4
+ import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, ensureImportFromQuery, ensureNamedImports, findRemovedExperimentalQueryHelpers, getLineNumber, printImportDeclaration, } from './shared.js';
5
+ export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
6
+ scope.visit(tree, (filePath) => {
7
+ if (!filePath.endsWith('.ts')) {
8
+ return;
9
+ }
10
+ const content = tree.read(filePath, 'utf-8');
11
+ if (!content?.includes('@ethlete/query')) {
12
+ return;
13
+ }
14
+ findRemovedExperimentalQueryHelpers(createSourceFile(content, filePath)).forEach(({ name, line }) => {
15
+ report.addWarning({
16
+ title: `Replace the removed helper ${name}`,
17
+ summary: `\`${name}\` came from the pre-v3 \`ExperimentalQuery\` namespace and is no longer exported by \`@ethlete/query\`, so this import does not compile. The migration does not rewrite it.`,
18
+ action: REMOVED_EXPERIMENTAL_QUERY_HELPERS[name],
19
+ locations: [{ filePath, line }],
20
+ source: 'cleanup-migration',
21
+ dedupeKey: `removed-experimental-helper:${filePath}:${name}`,
22
+ });
23
+ });
24
+ });
25
+ };
3
26
  /**
4
27
  * Points existing devtools usage at the v3 components instead of deleting it.
5
28
  *
@@ -69,8 +92,9 @@ export const replaceAnyQueryWithLegacy = (tree, scope) => {
69
92
  console.log(`\n✅ Replaced legacy AnyV2Query aliases in ${updatedFiles.length} files`);
70
93
  }
71
94
  };
72
- export const migrateEmptyPrepareCalls = (tree, scope) => {
95
+ export const migrateEmptyPrepareCalls = (tree, scope, report) => {
73
96
  const updatedFiles = [];
97
+ const graph = createModuleGraph(tree);
74
98
  scope.visit(tree, (filePath) => {
75
99
  if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
76
100
  return;
@@ -79,7 +103,17 @@ export const migrateEmptyPrepareCalls = (tree, scope) => {
79
103
  if (!content || !content.includes('.prepare()')) {
80
104
  return;
81
105
  }
82
- const nextContent = transformEmptyPrepareCalls(content);
106
+ const { content: nextContent, unresolved } = transformEmptyPrepareCalls(tree, graph, filePath, content);
107
+ unresolved.forEach(({ line, receiver }) => {
108
+ report.addManualReview({
109
+ title: `Check the empty prepare() call on ${receiver}`,
110
+ summary: `The migration could not tell whether \`${receiver}\` is a legacy query creator, so it left \`${receiver}.prepare()\` as is. A legacy creator needs \`.prepare({})\` in v3.`,
111
+ action: `Change the call to \`${receiver}.prepare({})\` if \`${receiver}\` is a legacy query creator, otherwise leave it.`,
112
+ locations: [{ filePath, line }],
113
+ source: 'cleanup-migration',
114
+ dedupeKey: `unresolved-empty-prepare:${filePath}:${line}`,
115
+ });
116
+ });
83
117
  if (nextContent !== content) {
84
118
  tree.write(filePath, nextContent);
85
119
  updatedFiles.push(filePath);
@@ -90,31 +124,36 @@ export const migrateEmptyPrepareCalls = (tree, scope) => {
90
124
  }
91
125
  };
92
126
  const migrateDevtoolsInFile = (content) => {
93
- const importsComponent = importsFromQuery(content, 'QueryDevtoolsComponent');
94
- let result = replaceDevtoolsProviderCalls(content);
127
+ const componentNames = localNamesImportedFromQuery(content, 'QueryDevtoolsComponent');
128
+ const providerNames = new Set([
129
+ 'provideQueryClientForDevtools',
130
+ ...localNamesImportedFromQuery(content, 'provideQueryClientForDevtools'),
131
+ ]);
132
+ let result = replaceDevtoolsProviderCalls(content, providerNames);
95
133
  const hasProvider = result.includes('provideQueryDevtools()');
96
134
  result = dropLegacyDevtoolsImports(result);
97
135
  if (hasProvider) {
98
136
  result = ensureImportFromQuery(result, ['provideQueryDevtools']);
99
137
  }
100
- if (importsComponent) {
138
+ if (componentNames.length > 0) {
101
139
  result = ensureNamedImports({
102
140
  content: result,
103
- importsNeeded: ['QueryDevtoolsComponent'],
141
+ importsNeeded: componentNames.map((name) => name === 'QueryDevtoolsComponent' ? name : `QueryDevtoolsComponent as ${name}`),
104
142
  moduleSpecifier: '@ethlete/query-devtools',
105
143
  });
106
144
  }
107
- return { content: result, importsComponent };
108
- };
109
- const importsFromQuery = (content, name) => {
110
- const sourceFile = createSourceFile(content);
111
- return sourceFile.statements.some((node) => ts.isImportDeclaration(node) &&
112
- ts.isStringLiteral(node.moduleSpecifier) &&
113
- node.moduleSpecifier.text === '@ethlete/query' &&
114
- !!node.importClause?.namedBindings &&
115
- ts.isNamedImports(node.importClause.namedBindings) &&
116
- node.importClause.namedBindings.elements.some((element) => element.name.text === name));
145
+ return { content: result, importsComponent: componentNames.length > 0 };
117
146
  };
147
+ const queryImports = (sourceFile) => sourceFile.statements.filter((node) => ts.isImportDeclaration(node) &&
148
+ ts.isStringLiteral(node.moduleSpecifier) &&
149
+ node.moduleSpecifier.text === '@ethlete/query');
150
+ const namedImportElements = (node) => node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)
151
+ ? node.importClause.namedBindings.elements
152
+ : [];
153
+ const importedName = (element) => (element.propertyName ?? element.name).text;
154
+ const localNamesImportedFromQuery = (content, name) => queryImports(createSourceFile(content)).flatMap((node) => namedImportElements(node)
155
+ .filter((element) => importedName(element) === name)
156
+ .map((element) => element.name.text));
118
157
  /**
119
158
  * Turns every `provideQueryClientForDevtools({ client, displayName })` into a single
120
159
  * `provideQueryDevtools()`.
@@ -123,13 +162,11 @@ const importsFromQuery = (content, name) => {
123
162
  * first call site keeps its position - it is already where the app wanted its devtools - and the
124
163
  * rest are removed along with the comma that separated them.
125
164
  */
126
- const replaceDevtoolsProviderCalls = (content) => {
165
+ const replaceDevtoolsProviderCalls = (content, providerNames) => {
127
166
  const sourceFile = createSourceFile(content);
128
167
  const calls = [];
129
168
  const visit = (node) => {
130
- if (ts.isCallExpression(node) &&
131
- ts.isIdentifier(node.expression) &&
132
- node.expression.text === 'provideQueryClientForDevtools') {
169
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && providerNames.has(node.expression.text)) {
133
170
  calls.push(node);
134
171
  }
135
172
  ts.forEachChild(node, visit);
@@ -169,30 +206,27 @@ const withSurroundingComma = (content, start, end) => {
169
206
  }
170
207
  return { start, end };
171
208
  };
172
- /** Drops the v2 devtools names from the `@ethlete/query` import; both moved or were renamed. */
209
+ const LEGACY_DEVTOOLS_IMPORTS = new Set(['QueryDevtoolsComponent', 'provideQueryClientForDevtools']);
210
+ /** Drops the v2 devtools names from the `@ethlete/query` imports; both moved or were renamed. */
173
211
  const dropLegacyDevtoolsImports = (content) => {
174
212
  const sourceFile = createSourceFile(content);
175
- let importNode;
176
- ts.forEachChild(sourceFile, (node) => {
177
- if (ts.isImportDeclaration(node) &&
178
- ts.isStringLiteral(node.moduleSpecifier) &&
179
- node.moduleSpecifier.text === '@ethlete/query') {
180
- importNode = node;
213
+ const replacements = [];
214
+ for (const node of queryImports(sourceFile)) {
215
+ const elements = namedImportElements(node);
216
+ const nextElements = elements.filter((element) => !LEGACY_DEVTOOLS_IMPORTS.has(importedName(element)));
217
+ if (nextElements.length === elements.length) {
218
+ continue;
181
219
  }
182
- });
183
- if (!importNode?.importClause?.namedBindings || !ts.isNamedImports(importNode.importClause.namedBindings)) {
184
- return content;
220
+ const nextImport = printImportDeclaration(node, sourceFile, nextElements.map((element) => element.getText(sourceFile)));
221
+ const end = !nextImport && content[node.getEnd()] === '\n' ? node.getEnd() + 1 : node.getEnd();
222
+ replacements.push({ start: node.getStart(sourceFile), end, replacement: nextImport });
185
223
  }
186
- const nextElements = importNode.importClause.namedBindings.elements.filter((element) => element.name.text !== 'QueryDevtoolsComponent' && element.name.text !== 'provideQueryClientForDevtools');
187
- if (nextElements.length === importNode.importClause.namedBindings.elements.length) {
188
- return content;
189
- }
190
- if (nextElements.length === 0) {
191
- const nextLineEnd = content[importNode.getEnd()] === '\n' ? importNode.getEnd() + 1 : importNode.getEnd();
192
- return content.slice(0, importNode.getStart(sourceFile)) + content.slice(nextLineEnd);
193
- }
194
- const nextImport = `import { ${nextElements.map((element) => element.getText(sourceFile)).join(', ')} } from '@ethlete/query';`;
195
- return content.slice(0, importNode.getStart(sourceFile)) + nextImport + content.slice(importNode.getEnd());
224
+ let result = content;
225
+ replacements.sort((left, right) => right.start - left.start);
226
+ replacements.forEach(({ start, end, replacement }) => {
227
+ result = result.slice(0, start) + replacement + result.slice(end);
228
+ });
229
+ return result;
196
230
  };
197
231
  const replaceAnyQueryInFile = (content) => {
198
232
  const sourceFile = createSourceFile(content);
@@ -224,20 +258,96 @@ const replaceAnyQueryInFile = (content) => {
224
258
  });
225
259
  return result;
226
260
  };
227
- const transformEmptyPrepareCalls = (content) => {
228
- const sourceFile = createSourceFile(content);
261
+ const findTopLevelVariable = (sourceFile, name) => {
262
+ for (const statement of sourceFile.statements) {
263
+ if (!ts.isVariableStatement(statement))
264
+ continue;
265
+ const declaration = statement.declarationList.declarations.find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === name);
266
+ if (declaration)
267
+ return declaration;
268
+ }
269
+ return undefined;
270
+ };
271
+ const findNamedImport = (sourceFile, localName) => {
272
+ for (const statement of sourceFile.statements) {
273
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
274
+ continue;
275
+ const element = namedImportElements(statement).find((candidate) => candidate.name.text === localName);
276
+ if (element)
277
+ return { specifier: statement.moduleSpecifier.text, importedName: importedName(element) };
278
+ }
279
+ return undefined;
280
+ };
281
+ const QUERY_CLIENT_CONSTRUCTORS = new Set(['V2QueryClient', 'QueryClient']);
282
+ /**
283
+ * Classifies what a top-level binding of `sourceFile` holds: a query creator (a call to an
284
+ * `@ethlete/query` export such as `createLegacyQueryCreator`, or a method call on a v2 client), something
285
+ * provably unrelated, or `unknown` when the binding cannot be followed.
286
+ */
287
+ const classifyBinding = (tree, graph, filePath, sourceFile, name, isClient, depth) => {
288
+ if (depth > 4)
289
+ return 'unknown';
290
+ const declaration = findTopLevelVariable(sourceFile, name);
291
+ if (declaration) {
292
+ const initializer = declaration.initializer;
293
+ if (isClient) {
294
+ return initializer &&
295
+ ts.isNewExpression(initializer) &&
296
+ ts.isIdentifier(initializer.expression) &&
297
+ QUERY_CLIENT_CONSTRUCTORS.has(initializer.expression.text) &&
298
+ findNamedImport(sourceFile, initializer.expression.text)?.specifier === '@ethlete/query'
299
+ ? 'query'
300
+ : 'foreign';
301
+ }
302
+ if (!initializer || !ts.isCallExpression(initializer))
303
+ return 'foreign';
304
+ const callee = initializer.expression;
305
+ if (ts.isIdentifier(callee)) {
306
+ return findNamedImport(sourceFile, callee.text)?.specifier === '@ethlete/query' ? 'query' : 'foreign';
307
+ }
308
+ if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
309
+ return classifyBinding(tree, graph, filePath, sourceFile, callee.expression.text, true, depth + 1);
310
+ }
311
+ return 'foreign';
312
+ }
313
+ const binding = findNamedImport(sourceFile, name);
314
+ if (!binding || binding.specifier === '@ethlete/query')
315
+ return 'unknown';
316
+ const declaringFile = graph.findDeclaringFile(filePath, binding.specifier, binding.importedName);
317
+ if (!declaringFile)
318
+ return graph.isWorkspaceSpecifier(binding.specifier) ? 'unknown' : 'foreign';
319
+ const declaringContent = tree.read(declaringFile, 'utf-8');
320
+ if (!declaringContent)
321
+ return 'unknown';
322
+ return classifyBinding(tree, graph, declaringFile, createSourceFile(declaringContent, declaringFile), binding.importedName, isClient, depth + 1);
323
+ };
324
+ const classifyPrepareReceiver = (tree, graph, filePath, sourceFile, receiver) => {
325
+ if (!ts.isIdentifier(receiver) || isShadowedInNestedScope(receiver))
326
+ return 'unknown';
327
+ return classifyBinding(tree, graph, filePath, sourceFile, receiver.text, false, 0);
328
+ };
329
+ const transformEmptyPrepareCalls = (tree, graph, filePath, content) => {
330
+ const sourceFile = createSourceFile(content, filePath);
229
331
  const replacements = [];
332
+ const unresolved = [];
230
333
  const visit = (node) => {
231
- if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
232
- const propertyAccess = node.expression;
233
- if (ts.isIdentifier(propertyAccess.name) &&
234
- propertyAccess.name.text === 'prepare' &&
235
- node.arguments.length === 0) {
236
- const callText = content.slice(node.getStart(sourceFile), node.getEnd());
334
+ if (ts.isCallExpression(node) &&
335
+ ts.isPropertyAccessExpression(node.expression) &&
336
+ ts.isIdentifier(node.expression.name) &&
337
+ node.expression.name.text === 'prepare' &&
338
+ node.arguments.length === 0) {
339
+ const kind = classifyPrepareReceiver(tree, graph, filePath, sourceFile, node.expression.expression);
340
+ if (kind === 'query') {
237
341
  replacements.push({
238
- start: node.getStart(sourceFile),
239
- end: node.getEnd(),
240
- replacement: callText.replace(/\.prepare\(\)/, '.prepare({})'),
342
+ start: node.arguments.pos,
343
+ end: node.arguments.end,
344
+ replacement: '{}',
345
+ });
346
+ }
347
+ else if (kind === 'unknown') {
348
+ unresolved.push({
349
+ line: getLineNumber(node, sourceFile),
350
+ receiver: node.expression.expression.getText(sourceFile),
241
351
  });
242
352
  }
243
353
  }
@@ -249,6 +359,6 @@ const transformEmptyPrepareCalls = (content) => {
249
359
  replacements.forEach(({ start, end, replacement }) => {
250
360
  result = result.slice(0, start) + replacement + result.slice(end);
251
361
  });
252
- return result;
362
+ return { content: result, unresolved };
253
363
  };
254
364
  //# sourceMappingURL=cleanup-migration.js.map
@@ -10,6 +10,7 @@ const LEGACY_HTTP_METHOD = {
10
10
  PATCH: 'patch',
11
11
  DELETE: 'delete',
12
12
  };
13
+ const LEGACY_GQL_METHODS = ['gqlQuery', 'gqlMutate'];
13
14
  const HTTP_OPTION_NAMES = ['reportProgress', 'responseType', 'transferCache', 'withCredentials'];
14
15
  const isLegacyHttpMethod = (value) => {
15
16
  return Object.values(LEGACY_HTTP_METHOD).includes(value);
@@ -219,6 +220,16 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
219
220
  ts.forEachChild(node, visit);
220
221
  return;
221
222
  }
223
+ if (oldClientNames.has(objectExpression.text) && LEGACY_GQL_METHODS.includes(methodExpression.text)) {
224
+ report.addManualReview({
225
+ title: `Rewrite the GraphQL creator ${node.name.text} by hand`,
226
+ summary: `${node.name.text} is built with \`${objectExpression.text}.${methodExpression.text}()\`. The migration only rewrites REST creators, so this one still calls a method the migrated client no longer has.`,
227
+ action: `Rebuild it on the v3 client with \`${methodExpression.text === 'gqlQuery' ? 'createGqlQueryViaPost' : 'createGqlMutationViaPost'}\` (or the \`ViaGet\` variant) and a \`gql\` document, then wrap it in \`createLegacyQueryCreator\` if its call sites stay on v2. See https://ethlete-sdk-docs.web.app/query/gql.`,
228
+ locations: [{ filePath, line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 }],
229
+ source: 'legacy-query-creator-migration',
230
+ dedupeKey: `gql-creator:${filePath}:${node.name.text}`,
231
+ });
232
+ }
222
233
  if (!oldClientNames.has(objectExpression.text) || !isLegacyHttpMethod(methodExpression.text)) {
223
234
  ts.forEachChild(node, visit);
224
235
  return;
@@ -270,7 +281,10 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
270
281
  if (HTTP_OPTION_NAMES.includes(property.name.text)) {
271
282
  info.httpOptions.set(property.name.text, property.name.text);
272
283
  }
273
- if (property.name.text !== 'route' && !HTTP_OPTION_NAMES.includes(property.name.text)) {
284
+ if (property.name.text === 'entity') {
285
+ info.entity = property.name.text;
286
+ }
287
+ if (!['route', 'entity'].includes(property.name.text) && !HTTP_OPTION_NAMES.includes(property.name.text)) {
274
288
  warnUnreadableConfigValue(property, 'as a shorthand property');
275
289
  }
276
290
  return;
@@ -281,6 +295,9 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
281
295
  if (property.name.text === 'route') {
282
296
  info.route = property.initializer.getText(sourceFile);
283
297
  }
298
+ if (property.name.text === 'entity') {
299
+ info.entity = property.initializer.getText(sourceFile);
300
+ }
284
301
  if (property.name.text === 'secure') {
285
302
  if (property.initializer.kind === ts.SyntaxKind.TrueKeyword) {
286
303
  info.secure = true;
@@ -329,6 +346,18 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
329
346
  }
330
347
  if (info.route) {
331
348
  creators.push(info);
349
+ if (info.entity) {
350
+ report.addFollowUp({
351
+ title: `Check the entity config carried onto ${toLegacyName(creatorName)}`,
352
+ summary: `${creatorName} had an \`entity\` config. It was moved onto the \`${toLegacyName(creatorName)}\` interop wrapper, which still writes to the EntityStore; the v3 creator \`${creatorName}\` does not, and v3 has no EntityStore.`,
353
+ action: 'Keep the call sites that rely on the store on the legacy wrapper. `set` now only runs on a real response, never on `prepare()` or a failure.',
354
+ locations: [
355
+ { filePath, line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 },
356
+ ],
357
+ source: 'legacy-query-creator-migration',
358
+ dedupeKey: `entity-config:${filePath}:${creatorName}`,
359
+ });
360
+ }
332
361
  }
333
362
  }
334
363
  ts.forEachChild(node, visit);
@@ -509,7 +538,7 @@ const getTypeParameter = (creator) => {
509
538
  const generateLegacyWrapper = (creator, exportPrefix) => {
510
539
  // `name` is what lets a `prepare()` called outside an injection context name itself in the error
511
540
  // instead of surfacing as a bare NG0203.
512
- return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\n${exportPrefix}const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
541
+ return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\n${exportPrefix}const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name}${creator.entity ? `, entity: ${creator.entity}` : ''} });`;
513
542
  };
514
543
  const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, creatorToClient, }) => {
515
544
  const createdProviders = [];
@@ -532,6 +561,14 @@ const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, cre
532
561
  }
533
562
  tree.write(filePath, nextContent);
534
563
  createdProviders.push(filePath);
564
+ body.warnings.forEach((warning) => {
565
+ report.addWarning({
566
+ ...warning,
567
+ locations: [{ filePath }, ...(v2Config ? [{ filePath: v2Config.filePath, line: v2Config.line }] : [])],
568
+ source: 'legacy-query-creator-migration',
569
+ dedupeKey: `auth-provider-warning:${filePath}:${authProviderName}:${warning.title}`,
570
+ });
571
+ });
535
572
  if (body.isScaffolded) {
536
573
  report.addFollowUp({
537
574
  title: `Finish the scaffolded auth provider ${authProviderName}`,
@@ -24,6 +24,7 @@ export const createMigrationScope = (tree, options) => {
24
24
  return {
25
25
  visit: (targetTree, callback) => visitNotIgnoredFiles(targetTree, '', callback),
26
26
  describe: () => 'the whole workspace',
27
+ isScoped: false,
27
28
  };
28
29
  }
29
30
  return {
@@ -33,6 +34,7 @@ export const createMigrationScope = (tree, options) => {
33
34
  }
34
35
  },
35
36
  describe: () => roots.join(', '),
37
+ isScoped: true,
36
38
  };
37
39
  };
38
40
  //# sourceMappingURL=migration-scope.js.map