@ethlete/query 6.0.0-next.62 → 6.0.0-next.63

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.
@@ -349,11 +349,37 @@ export const removeFeatureStores = (tree, graph, inScope, features, report) => {
349
349
  const content = tree.read(file, 'utf-8');
350
350
  return content ? createSourceFile(content, file).statements.flatMap(declaredNames) : [];
351
351
  };
352
- let converting = new Set(features);
352
+ const importClosure = (feature, roots) => {
353
+ const storeFiles = new Set(storeFilesOf(feature));
354
+ const closure = new Set(roots);
355
+ const queue = [...closure];
356
+ for (let file = queue.pop(); file; file = queue.pop()) {
357
+ const content = tree.read(file, 'utf-8');
358
+ if (!content)
359
+ continue;
360
+ for (const statement of createSourceFile(content, file).statements) {
361
+ if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) ||
362
+ !statement.moduleSpecifier ||
363
+ !ts.isStringLiteral(statement.moduleSpecifier)) {
364
+ continue;
365
+ }
366
+ const resolved = graph.resolveFile(file, statement.moduleSpecifier.text);
367
+ if (resolved && storeFiles.has(resolved) && !closure.has(resolved)) {
368
+ closure.add(resolved);
369
+ queue.push(resolved);
370
+ }
371
+ }
372
+ }
373
+ return closure;
374
+ };
375
+ const converting = new Set(features);
353
376
  let scan;
354
377
  const keptLocations = new Map();
378
+ const keptFiles = new Map();
379
+ const keptVia = new Map();
380
+ const removableFilesOf = (feature) => storeFilesOf(feature).filter((file) => !keptFiles.has(file));
355
381
  for (;;) {
356
- const deletedFiles = new Set([...converting].flatMap(storeFilesOf));
382
+ const deletedFiles = new Set([...converting].flatMap(removableFilesOf));
357
383
  const movedFiles = new Map([...converting].map((feature) => [feature.files.actions, feature.queriesFile]));
358
384
  const needles = [
359
385
  ...new Set([...deletedFiles].flatMap((file) => [...declaredIn(file), file.split('/').pop().replace(/\.ts$/, '')])),
@@ -371,20 +397,34 @@ export const removeFeatureStores = (tree, graph, inScope, features, report) => {
371
397
  movedFiles,
372
398
  needles,
373
399
  });
374
- const blocked = new Set();
400
+ const usedOutside = new Map();
375
401
  for (const reference of scan.references) {
376
402
  const feature = featureOfFile.get(reference.declaringFile);
377
403
  if (!feature || reference.target || !converting.has(feature))
378
404
  continue;
379
- blocked.add(feature);
380
- keptLocations.set(feature, [
381
- ...(keptLocations.get(feature) ?? []),
382
- { filePath: reference.file, line: reference.line },
383
- ]);
405
+ const files = usedOutside.get(feature) ?? new Map();
406
+ const location = { filePath: reference.file, line: reference.line };
407
+ files.set(reference.declaringFile, [...(files.get(reference.declaringFile) ?? []), location]);
408
+ usedOutside.set(feature, files);
409
+ keptLocations.set(feature, [...(keptLocations.get(feature) ?? []), location]);
384
410
  }
385
- if (blocked.size === 0)
411
+ if (usedOutside.size === 0)
386
412
  break;
387
- converting = new Set([...converting].filter((feature) => !blocked.has(feature)));
413
+ for (const [feature, files] of usedOutside) {
414
+ const closure = importClosure(feature, [...keptFiles.keys(), ...files.keys()].filter((file) => featureOfFile.get(file) === feature));
415
+ if (closure.has(feature.files.actions) || (feature.files.reducer && closure.has(feature.files.reducer))) {
416
+ converting.delete(feature);
417
+ continue;
418
+ }
419
+ for (const [file, locations] of files)
420
+ keptFiles.set(file, [...(keptFiles.get(file) ?? []), ...locations]);
421
+ for (const file of closure) {
422
+ if (keptFiles.has(file))
423
+ continue;
424
+ keptFiles.set(file, []);
425
+ keptVia.set(file, [...files.keys()][0]);
426
+ }
427
+ }
388
428
  }
389
429
  for (const [feature, locations] of keptLocations) {
390
430
  if (converting.has(feature))
@@ -395,7 +435,20 @@ export const removeFeatureStores = (tree, graph, inScope, features, report) => {
395
435
  locations: [{ filePath: feature.files.actions }, ...locations],
396
436
  });
397
437
  }
398
- const deletedFiles = new Set([...converting].flatMap(storeFilesOf));
438
+ for (const [file, locations] of keptFiles) {
439
+ const feature = featureOfFile.get(file);
440
+ if (!converting.has(feature))
441
+ continue;
442
+ const via = keptVia.get(file);
443
+ report.add({
444
+ id: TOOLKIT_TASK.STORE_FILE_KEPT,
445
+ summary: via
446
+ ? `\`${file}\` of \`${feature.base}\` is kept because the kept \`${via}\` imports it.`
447
+ : `\`${file}\` of \`${feature.base}\` is kept because code outside the feature uses it.`,
448
+ locations: [{ filePath: file }, ...locations],
449
+ });
450
+ }
451
+ const deletedFiles = new Set([...converting].flatMap(removableFilesOf));
399
452
  const movedFiles = new Map([...converting].map((feature) => [feature.files.actions, feature.queriesFile]));
400
453
  const editedFiles = new Set();
401
454
  const applyScan = (result, extraEdits) => {
@@ -176,7 +176,7 @@ export const renderAuthProviderBody = (config) => {
176
176
  }
177
177
  const refreshEntries = [` queryCreator: ${config.refreshCreatorName},`];
178
178
  if (config.responseAdapter) {
179
- refreshEntries.push(' extractTokens: (response) => {', ` const { token, refreshToken } = (${config.responseAdapter})(response);`, '', ' return { accessToken: token, refreshToken };', ' },');
179
+ refreshEntries.push(' extractTokens: (response) => {', ` const { token, refreshToken } = (${config.responseAdapter})(response) as { token: string; refreshToken: string };`, '', ' return { accessToken: token, refreshToken };', ' },');
180
180
  }
181
181
  if (config.requestArgsAdapter) {
182
182
  refreshEntries.push(` // TODO(query-v3): v2 requestArgsAdapter took { token, refreshToken }; buildArgs takes the refresh token alone.`, ` buildArgs: ${config.requestArgsAdapter},`);
@@ -1,6 +1,6 @@
1
1
  import * as ts from 'typescript';
2
2
  import { createModuleGraph } from './module-graph.js';
3
- import { isShadowedInNestedScope } from './rename-symbols.js';
3
+ import { findUsedNames, isShadowedInNestedScope, pruneUnusedNamedImports } from './rename-symbols.js';
4
4
  import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, ensureImportFromQuery, ensureNamedImports, findRemovedExperimentalQueryHelpers, getLineNumber, printImportDeclaration, } from './shared.js';
5
5
  export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
6
6
  scope.visit(tree, (filePath) => {
@@ -34,10 +34,16 @@ export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
34
34
  export const migrateDevtoolsUsage = (tree, scope, report) => {
35
35
  const updatedFiles = [];
36
36
  const filesNeedingDevtoolsPackage = [];
37
+ let mountsFromProvider = false;
38
+ scope.visit(tree, (filePath) => {
39
+ if (filePath.endsWith('.ts') && tree.read(filePath, 'utf-8')?.includes('provideQueryClientForDevtools')) {
40
+ mountsFromProvider = true;
41
+ }
42
+ });
37
43
  scope.visit(tree, (filePath) => {
38
44
  if (filePath.endsWith('.html')) {
39
45
  const html = tree.read(filePath, 'utf-8');
40
- const nextHtml = html && selectLazyDevtools(html);
46
+ const nextHtml = html && (mountsFromProvider ? removeDevtoolsTags(html) : selectLazyDevtools(html));
41
47
  if (nextHtml && nextHtml !== html) {
42
48
  tree.write(filePath, nextHtml);
43
49
  updatedFiles.push(filePath);
@@ -52,13 +58,14 @@ export const migrateDevtoolsUsage = (tree, scope, report) => {
52
58
  (!content.includes('provideQueryClientForDevtools') && !content.includes('QueryDevtoolsComponent'))) {
53
59
  return;
54
60
  }
55
- const { content: nextContent, importsComponent } = migrateDevtoolsInFile(content);
61
+ const { content: migrated, importsComponent, mountsDevtools } = migrateDevtoolsInFile(content, mountsFromProvider);
62
+ const nextContent = pruneUnusedNamedImports(mountsFromProvider ? removeDevtoolsTags(migrated) : migrated, findUsedNames(content));
56
63
  if (nextContent === content) {
57
64
  return;
58
65
  }
59
66
  tree.write(filePath, nextContent);
60
67
  updatedFiles.push(filePath);
61
- if (importsComponent) {
68
+ if (importsComponent || mountsDevtools) {
62
69
  filesNeedingDevtoolsPackage.push(filePath);
63
70
  }
64
71
  });
@@ -73,7 +80,7 @@ export const migrateDevtoolsUsage = (tree, scope, report) => {
73
80
  // `@ethlete/query-devtools`.
74
81
  report.addManualReview({
75
82
  title: 'Add @ethlete/query-devtools for the query devtools',
76
- summary: '`QueryDevtoolsComponent` is now `QueryDevtoolsLazyComponent` from `@ethlete/query-devtools/lazy`, and `<et-query-devtools>` is `<et-query-devtools-lazy>`. The imports and templates were rewritten, but the package may not be a dependency of these projects yet.',
83
+ summary: "`provideQueryDevtools()` now mounts the devtools through `ui: () => import('@ethlete/query-devtools/lazy')`, so `<et-query-devtools>` and its component import were removed. Where no devtools provider was found, `QueryDevtoolsLazyComponent` and `<et-query-devtools-lazy>` replace them. The package may not be a dependency of these projects yet.",
77
84
  action: 'Run `yarn add @ethlete/query-devtools` where needed, then `yarn install` and re-lint the affected libs so the `@nx/dependency-checks` rule sees the new dependency.',
78
85
  locations: filesNeedingDevtoolsPackage.map((filePath) => ({ filePath })),
79
86
  source: 'cleanup-migration',
@@ -122,6 +129,27 @@ export const reportLeftoverV2Clients = (tree, scope, report) => {
122
129
  });
123
130
  });
124
131
  };
132
+ export const reportLeftoverLegacyDevtools = (tree, scope, report) => {
133
+ scope.visit(tree, (filePath) => {
134
+ if (filePath.endsWith('.spec.ts') || (!filePath.endsWith('.ts') && !filePath.endsWith('.html'))) {
135
+ return;
136
+ }
137
+ const content = tree.read(filePath, 'utf-8');
138
+ const index = content?.search(/<et-query-devtools(?![\w-])|\b(QueryDevtoolsComponent|provideQueryClientForDevtools)\b/);
139
+ if (!content || index === undefined || index < 0) {
140
+ return;
141
+ }
142
+ const line = content.slice(0, index).split('\n').length;
143
+ report.addManualReview({
144
+ title: 'Migrator error: legacy devtools usage is left',
145
+ summary: 'The migration should leave no legacy `<et-query-devtools>`, `QueryDevtoolsComponent` or `provideQueryClientForDevtools` behind, so it missed this one. Report this case as a migrator bug.',
146
+ action: 'Switch it by hand to `provideQueryDevtools()` and `QueryDevtoolsLazyComponent` from `@ethlete/query-devtools/lazy`, then report the shape the migration did not handle.',
147
+ locations: [{ filePath, line }],
148
+ source: 'cleanup-migration',
149
+ dedupeKey: `leftover-legacy-devtools:${filePath}:${line}`,
150
+ });
151
+ });
152
+ };
125
153
  export const migrateEmptyPrepareCalls = (tree, scope, report) => {
126
154
  const updatedFiles = [];
127
155
  const graph = createModuleGraph(tree);
@@ -154,7 +182,32 @@ export const migrateEmptyPrepareCalls = (tree, scope, report) => {
154
182
  }
155
183
  };
156
184
  const LAZY_DEVTOOLS_COMPONENT = 'QueryDevtoolsLazyComponent';
185
+ const DEVTOOLS_UI_PROVIDER_CALL = "provideQueryDevtools({ ui: () => import('@ethlete/query-devtools/lazy') })";
157
186
  const selectLazyDevtools = (content) => content.replace(/(<\/?)et-query-devtools(?![\w-])/g, '$1et-query-devtools-lazy');
187
+ const removeDevtoolsTags = (content) => content.replace(/[ \t]*<et-query-devtools(?![\w-])[^>]*?(?:\/>|>\s*<\/et-query-devtools>)[ \t]*\n?/g, '');
188
+ const removeFromImportsArrays = (content, names) => {
189
+ const sourceFile = createSourceFile(content);
190
+ const ranges = [];
191
+ const visit = (node) => {
192
+ if (ts.isPropertyAssignment(node) &&
193
+ ts.isIdentifier(node.name) &&
194
+ node.name.text === 'imports' &&
195
+ ts.isArrayLiteralExpression(node.initializer)) {
196
+ node.initializer.elements
197
+ .filter((element) => ts.isIdentifier(element) && names.has(element.text))
198
+ .forEach((element) => {
199
+ const range = withSurroundingComma(content, element.getStart(sourceFile), element.getEnd());
200
+ const padding = content[range.start - 1] === '[' ? (/^[ \t]*/.exec(content.slice(range.end))?.[0] ?? '') : '';
201
+ ranges.push({ start: range.start, end: range.end + padding.length });
202
+ });
203
+ }
204
+ ts.forEachChild(node, visit);
205
+ };
206
+ visit(sourceFile);
207
+ return ranges
208
+ .sort((left, right) => right.start - left.start)
209
+ .reduce((result, { start, end }) => result.slice(0, start) + result.slice(end), content);
210
+ };
158
211
  const renameIdentifiers = (content, names, replacement) => {
159
212
  const sourceFile = createSourceFile(content);
160
213
  const ranges = [];
@@ -172,19 +225,23 @@ const renameIdentifiers = (content, names, replacement) => {
172
225
  .sort((left, right) => right.start - left.start)
173
226
  .reduce((result, { start, end }) => result.slice(0, start) + replacement + result.slice(end), content);
174
227
  };
175
- const migrateDevtoolsInFile = (content) => {
228
+ const migrateDevtoolsInFile = (content, mountsFromProvider) => {
176
229
  const componentNames = localNamesImportedFromQuery(content, 'QueryDevtoolsComponent');
177
230
  const providerNames = new Set([
178
231
  'provideQueryClientForDevtools',
179
232
  ...localNamesImportedFromQuery(content, 'provideQueryClientForDevtools'),
180
233
  ]);
181
- let result = replaceDevtoolsProviderCalls(content, providerNames);
182
- const hasProvider = result.includes('provideQueryDevtools()');
234
+ const providerCall = mountsFromProvider ? DEVTOOLS_UI_PROVIDER_CALL : 'provideQueryDevtools()';
235
+ let result = replaceDevtoolsProviderCalls(content, providerNames, providerCall);
236
+ const hasProvider = result.includes(providerCall);
183
237
  result = dropLegacyDevtoolsImports(result);
184
238
  if (hasProvider) {
185
239
  result = ensureImportFromQuery(result, ['provideQueryDevtools']);
186
240
  }
187
- if (componentNames.length > 0) {
241
+ if (componentNames.length > 0 && mountsFromProvider) {
242
+ result = removeFromImportsArrays(result, new Set(componentNames));
243
+ }
244
+ else if (componentNames.length > 0) {
188
245
  result = selectLazyDevtools(renameIdentifiers(result, new Set(componentNames), LAZY_DEVTOOLS_COMPONENT));
189
246
  result = ensureNamedImports({
190
247
  content: result,
@@ -192,7 +249,11 @@ const migrateDevtoolsInFile = (content) => {
192
249
  moduleSpecifier: '@ethlete/query-devtools/lazy',
193
250
  });
194
251
  }
195
- return { content: result, importsComponent: componentNames.length > 0 };
252
+ return {
253
+ content: result,
254
+ importsComponent: componentNames.length > 0 && !mountsFromProvider,
255
+ mountsDevtools: hasProvider && mountsFromProvider,
256
+ };
196
257
  };
197
258
  const queryImports = (sourceFile) => sourceFile.statements.filter((node) => ts.isImportDeclaration(node) &&
198
259
  ts.isStringLiteral(node.moduleSpecifier) &&
@@ -212,7 +273,7 @@ const localNamesImportedFromQuery = (content, name) => queryImports(createSource
212
273
  * first call site keeps its position - it is already where the app wanted its devtools - and the
213
274
  * rest are removed along with the comma that separated them.
214
275
  */
215
- const replaceDevtoolsProviderCalls = (content, providerNames) => {
276
+ const replaceDevtoolsProviderCalls = (content, providerNames, providerCall) => {
216
277
  const sourceFile = createSourceFile(content);
217
278
  const calls = [];
218
279
  const visit = (node) => {
@@ -229,7 +290,7 @@ const replaceDevtoolsProviderCalls = (content, providerNames) => {
229
290
  const start = call.getStart(sourceFile);
230
291
  const end = call.getEnd();
231
292
  if (index === 0) {
232
- return { start, end, replacement: 'provideQueryDevtools()' };
293
+ return { start, end, replacement: providerCall };
233
294
  }
234
295
  return { ...withSurroundingComma(content, start, end), replacement: '' };
235
296
  });
@@ -1,4 +1,5 @@
1
1
  import * as ts from 'typescript';
2
+ import { findUsedNames, pruneUnusedNamedImports } from './rename-symbols.js';
2
3
  import { createSourceFile, ensureAngularCoreImports, ensureImportFromQuery, getIndentation, getLineNumber, getLineNumberFromPosition, } from './shared.js';
3
4
  /** The creator methods that resolve an injector, and so throw `ET950` when called without one. */
4
5
  const INJECTOR_METHODS = new Set(['prepare', 'createSubject', 'createSignal', 'behaviorSubject']);
@@ -412,12 +413,11 @@ const transformLegacyCreatorPrepareCalls = (tree, classInjectors, pollingInfo, s
412
413
  }
413
414
  });
414
415
  filesByPath.forEach((classes, filePath) => {
415
- let content = tree.read(filePath, 'utf-8');
416
+ const content = tree.read(filePath, 'utf-8');
416
417
  if (!content) {
417
418
  return;
418
419
  }
419
- content = ensureAngularCoreImports(content, ['inject', 'Injector']);
420
- const nextContent = transformPrepareCallsInFile(content, classes, pollingInfo);
420
+ const nextContent = pruneUnusedNamedImports(transformPrepareCallsInFile(ensureAngularCoreImports(content, ['inject', 'Injector']), classes, pollingInfo), new Set([...findUsedNames(content), 'inject', 'Injector']));
421
421
  if (nextContent !== content) {
422
422
  tree.write(filePath, nextContent);
423
423
  updatedFiles.push(filePath);
@@ -493,15 +493,22 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
493
493
  else if (containingFunction) {
494
494
  const functionWithInject = findOutermostFunctionWithInject(containingFunction);
495
495
  if (!functionWithInject) {
496
+ if (propertyAccess.name.text === 'prepare' && isInsideAsyncValidator(node)) {
497
+ replacements.push({
498
+ start: node.getStart(sourceFile),
499
+ end: node.getEnd(),
500
+ replacement: transformSinglePrepareCall(node, undefined, sourceFile, pollingInfo),
501
+ });
502
+ }
496
503
  ts.forEachChild(node, visit);
497
504
  return;
498
505
  }
499
506
  const functionPosition = functionWithInject.getStart(sourceFile);
500
507
  if (!functionsNeedingInjector.has(functionPosition)) {
501
- const existingInjectorParameter = findInjectorParameter(functionWithInject);
502
- if (existingInjectorParameter) {
508
+ const existingInjector = findExistingInjector(functionWithInject);
509
+ if (existingInjector) {
503
510
  functionsNeedingInjector.set(functionPosition, {
504
- injectorName: existingInjectorParameter,
511
+ injectorName: existingInjector,
505
512
  bodyStart: -1,
506
513
  needsDeclaration: false,
507
514
  });
@@ -561,7 +568,7 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
561
568
  });
562
569
  return rewrittenComputeds.size > 0 ? ensureImportFromQuery(result, ['queryComputed']) : result;
563
570
  };
564
- const findInjectorParameter = (func) => {
571
+ const findExistingInjector = (func) => {
565
572
  for (const parameter of func.parameters) {
566
573
  if (!ts.isIdentifier(parameter.name)) {
567
574
  continue;
@@ -573,6 +580,23 @@ const findInjectorParameter = (func) => {
573
580
  return parameter.name.text;
574
581
  }
575
582
  }
583
+ if (!func.body || !ts.isBlock(func.body))
584
+ return undefined;
585
+ for (const statement of func.body.statements) {
586
+ if (!ts.isVariableStatement(statement))
587
+ continue;
588
+ for (const declaration of statement.declarationList.declarations) {
589
+ const initializer = declaration.initializer;
590
+ if (ts.isIdentifier(declaration.name) &&
591
+ initializer &&
592
+ ts.isCallExpression(initializer) &&
593
+ ts.isIdentifier(initializer.expression) &&
594
+ initializer.expression.text === 'inject' &&
595
+ initializer.arguments[0]?.getText() === 'Injector') {
596
+ return declaration.name.text;
597
+ }
598
+ }
599
+ }
576
600
  return undefined;
577
601
  };
578
602
  const getFunctionBodyStart = (func, sourceFile) => {
@@ -676,7 +700,8 @@ const transformSinglePrepareCall = (callNode, injectorReference, sourceFile, pol
676
700
  spreadExpression = existingArgument.getText(sourceFile);
677
701
  }
678
702
  }
679
- nextArgumentProperties.push(`injector: ${injectorReference}`);
703
+ if (injectorReference)
704
+ nextArgumentProperties.push(`injector: ${injectorReference}`);
680
705
  const queryVariableName = findQueryVariableNameForPrepareCall(callNode, sourceFile);
681
706
  const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo, isDiscardedPrepareCall(callNode), isOneShotLocalVariable(callNode) || isInsideAsyncValidator(callNode));
682
707
  if (existingConfig && ts.isObjectLiteralExpression(existingConfig.initializer)) {
@@ -984,22 +1009,30 @@ const getPlainComputedHost = (node) => {
984
1009
  const ASYNC_VALIDATOR_PROPERTIES = new Set(['asyncValidator', 'asyncValidators']);
985
1010
  const ASYNC_VALIDATOR_SETTERS = new Set(['setAsyncValidators', 'addAsyncValidators']);
986
1011
  /**
987
- * Whether the innermost function around the call is an Angular async validator: a value of an
988
- * `asyncValidator(s)` property or setter argument, an `AsyncValidatorFn`, or `validate` of an `AsyncValidator`.
1012
+ * Whether a function around the call is an Angular async validator: a value of an `asyncValidator(s)` property or
1013
+ * setter argument, an `AsyncValidatorFn` or a factory of one, a function returning `Observable`/`Promise` of
1014
+ * `ValidationErrors`, or `validate` of an `AsyncValidator`.
989
1015
  */
990
1016
  const isInsideAsyncValidator = (callNode) => {
991
- let fn = callNode.parent;
992
- while (fn && !isCallbackFunction(fn) && !ts.isMethodDeclaration(fn))
993
- fn = fn.parent;
994
- if (!fn)
995
- return false;
996
- if (ts.isMethodDeclaration(fn)) {
997
- const owner = fn.parent;
998
- return (ts.isIdentifier(fn.name) &&
999
- fn.name.text === 'validate' &&
1000
- ts.isClassDeclaration(owner) &&
1001
- !!owner.heritageClauses?.some((clause) => clause.types.some((type) => type.expression.getText() === 'AsyncValidator')));
1017
+ for (let node = callNode.parent; node; node = node.parent) {
1018
+ if (ts.isMethodDeclaration(node))
1019
+ return isAsyncValidatorMethod(node);
1020
+ if (isCallbackFunction(node) && isAsyncValidatorFunction(node))
1021
+ return true;
1002
1022
  }
1023
+ return false;
1024
+ };
1025
+ const isAsyncValidatorMethod = (method) => {
1026
+ const owner = method.parent;
1027
+ return (ts.isIdentifier(method.name) &&
1028
+ method.name.text === 'validate' &&
1029
+ ts.isClassDeclaration(owner) &&
1030
+ !!owner.heritageClauses?.some((clause) => clause.types.some((type) => type.expression.getText() === 'AsyncValidator')));
1031
+ };
1032
+ const ASYNC_VALIDATOR_SIGNATURE = /\bAsyncValidatorFn\b|\b(?:Observable|Promise)<\s*ValidationErrors\b/;
1033
+ const isAsyncValidatorFunction = (fn) => {
1034
+ if (fn.type && ASYNC_VALIDATOR_SIGNATURE.test(fn.type.getText()))
1035
+ return true;
1003
1036
  let value = fn;
1004
1037
  if (ts.isArrayLiteralExpression(value.parent))
1005
1038
  value = value.parent;
@@ -210,9 +210,13 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report,
210
210
  const sourceFile = createSourceFile(content, filePath);
211
211
  const creators = [];
212
212
  const oldClientNames = new Set();
213
+ const renamedOwnClients = new Map();
213
214
  queryClientFiles.forEach((configNames) => {
214
215
  configNames.forEach((configName) => oldClientNames.add(configName));
215
216
  });
217
+ queryClientFiles.get(filePath)?.forEach((configName) => {
218
+ renamedOwnClients.set(ensureConfigSuffix(configName), configName);
219
+ });
216
220
  const visit = (node) => {
217
221
  if (ts.isVariableDeclaration(node) &&
218
222
  ts.isIdentifier(node.name) &&
@@ -225,27 +229,15 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report,
225
229
  ts.forEachChild(node, visit);
226
230
  return;
227
231
  }
228
- if (oldClientNames.has(objectExpression.text) && LEGACY_GQL_METHODS.includes(methodExpression.text)) {
229
- const isQuery = methodExpression.text === 'gqlQuery';
230
- const viaGet = isQuery && gqlGetClients.has(objectExpression.text);
231
- const creator = isQuery ? `createGqlQueryVia${viaGet ? 'Get' : 'Post'}` : 'createGqlMutationViaPost';
232
- report.addManualReview({
233
- title: `Rewrite the GraphQL creator ${node.name.text} by hand`,
234
- 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.${viaGet ? " The v2 client sent GraphQL queries with `request.gql.transferVia: 'GET'`, which v3 sets per creator." : ''}`,
235
- action: `Rebuild it on the v3 client with \`${creator}\` 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.`,
236
- locations: [{ filePath, line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 }],
237
- source: 'legacy-query-creator-migration',
238
- dedupeKey: `gql-creator:${filePath}:${node.name.text}`,
239
- });
240
- }
241
- if (!oldClientNames.has(objectExpression.text) || !isLegacyHttpMethod(methodExpression.text)) {
232
+ const clientName = renamedOwnClients.get(objectExpression.text) ?? objectExpression.text;
233
+ if (!oldClientNames.has(clientName) || !isLegacyHttpMethod(methodExpression.text)) {
242
234
  ts.forEachChild(node, visit);
243
235
  return;
244
236
  }
245
237
  const creatorName = node.name.text;
246
238
  const info = {
247
239
  name: creatorName,
248
- clientName: objectExpression.text,
240
+ clientName,
249
241
  method: methodExpression.text,
250
242
  route: '',
251
243
  secure: false,
@@ -371,8 +363,109 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report,
371
363
  ts.forEachChild(node, visit);
372
364
  };
373
365
  visit(sourceFile);
366
+ reportLegacyGqlCalls(sourceFile, filePath, oldClientNames, gqlGetClients, report);
374
367
  return creators;
375
368
  };
369
+ const collectClientAliases = (sourceFile, oldClientNames) => {
370
+ const aliases = new Map();
371
+ const resolve = (expression) => {
372
+ if (!ts.isIdentifier(expression)) {
373
+ return undefined;
374
+ }
375
+ return aliases.get(expression.text) ?? (oldClientNames.has(expression.text) ? expression.text : undefined);
376
+ };
377
+ const visitImports = (node) => {
378
+ if (ts.isImportSpecifier(node) && node.propertyName && oldClientNames.has(node.propertyName.text)) {
379
+ aliases.set(node.name.text, node.propertyName.text);
380
+ }
381
+ ts.forEachChild(node, visitImports);
382
+ };
383
+ visitImports(sourceFile);
384
+ const aliasDeclarations = [];
385
+ const collectDeclarations = (node) => {
386
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
387
+ aliasDeclarations.push({ name: node.name.text, initializer: node.initializer });
388
+ }
389
+ ts.forEachChild(node, collectDeclarations);
390
+ };
391
+ collectDeclarations(sourceFile);
392
+ const addAlias = ({ name, initializer }) => {
393
+ const clientName = resolve(initializer);
394
+ if (!clientName || clientName === name || aliases.has(name)) {
395
+ return false;
396
+ }
397
+ aliases.set(name, clientName);
398
+ return true;
399
+ };
400
+ while (aliasDeclarations.filter(addAlias).length > 0) {
401
+ continue;
402
+ }
403
+ return resolve;
404
+ };
405
+ const describeGqlCall = (call, sourceFile) => {
406
+ const path = [];
407
+ let node = call;
408
+ while (node.parent && !ts.isSourceFile(node.parent)) {
409
+ node = node.parent;
410
+ if (ts.isPropertyAssignment(node)) {
411
+ path.unshift(node.name.getText(sourceFile));
412
+ continue;
413
+ }
414
+ if (ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node)) {
415
+ if (node.name && ts.isIdentifier(node.name)) {
416
+ path.unshift(node.name.text);
417
+ }
418
+ break;
419
+ }
420
+ if (ts.isPropertyDeclaration(node) || ts.isMethodDeclaration(node)) {
421
+ path.unshift(node.name.getText(sourceFile));
422
+ if (ts.isClassLike(node.parent) && node.parent.name) {
423
+ path.unshift(node.parent.name.text);
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ return path.length > 0
429
+ ? path.join('.')
430
+ : `at line ${sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile)).line + 1}`;
431
+ };
432
+ const reportLegacyGqlCalls = (sourceFile, filePath, oldClientNames, gqlGetClients, report) => {
433
+ const resolveClient = collectClientAliases(sourceFile, oldClientNames);
434
+ const visit = (node) => {
435
+ if (ts.isCallExpression(node) &&
436
+ ts.isPropertyAccessExpression(node.expression) &&
437
+ LEGACY_GQL_METHODS.includes(node.expression.name.text)) {
438
+ const receiver = node.expression.expression;
439
+ const receiverText = receiver.getText(sourceFile);
440
+ const method = node.expression.name.text;
441
+ const clientName = resolveClient(receiver);
442
+ const name = describeGqlCall(node, sourceFile);
443
+ const isQuery = method === 'gqlQuery';
444
+ const viaGet = isQuery && !!clientName && gqlGetClients.has(clientName);
445
+ const creator = !isQuery
446
+ ? '`createGqlMutationViaPost`'
447
+ : viaGet
448
+ ? '`createGqlQueryViaGet`'
449
+ : clientName || gqlGetClients.size === 0
450
+ ? '`createGqlQueryViaPost`'
451
+ : "`createGqlQueryViaPost` (or `createGqlQueryViaGet` if its client set `request.gql.transferVia: 'GET'`)";
452
+ const builtWith = clientName
453
+ ? `\`${receiverText}.${method}()\``
454
+ : `\`${receiverText}.${method}()\`, which the migration takes for a v2 query client`;
455
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
456
+ report.addManualReview({
457
+ title: `Rewrite the GraphQL creator ${name} by hand`,
458
+ summary: `${name} is built with ${builtWith}. The migration only rewrites REST creators, so this one still calls a method the migrated client no longer has, and the callback parameters in its config lose their types: expect implicit-any errors (TS7006) there until it is rebuilt.${viaGet ? " The v2 client sent GraphQL queries with `request.gql.transferVia: 'GET'`, which v3 sets per creator." : ''}`,
459
+ action: `Rebuild it on the v3 client with ${creator} 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.`,
460
+ locations: [{ filePath, line }],
461
+ source: 'legacy-query-creator-migration',
462
+ dedupeKey: `gql-creator:${filePath}:${name}:${line}`,
463
+ });
464
+ }
465
+ ts.forEachChild(node, visit);
466
+ };
467
+ visit(sourceFile);
468
+ };
376
469
  const extractTypeFromDef = (node, sourceFile) => {
377
470
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'def') {
378
471
  if (node.typeArguments && node.typeArguments.length > 0) {
@@ -1,11 +1,12 @@
1
1
  import { formatFiles } from '@nx/devkit';
2
2
  import { reportAuthConsumers } from './auth-consumers-migration.js';
3
- import { migrateDevtoolsUsage, reportLeftoverV2Clients, migrateEmptyPrepareCalls, replaceAnyQueryWithLegacy, reportRemovedExperimentalQueryHelpers, } from './cleanup-migration.js';
3
+ import { migrateDevtoolsUsage, reportLeftoverLegacyDevtools, reportLeftoverV2Clients, migrateEmptyPrepareCalls, replaceAnyQueryWithLegacy, reportRemovedExperimentalQueryHelpers, } from './cleanup-migration.js';
4
4
  import { reportDefaultHeaderUsages, reportMissingHttpClientProviders } from './http-client-check.js';
5
5
  import { migrateLegacyPrepareCalls } from './legacy-prepare-migration.js';
6
6
  import { createNewQueryCreators, updateLegacyCreatorImportsAndUsages } from './legacy-query-creator-migration.js';
7
7
  import { createMigrationScope } from './migration-scope.js';
8
8
  import { generateProviderAliases, generateQueryCreators, migrateQueryClients, updateImportsAcrossWorkspace, } from './query-client-migration.js';
9
+ import { reportLegacyModuleResolution } from './module-resolution.js';
9
10
  import { QueryV3MigrationReport } from './report.js';
10
11
  export default async function migrate(tree, schema) {
11
12
  console.log('\nšŸ”„ Starting query v3 migration...');
@@ -28,11 +29,13 @@ export default async function migrate(tree, schema) {
28
29
  migrateLegacyPrepareCalls(tree, report, scope);
29
30
  reportRemovedExperimentalQueryHelpers(tree, scope, report);
30
31
  reportLeftoverV2Clients(tree, scope, report);
32
+ reportLeftoverLegacyDevtools(tree, scope, report);
31
33
  reportAuthConsumers(tree, scope, report);
32
34
  if (queryClientFiles.size > 0) {
33
35
  reportMissingHttpClientProviders(tree, report, scope);
34
36
  reportDefaultHeaderUsages(tree, report, scope);
35
37
  }
38
+ reportLegacyModuleResolution(tree, scope, report);
36
39
  report.writeToTree(tree, scope.isScoped ? scope.describe() : undefined);
37
40
  report.printSummary();
38
41
  if (!schema.skipFormat) {