@ethlete/query 6.0.0-next.46 → 6.0.0-next.49

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.
@@ -8,6 +8,12 @@ const CLIENT_FEATURE = 'withEthleteApiErrors';
8
8
  const AUTH_FEATURE = 'withBearerAuthMultiTabSync';
9
9
  const isMigratableFile = (filePath) => /\.(ts|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts');
10
10
  const propertyNamed = (config, sourceFile, name) => config.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText(sourceFile) === name);
11
+ const hasFeature = (config, sourceFile, feature) => {
12
+ const features = propertyNamed(config, sourceFile, 'features')?.initializer;
13
+ return (!!features &&
14
+ ts.isArrayLiteralExpression(features) &&
15
+ features.elements.some((element) => ts.isCallExpression(element) && ts.isIdentifier(element.expression) && element.expression.text === feature));
16
+ };
11
17
  /** `{ enabled: false, channelName: 'x' }` -> `withBearerAuthMultiTabSync({ channelName: 'x' })`, or `null` when off. */
12
18
  const authFeatureCallFor = (property, sourceFile) => {
13
19
  if (!property)
@@ -76,6 +82,9 @@ const collectEdits = (filePath, sourceFile, report) => {
76
82
  dedupeKey: `spread-config:${callee}`,
77
83
  });
78
84
  }
85
+ else if (isClient && hasFeature(config, sourceFile, CLIENT_FEATURE)) {
86
+ return;
87
+ }
79
88
  else if (isClient) {
80
89
  const feature = `${CLIENT_FEATURE}()`;
81
90
  report.addFollowUp({
@@ -95,6 +104,9 @@ const collectEdits = (filePath, sourceFile, report) => {
95
104
  }
96
105
  else {
97
106
  const syncProperty = propertyNamed(config, sourceFile, 'multiTabSync');
107
+ const alreadyAdded = hasFeature(config, sourceFile, AUTH_FEATURE);
108
+ if (alreadyAdded && !syncProperty)
109
+ return;
98
110
  const value = syncProperty?.initializer;
99
111
  if (value &&
100
112
  value.kind !== ts.SyntaxKind.TrueKeyword &&
@@ -109,7 +121,7 @@ const collectEdits = (filePath, sourceFile, report) => {
109
121
  dedupeKey: 'non-literal-multi-tab-sync',
110
122
  });
111
123
  }
112
- const feature = authFeatureCallFor(syncProperty, sourceFile);
124
+ const feature = alreadyAdded ? null : authFeatureCallFor(syncProperty, sourceFile);
113
125
  if (feature) {
114
126
  report.addFollowUp({
115
127
  title: 'Auth provider kept multi-tab sync',
@@ -47,17 +47,19 @@ const readConfig = (configArgument, sourceFile, filePath, node) => {
47
47
  return config;
48
48
  }
49
49
  refreshConfig.initializer.properties.forEach((property) => {
50
- if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
50
+ const isShorthand = ts.isShorthandPropertyAssignment(property);
51
+ if ((!ts.isPropertyAssignment(property) && !isShorthand) || !ts.isIdentifier(property.name)) {
51
52
  return;
52
53
  }
53
54
  const key = property.name.text;
54
55
  if (!REFRESH_CONFIG_KEYS.has(key)) {
55
56
  return;
56
57
  }
57
- const text = property.initializer.getText(sourceFile);
58
+ const value = isShorthand ? property.name : property.initializer;
59
+ const text = value.getText(sourceFile);
58
60
  if (key === 'queryCreator') {
59
- if (ts.isIdentifier(property.initializer)) {
60
- config.refreshCreatorName = property.initializer.text;
61
+ if (ts.isIdentifier(value)) {
62
+ config.refreshCreatorName = value.text;
61
63
  }
62
64
  return;
63
65
  }
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { createSourceFile, ensureImportFromQuery, ensureNamedImports, getLineNumberFromPosition } from './shared.js';
2
+ import { createSourceFile, ensureImportFromQuery, ensureNamedImports } from './shared.js';
3
3
  /**
4
4
  * Points existing devtools usage at the v3 components instead of deleting it.
5
5
  *
@@ -222,43 +222,7 @@ const replaceAnyQueryInFile = (content) => {
222
222
  replacements.forEach(({ start, end, replacement }) => {
223
223
  result = result.slice(0, start) + replacement + result.slice(end);
224
224
  });
225
- return removeAnyQueryFromImports(result);
226
- };
227
- const removeAnyQueryFromImports = (content) => {
228
- const sourceFile = createSourceFile(content);
229
- let importNode;
230
- ts.forEachChild(sourceFile, (node) => {
231
- if (ts.isImportDeclaration(node) &&
232
- ts.isStringLiteral(node.moduleSpecifier) &&
233
- node.moduleSpecifier.text === '@ethlete/query') {
234
- importNode = node;
235
- }
236
- });
237
- if (!importNode?.importClause?.namedBindings || !ts.isNamedImports(importNode.importClause.namedBindings)) {
238
- return content;
239
- }
240
- const replaced = new Map([
241
- ['AnyV2Query', 'AnyLegacyQuery'],
242
- ['AnyV2QueryCreator', 'AnyLegacyQueryCreator'],
243
- ]);
244
- const names = new Set();
245
- let changed = false;
246
- importNode.importClause.namedBindings.elements.forEach((element) => {
247
- const replacement = replaced.get(element.name.text);
248
- if (!replacement) {
249
- names.add(element.getText(sourceFile));
250
- return;
251
- }
252
- // Only add the alias that was actually imported. Adding both unconditionally is what put
253
- // `AnyLegacyQuery` / `AnyLegacyQueryCreator` into hundreds of files that never referenced them.
254
- changed = true;
255
- names.add(replacement);
256
- });
257
- if (!changed) {
258
- return content;
259
- }
260
- const nextImport = `import { ${Array.from(names).sort().join(', ')} } from '@ethlete/query';`;
261
- return content.slice(0, importNode.getStart(sourceFile)) + nextImport + content.slice(importNode.getEnd());
225
+ return result;
262
226
  };
263
227
  const transformEmptyPrepareCalls = (content) => {
264
228
  const sourceFile = createSourceFile(content);
@@ -287,8 +251,4 @@ const transformEmptyPrepareCalls = (content) => {
287
251
  });
288
252
  return result;
289
253
  };
290
- export const describeTemplateLine = (content, position, suffix) => {
291
- const lineNumber = getLineNumberFromPosition(content, position);
292
- return `${lineNumber} (${suffix})`;
293
- };
294
254
  //# sourceMappingURL=cleanup-migration.js.map
@@ -1,5 +1,8 @@
1
1
  import * as ts from 'typescript';
2
2
  import { createSourceFile, ensureAngularCoreImports, getIndentation, getLineNumber, getLineNumberFromPosition, } from './shared.js';
3
+ /** `default` can never be a class name, so it cannot collide with a named class of the same file. */
4
+ const ANONYMOUS_DEFAULT_CLASS_KEY = 'default';
5
+ const getClassKey = (node) => node.name?.text ?? ANONYMOUS_DEFAULT_CLASS_KEY;
3
6
  /** Contexts that already have an injector of their own, so the call site needs no rewrite. */
4
7
  const CONTEXTS_WITH_INJECTOR = [
5
8
  'queryComputed',
@@ -265,7 +268,7 @@ const findClassesNeedingInjector = (sourceFile, usages) => {
265
268
  const filePath = usages[0]?.filePath ?? sourceFile.fileName;
266
269
  const visit = (node) => {
267
270
  if (ts.isClassDeclaration(node)) {
268
- const className = node.name?.text ?? 'UnnamedClass';
271
+ const className = getClassKey(node);
269
272
  const usagesInClass = usages.filter((usage) => {
270
273
  return usage.position.start >= node.getStart(sourceFile) && usage.position.end <= node.getEnd();
271
274
  });
@@ -331,7 +334,7 @@ const addInjectorMemberToClass = (content, classInfo) => {
331
334
  const sourceFile = createSourceFile(content);
332
335
  let targetClass;
333
336
  const visit = (node) => {
334
- if (ts.isClassDeclaration(node) && node.name?.text === classInfo.className) {
337
+ if (ts.isClassDeclaration(node) && getClassKey(node) === classInfo.className) {
335
338
  targetClass = node;
336
339
  }
337
340
  ts.forEachChild(node, visit);
@@ -342,7 +345,7 @@ const addInjectorMemberToClass = (content, classInfo) => {
342
345
  }
343
346
  const classBodyStart = targetClass.members.length > 0 ? targetClass.members[0].getStart(sourceFile) : targetClass.getEnd() - 1;
344
347
  const indentation = getIndentation(content, classBodyStart);
345
- const injectorMember = `${indentation}private ${classInfo.injectorMemberName} = inject(Injector);\n\n`;
348
+ const injectorMember = `private ${classInfo.injectorMemberName} = inject(Injector);\n\n${indentation}`;
346
349
  return content.slice(0, classBodyStart) + injectorMember + content.slice(classBodyStart);
347
350
  };
348
351
  const transformLegacyCreatorPrepareCalls = (tree, classInjectors, pollingInfo, scope) => {
@@ -412,12 +415,7 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
412
415
  const containingClass = findContainingClass(node);
413
416
  const containingFunction = findContainingStandaloneFunction(node);
414
417
  if (containingClass) {
415
- const className = containingClass.name?.text;
416
- if (!className) {
417
- ts.forEachChild(node, visit);
418
- return;
419
- }
420
- const injectorMember = classNameToInjector.get(className);
418
+ const injectorMember = classNameToInjector.get(getClassKey(containingClass));
421
419
  if (!injectorMember) {
422
420
  ts.forEachChild(node, visit);
423
421
  return;
@@ -481,7 +479,7 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
481
479
  }
482
480
  injectorDeclarations.push({
483
481
  position: bodyStart,
484
- text: `${getIndentation(content, bodyStart)}const ${injectorName} = inject(Injector);\n`,
482
+ text: `const ${injectorName} = inject(Injector);\n${getIndentation(content, bodyStart)}`,
485
483
  });
486
484
  });
487
485
  injectorDeclarations.sort((left, right) => right.position - left.position);
@@ -779,8 +777,7 @@ const getQueryVariableName = (expression) => {
779
777
  return currentExpression.text;
780
778
  }
781
779
  if (ts.isPropertyAccessExpression(currentExpression)) {
782
- currentExpression = currentExpression.expression;
783
- continue;
780
+ return currentExpression.name.text;
784
781
  }
785
782
  if (ts.isCallExpression(currentExpression)) {
786
783
  currentExpression = currentExpression.expression;
@@ -10,6 +10,7 @@ const LEGACY_HTTP_METHOD = {
10
10
  PATCH: 'patch',
11
11
  DELETE: 'delete',
12
12
  };
13
+ const HTTP_OPTION_NAMES = ['reportProgress', 'responseType', 'transferCache', 'withCredentials'];
13
14
  const isLegacyHttpMethod = (value) => {
14
15
  return Object.values(LEGACY_HTTP_METHOD).includes(value);
15
16
  };
@@ -85,6 +86,7 @@ export const updateLegacyCreatorImportsAndUsages = (tree, scope, report) => {
85
86
  }
86
87
  console.log(`\n✅ Found ${legacyCreators.size} legacy query creator wrappers`);
87
88
  const moduleGraph = createModuleGraph(tree);
89
+ const pendingWrites = new Map();
88
90
  scope.visit(tree, (filePath) => {
89
91
  if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
90
92
  return;
@@ -101,10 +103,14 @@ export const updateLegacyCreatorImportsAndUsages = (tree, scope, report) => {
101
103
  report,
102
104
  });
103
105
  if (nextContent !== content) {
104
- tree.write(filePath, nextContent);
105
- updatedFiles.push(filePath);
106
+ pendingWrites.set(filePath, nextContent);
106
107
  }
107
108
  });
109
+ // Writing a barrel mid-pass would hide its original re-exports from consumers visited after it.
110
+ pendingWrites.forEach((nextContent, filePath) => {
111
+ tree.write(filePath, nextContent);
112
+ updatedFiles.push(filePath);
113
+ });
108
114
  if (updatedFiles.length > 0) {
109
115
  console.log('\n✅ Updated legacy creator imports and usages in:');
110
116
  updatedFiles.forEach((filePath) => console.log(` - ${filePath}`));
@@ -226,17 +232,62 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
226
232
  secure: false,
227
233
  httpOptions: new Map(),
228
234
  };
235
+ const warnUnreadableConfigValue = (property, passedAs) => {
236
+ const propertyName = property.name.getText(sourceFile);
237
+ report.addWarning({
238
+ title: `Carry over \`${propertyName}\` of ${creatorName}`,
239
+ summary: `${creatorName} passes \`${propertyName}\` ${passedAs} in its v2 config. The migration cannot read its value, so it was not carried over to the v3 creator.`,
240
+ action: propertyName === 'secure'
241
+ ? 'If the value is true, switch the generated creator to the matching *Secure creator of the client by hand.'
242
+ : 'Move what it configures onto the generated creator by hand.',
243
+ locations: [
244
+ { filePath, line: sourceFile.getLineAndCharacterOfPosition(property.getStart(sourceFile)).line + 1 },
245
+ ],
246
+ source: 'legacy-query-creator-migration',
247
+ dedupeKey: `unreadable-config:${filePath}:${creatorName}:${propertyName}`,
248
+ });
249
+ };
229
250
  const configObject = node.initializer.arguments[0];
230
251
  if (configObject && ts.isObjectLiteralExpression(configObject)) {
231
252
  configObject.properties.forEach((property) => {
253
+ if (ts.isSpreadAssignment(property)) {
254
+ report.addWarning({
255
+ title: `Restore the spread config of ${creatorName}`,
256
+ summary: `${creatorName} spreads \`${property.expression.getText(sourceFile)}\` into its v2 config. The migration cannot see what it contains, so none of it was carried over to the v3 creator.`,
257
+ action: 'Move the route, secure flag, types and http options it provides onto the generated creator by hand.',
258
+ locations: [
259
+ { filePath, line: sourceFile.getLineAndCharacterOfPosition(property.getStart(sourceFile)).line + 1 },
260
+ ],
261
+ source: 'legacy-query-creator-migration',
262
+ dedupeKey: `spread-config:${filePath}:${creatorName}`,
263
+ });
264
+ return;
265
+ }
266
+ if (ts.isShorthandPropertyAssignment(property)) {
267
+ if (property.name.text === 'route') {
268
+ info.route = property.name.text;
269
+ }
270
+ if (HTTP_OPTION_NAMES.includes(property.name.text)) {
271
+ info.httpOptions.set(property.name.text, property.name.text);
272
+ }
273
+ if (property.name.text !== 'route' && !HTTP_OPTION_NAMES.includes(property.name.text)) {
274
+ warnUnreadableConfigValue(property, 'as a shorthand property');
275
+ }
276
+ return;
277
+ }
232
278
  if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
233
279
  return;
234
280
  }
235
281
  if (property.name.text === 'route') {
236
282
  info.route = property.initializer.getText(sourceFile);
237
283
  }
238
- if (property.name.text === 'secure' && property.initializer.kind === ts.SyntaxKind.TrueKeyword) {
239
- info.secure = true;
284
+ if (property.name.text === 'secure') {
285
+ if (property.initializer.kind === ts.SyntaxKind.TrueKeyword) {
286
+ info.secure = true;
287
+ }
288
+ else if (property.initializer.kind !== ts.SyntaxKind.FalseKeyword) {
289
+ warnUnreadableConfigValue(property, 'with a value that is not a literal');
290
+ }
240
291
  }
241
292
  if (property.name.text === 'types' && ts.isObjectLiteralExpression(property.initializer)) {
242
293
  property.initializer.properties.forEach((typeProperty) => {
@@ -271,7 +322,7 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
271
322
  }
272
323
  });
273
324
  }
274
- if (['reportProgress', 'responseType', 'transferCache', 'withCredentials'].includes(property.name.text)) {
325
+ if (HTTP_OPTION_NAMES.includes(property.name.text)) {
275
326
  info.httpOptions.set(property.name.text, property.initializer.getText(sourceFile));
276
327
  }
277
328
  });
@@ -303,27 +354,42 @@ const transformLegacyQueryCreators = (content, legacyCreators) => {
303
354
  }
304
355
  creatorsByClient.get(creator.clientName).push(creator);
305
356
  });
357
+ const wrappersByStatement = new Map();
306
358
  legacyCreators.forEach((creator) => {
307
- const nextCreator = generateNewQueryCreator(creator);
308
- const legacyWrapper = generateLegacyWrapper(creator);
309
359
  let variableStatement;
360
+ let declaration;
310
361
  const visit = (node) => {
362
+ if (variableStatement)
363
+ return;
311
364
  if (ts.isVariableStatement(node)) {
312
- const declaration = node.declarationList.declarations[0];
313
- if (declaration && ts.isIdentifier(declaration.name) && declaration.name.text === creator.name) {
365
+ declaration = node.declarationList.declarations.find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === creator.name);
366
+ if (declaration) {
314
367
  variableStatement = node;
368
+ return;
315
369
  }
316
370
  }
317
371
  ts.forEachChild(node, visit);
318
372
  };
319
373
  visit(sourceFile);
320
- if (variableStatement) {
374
+ if (!variableStatement || !declaration)
375
+ return;
376
+ const isTopLevel = ts.isSourceFile(variableStatement.parent);
377
+ const exportPrefix = isTopLevel ? 'export ' : '';
378
+ const nextCreator = `${creator.name} = ${generateNewQueryCreator(creator)}`;
379
+ const legacyWrapper = generateLegacyWrapper(creator, exportPrefix);
380
+ if (variableStatement.declarationList.declarations.length === 1) {
321
381
  replacements.push({
322
382
  start: variableStatement.getStart(sourceFile),
323
383
  end: variableStatement.getEnd(),
324
- replacement: `${nextCreator}\n${legacyWrapper}`,
384
+ replacement: `${exportPrefix}const ${nextCreator};\n${legacyWrapper}`,
325
385
  });
386
+ return;
326
387
  }
388
+ replacements.push({ start: declaration.getStart(sourceFile), end: declaration.getEnd(), replacement: nextCreator });
389
+ wrappersByStatement.set(variableStatement, [...(wrappersByStatement.get(variableStatement) ?? []), legacyWrapper]);
390
+ });
391
+ wrappersByStatement.forEach((wrappers, statement) => {
392
+ replacements.push({ start: statement.getEnd(), end: statement.getEnd(), replacement: `\n${wrappers.join('\n')}` });
327
393
  });
328
394
  let result = content;
329
395
  replacements.sort((left, right) => right.start - left.start);
@@ -334,7 +400,7 @@ const transformLegacyQueryCreators = (content, legacyCreators) => {
334
400
  result = addCreatorImports(result, creatorsByClient);
335
401
  // The rewritten creators no longer go through `def<T>()` or the v2 client object, so whatever is
336
402
  // left over from those constructs would land as a lint error the moment `formatFiles` runs.
337
- return pruneUnusedNamedImports(result, 'all');
403
+ return pruneUnusedNamedImports(result);
338
404
  };
339
405
  const addCreatorImports = (content, creatorsByClient) => {
340
406
  const sourceFile = createSourceFile(content);
@@ -412,7 +478,7 @@ const generateNewQueryCreator = (creator) => {
412
478
  const typeParameter = getTypeParameter(creator);
413
479
  const options = Array.from(creator.httpOptions.entries()).map(([key, value]) => `${key}: ${value}`);
414
480
  const optionsBlock = options.length > 0 ? `, {\n ${options.join(',\n ')}\n}` : '';
415
- return `export const ${creator.name} = ${creatorFunction}${typeParameter}(${creator.route}${optionsBlock});`;
481
+ return `${creatorFunction}${typeParameter}(${creator.route}${optionsBlock})`;
416
482
  };
417
483
  const getTypeParameter = (creator) => {
418
484
  if (creator.typeArgs && (creator.typeBody || creator.typeResponse)) {
@@ -440,10 +506,10 @@ const getTypeParameter = (creator) => {
440
506
  }
441
507
  return '';
442
508
  };
443
- const generateLegacyWrapper = (creator) => {
509
+ const generateLegacyWrapper = (creator, exportPrefix) => {
444
510
  // `name` is what lets a `prepare()` called outside an injection context name itself in the error
445
511
  // instead of surfacing as a bare NG0203.
446
- return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\nexport const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
512
+ return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\n${exportPrefix}const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
447
513
  };
448
514
  const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, creatorToClient, }) => {
449
515
  const createdProviders = [];
@@ -23,18 +23,15 @@ export const createMigrationScope = (tree, options) => {
23
23
  if (roots.length === 0) {
24
24
  return {
25
25
  visit: (targetTree, callback) => visitNotIgnoredFiles(targetTree, '', callback),
26
- includes: () => true,
27
26
  describe: () => 'the whole workspace',
28
27
  };
29
28
  }
30
- const includes = (filePath) => roots.some((root) => filePath === root || filePath.startsWith(`${root}/`));
31
29
  return {
32
30
  visit: (targetTree, callback) => {
33
31
  for (const root of roots) {
34
32
  visitNotIgnoredFiles(targetTree, root, callback);
35
33
  }
36
34
  },
37
- includes,
38
35
  describe: () => roots.join(', '),
39
36
  };
40
37
  };
@@ -115,17 +115,25 @@ const migrateQueryClientToConfig = ({ content, filePath, renamesOut, report }) =
115
115
  const sourceFile = createSourceFile(content, filePath);
116
116
  const replacements = [];
117
117
  const variableRenames = new Map();
118
+ let hasUnmigratedClient = false;
118
119
  const visit = (node) => {
119
- if (ts.isNewExpression(node) &&
120
- ts.isIdentifier(node.expression) &&
121
- node.expression.text === 'V2QueryClient' &&
122
- node.arguments &&
123
- node.arguments.length > 0) {
124
- const configArgument = node.arguments[0];
125
- if (ts.isObjectLiteralExpression(configArgument)) {
120
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'V2QueryClient') {
121
+ const configArgument = node.arguments?.[0];
122
+ if (!configArgument || !ts.isObjectLiteralExpression(configArgument)) {
123
+ hasUnmigratedClient = true;
124
+ report.addManualReview({
125
+ title: 'Migrate a V2QueryClient whose config is not an object literal',
126
+ summary: `\`${node.getText(sourceFile)}\` passes its config indirectly, so the migration cannot read it and left the client on v2.`,
127
+ action: 'Inline the config object and re-run the migration, or rewrite the client by hand as `createQueryClient({ baseUrl, name })` with provider aliases and creators.',
128
+ locations: [{ filePath, line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 }],
129
+ source: 'query-client-migration',
130
+ dedupeKey: `unmigrated-client:${filePath}:${node.getStart(sourceFile)}`,
131
+ });
132
+ }
133
+ else {
126
134
  const oldVariableName = getVariableNameForQueryClient(node);
127
- const newVariableName = ensureConfigSuffix(oldVariableName);
128
- if (oldVariableName !== newVariableName) {
135
+ const newVariableName = oldVariableName && ensureConfigSuffix(oldVariableName);
136
+ if (oldVariableName && newVariableName && oldVariableName !== newVariableName) {
129
137
  const collisionRegex = new RegExp(`\\b(?:const|let|var)\\s+${newVariableName}\\b`);
130
138
  if (collisionRegex.test(content)) {
131
139
  report.addWarning({
@@ -158,16 +166,20 @@ const migrateQueryClientToConfig = ({ content, filePath, renamesOut, report }) =
158
166
  replacements.forEach(({ start, end, replacement }) => {
159
167
  result = result.slice(0, start) + replacement + result.slice(end);
160
168
  });
161
- result = removeQueryClientImport(result);
169
+ result = removeQueryClientImport(result, hasUnmigratedClient);
162
170
  return renameVariables(result, variableRenames);
163
171
  };
164
- const removeQueryClientImport = (content) => {
172
+ const removeQueryClientImport = (content, keepLegacyClient) => {
165
173
  const sourceFile = createSourceFile(content);
166
174
  let queryImportNode;
167
175
  ts.forEachChild(sourceFile, (node) => {
168
- if (ts.isImportDeclaration(node) &&
176
+ if (!queryImportNode &&
177
+ ts.isImportDeclaration(node) &&
169
178
  ts.isStringLiteral(node.moduleSpecifier) &&
170
- node.moduleSpecifier.text === '@ethlete/query') {
179
+ node.moduleSpecifier.text === '@ethlete/query' &&
180
+ node.importClause?.namedBindings &&
181
+ ts.isNamedImports(node.importClause.namedBindings) &&
182
+ node.importClause.namedBindings.elements.some((element) => element.name.text === 'V2QueryClient')) {
171
183
  queryImportNode = node;
172
184
  }
173
185
  });
@@ -183,10 +195,10 @@ const removeQueryClientImport = (content) => {
183
195
  return ensureImportFromQuery(content, requiredImports);
184
196
  }
185
197
  const existingElements = queryImportNode.importClause.namedBindings.elements.filter((element) => {
186
- return element.name.text !== 'V2QueryClient';
198
+ return keepLegacyClient || element.name.text !== 'V2QueryClient';
187
199
  });
188
200
  const nextImports = new Set(requiredImports);
189
- existingElements.forEach((element) => nextImports.add(element.name.text));
201
+ existingElements.forEach((element) => nextImports.add(element.getText(sourceFile)));
190
202
  const nextImportStatement = `import { ${Array.from(nextImports).sort().join(', ')} } from '@ethlete/query';`;
191
203
  return (content.slice(0, queryImportNode.getStart(sourceFile)) +
192
204
  nextImportStatement +
@@ -267,14 +279,37 @@ const reportDroppedClientOptions = ({ configArgument, sourceFile, filePath, repo
267
279
  dedupeKey: `dropped-client-option:${filePath}:${optionPath}`,
268
280
  });
269
281
  };
282
+ const raiseSpread = (spread) => {
283
+ const text = spread.getText(sourceFile);
284
+ report.addWarning({
285
+ title: `Reconfigure spread query client options "${text}"`,
286
+ summary: `The v2 client config spreads \`${text}\`. The migration cannot see which options it holds, so none of them were carried over to \`createQueryClient\`.`,
287
+ action: 'Inline the spread options that are still needed (`baseRoute` becomes `baseUrl`) and re-check the result.',
288
+ locations: [{ filePath, line: sourceFile.getLineAndCharacterOfPosition(spread.getStart(sourceFile)).line + 1 }],
289
+ source: 'query-client-migration',
290
+ dedupeKey: `dropped-client-spread:${filePath}:${spread.getStart(sourceFile)}`,
291
+ });
292
+ };
270
293
  configArgument.properties.forEach((property) => {
271
- if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
294
+ if (ts.isSpreadAssignment(property)) {
295
+ raiseSpread(property);
296
+ return;
297
+ }
298
+ if ((!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) ||
299
+ !ts.isIdentifier(property.name)) {
272
300
  return;
273
301
  }
274
302
  const name = property.name.text;
275
- if ((name === 'request' || name === 'logging') && ts.isObjectLiteralExpression(property.initializer)) {
303
+ if ((name === 'request' || name === 'logging') &&
304
+ ts.isPropertyAssignment(property) &&
305
+ ts.isObjectLiteralExpression(property.initializer)) {
276
306
  property.initializer.properties.forEach((nested) => {
277
- if (!ts.isPropertyAssignment(nested) || !ts.isIdentifier(nested.name)) {
307
+ if (ts.isSpreadAssignment(nested)) {
308
+ raiseSpread(nested);
309
+ return;
310
+ }
311
+ if ((!ts.isPropertyAssignment(nested) && !ts.isShorthandPropertyAssignment(nested)) ||
312
+ !ts.isIdentifier(nested.name)) {
278
313
  return;
279
314
  }
280
315
  if (name === 'request' && ['queryParams', 'cacheAdapter', 'retryFn'].includes(nested.name.text)) {
@@ -289,40 +324,42 @@ const reportDroppedClientOptions = ({ configArgument, sourceFile, filePath, repo
289
324
  }
290
325
  });
291
326
  };
327
+ const getPropertyValueText = (property, sourceFile) => {
328
+ if (ts.isShorthandPropertyAssignment(property)) {
329
+ return property.name.text;
330
+ }
331
+ return ts.isPropertyAssignment(property) ? property.initializer.getText(sourceFile) : undefined;
332
+ };
333
+ const findProperty = (objectLiteral, name) => objectLiteral.properties.find((property) => {
334
+ return ((ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
335
+ ts.isIdentifier(property.name) &&
336
+ property.name.text === name);
337
+ });
292
338
  const migrateConfigObject = (configArgument, node, sourceFile) => {
293
- const variableName = getVariableNameForQueryClient(node);
339
+ const variableName = getVariableNameForQueryClient(node) ?? 'client';
294
340
  const nextConfig = [];
295
- const baseRouteProperty = configArgument.properties.find((property) => {
296
- return ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'baseRoute';
297
- });
298
- if (baseRouteProperty && ts.isPropertyAssignment(baseRouteProperty)) {
299
- const baseRouteValue = baseRouteProperty.initializer
300
- .getText(sourceFile)
301
- .replace('as `https://${string}`', '')
302
- .trim();
303
- nextConfig.push(`baseUrl: ${baseRouteValue}`);
341
+ const baseRouteProperty = findProperty(configArgument, 'baseRoute');
342
+ const baseRouteText = baseRouteProperty && getPropertyValueText(baseRouteProperty, sourceFile);
343
+ if (baseRouteText) {
344
+ nextConfig.push(`baseUrl: ${baseRouteText.replace('as `https://${string}`', '').trim()}`);
304
345
  }
305
346
  nextConfig.push(`name: '${variableName}'`);
306
- const requestProperty = configArgument.properties.find((property) => {
307
- return ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'request';
308
- });
347
+ const requestProperty = findProperty(configArgument, 'request');
309
348
  if (requestProperty &&
310
349
  ts.isPropertyAssignment(requestProperty) &&
311
350
  ts.isObjectLiteralExpression(requestProperty.initializer)) {
312
- requestProperty.initializer.properties.forEach((property) => {
313
- if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
314
- return;
315
- }
316
- if (property.name.text === 'queryParams') {
317
- nextConfig.push(`queryString: ${property.initializer.getText(sourceFile)}`);
318
- }
319
- if (property.name.text === 'cacheAdapter') {
320
- nextConfig.push(`cacheAdapter: ${property.initializer.getText(sourceFile)}`);
351
+ const requestConfig = requestProperty.initializer;
352
+ for (const [from, to] of [
353
+ ['queryParams', 'queryString'],
354
+ ['cacheAdapter', 'cacheAdapter'],
355
+ ['retryFn', 'retryFn'],
356
+ ]) {
357
+ const property = findProperty(requestConfig, from);
358
+ const valueText = property && getPropertyValueText(property, sourceFile);
359
+ if (valueText) {
360
+ nextConfig.push(`${to}: ${valueText}`);
321
361
  }
322
- if (property.name.text === 'retryFn') {
323
- nextConfig.push(`retryFn: ${property.initializer.getText(sourceFile)}`);
324
- }
325
- });
362
+ }
326
363
  }
327
364
  return `createQueryClient({\n ${nextConfig.join(',\n ')}\n})`;
328
365
  };
@@ -334,7 +371,7 @@ const getVariableNameForQueryClient = (node) => {
334
371
  }
335
372
  parent = parent.parent;
336
373
  }
337
- return 'client';
374
+ return undefined;
338
375
  };
339
376
  const extractClientConfigNames = (content) => {
340
377
  const sourceFile = createSourceFile(content);
@@ -433,6 +470,19 @@ const generateCreatorsForConfig = (configName) => {
433
470
  `export const ${creatorBaseName}Delete = createDeleteQuery(${configVariableName});`,
434
471
  ].join('\n');
435
472
  };
473
+ const declaresName = (declarations, name) => declarations.some((declaration) => declaration.name && ts.isIdentifier(declaration.name) && declaration.name.text === name);
474
+ const isShadowedByLocalDeclaration = (identifier) => {
475
+ for (let scope = identifier.parent; scope && !ts.isSourceFile(scope); scope = scope.parent) {
476
+ if (ts.isFunctionLike(scope) && declaresName(scope.parameters, identifier.text)) {
477
+ return true;
478
+ }
479
+ if (ts.isBlock(scope) &&
480
+ scope.statements.some((statement) => ts.isVariableStatement(statement) && declaresName(statement.declarationList.declarations, identifier.text))) {
481
+ return true;
482
+ }
483
+ }
484
+ return false;
485
+ };
436
486
  const updateImportsInFile = (content, renames) => {
437
487
  const sourceFile = createSourceFile(content);
438
488
  const replacements = [];
@@ -443,7 +493,7 @@ const updateImportsInFile = (content, renames) => {
443
493
  const nextElements = [];
444
494
  let hasChanges = false;
445
495
  node.importClause.namedBindings.elements.forEach((element) => {
446
- const nextName = renames.get(element.name.text);
496
+ const nextName = renames.get(element.propertyName?.text ?? element.name.text);
447
497
  if (!nextName) {
448
498
  nextElements.push(element.getText(sourceFile));
449
499
  return;
@@ -494,7 +544,7 @@ const updateImportsInFile = (content, renames) => {
494
544
  ts.forEachChild(node, visit);
495
545
  return;
496
546
  }
497
- if (ts.isPropertyAssignment(parent) && parent.name === node) {
547
+ if ((ts.isPropertyAssignment(parent) && parent.name === node) || isShadowedByLocalDeclaration(node)) {
498
548
  ts.forEachChild(node, visit);
499
549
  return;
500
550
  }