@ethlete/query 6.0.0-next.61 → 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.
Files changed (28) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/fesm2022/ethlete-query-devtools-contract.mjs +1 -1
  3. package/fesm2022/ethlete-query-ngrx-toolkit.mjs +30 -7
  4. package/fesm2022/ethlete-query-ngrx-toolkit.mjs.map +1 -1
  5. package/fesm2022/ethlete-query.mjs +183 -43
  6. package/fesm2022/ethlete-query.mjs.map +1 -1
  7. package/generators/migrate-from-ngrx-toolkit/args-assignability.js +55 -8
  8. package/generators/migrate-from-ngrx-toolkit/consumers.js +133 -37
  9. package/generators/migrate-from-ngrx-toolkit/creators.js +8 -4
  10. package/generators/migrate-from-ngrx-toolkit/feature.js +31 -4
  11. package/generators/migrate-from-ngrx-toolkit/migration.js +3 -1
  12. package/generators/migrate-from-ngrx-toolkit/module-resolution.js +23 -0
  13. package/generators/migrate-from-ngrx-toolkit/store-removal.js +64 -11
  14. package/generators/migrate-to-query-v3/auth-consumers-migration.js +64 -0
  15. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +93 -1
  16. package/generators/migrate-to-query-v3/cleanup-migration.js +135 -15
  17. package/generators/migrate-to-query-v3/legacy-prepare-migration.js +275 -27
  18. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +171 -25
  19. package/generators/migrate-to-query-v3/migration.js +9 -3
  20. package/generators/migrate-to-query-v3/module-resolution.js +131 -0
  21. package/generators/migrate-to-query-v3/query-client-migration.js +82 -14
  22. package/generators/migrate-to-query-v3/rename-symbols.js +16 -11
  23. package/generators/move-devtools-contract-imports/moved-names.js +1 -0
  24. package/migrations.json +10 -0
  25. package/package.json +1 -1
  26. package/types/ethlete-query-devtools-contract.d.ts +2 -2
  27. package/types/ethlete-query-ngrx-toolkit.d.ts +13 -0
  28. package/types/ethlete-query.d.ts +40 -6
@@ -18,6 +18,12 @@ export type ToolkitSide = OptionalNullAsUndefined<{
18
18
  export type CreatorSide = OptionalNullAsUndefined<{
19
19
  [K in keyof CreatorArgs as K extends 'queryParams' | 'body' ? K : never]: CreatorArgs[K];
20
20
  }>;
21
+ export type ToolkitRaw = {
22
+ [K in keyof ToolkitArgs as K extends 'params' ? 'queryParams' : K extends 'body' ? K : never]: ToolkitArgs[K];
23
+ };
24
+ export type CreatorRaw = {
25
+ [K in keyof CreatorArgs as K extends 'queryParams' | 'body' ? K : never]: CreatorArgs[K];
26
+ };
21
27
  `;
22
28
  const COMPILER_OPTIONS = {
23
29
  strict: true,
@@ -59,14 +65,46 @@ const hasEveryKey = (checker, source, target, seen, depth) => {
59
65
  return true;
60
66
  seen.add(from);
61
67
  return checker.getPropertiesOfType(from).every((property) => {
62
- const counterpart = checker.getPropertyOfType(to, property.name);
68
+ const counterpart = checker.getPropertiesOfType(to).find((each) => each.escapedName === property.escapedName);
63
69
  return (!!counterpart &&
64
70
  hasEveryKey(checker, checker.getTypeOfSymbol(property), checker.getTypeOfSymbol(counterpart), seen, depth + 1));
65
71
  });
66
72
  };
67
- export const toolkitArgsFitCreator = (tree, graph, toolkit, creator) => {
73
+ const isOptional = (property) => !!(property.flags & ts.SymbolFlags.Optional);
74
+ const describeType = (checker, type) => {
75
+ const text = `\`${checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)}\``;
76
+ const declaration = (type.aliasSymbol ?? type.getSymbol())?.declarations?.[0];
77
+ return declaration ? `${text} from ${toTreePath(declaration.getSourceFile().fileName)}` : text;
78
+ };
79
+ const firstDifference = (checker, toolkit, creator, path, depth) => {
80
+ const from = checker.getNonNullableType(toolkit);
81
+ const to = checker.getNonNullableType(creator);
82
+ if (depth > MAX_DEPTH)
83
+ return null;
84
+ if (!isRecord(checker, from) || !isRecord(checker, to)) {
85
+ return checker.isTypeAssignableTo(from, to)
86
+ ? null
87
+ : `\`${path}\` is ${describeType(checker, to)} there and ${describeType(checker, from)} in the action`;
88
+ }
89
+ const creatorProperties = checker.getPropertiesOfType(to);
90
+ const toolkitProperties = checker.getPropertiesOfType(from);
91
+ for (const property of toolkitProperties) {
92
+ const name = `${path ? `${path}.` : ''}${property.name}`;
93
+ const counterpart = creatorProperties.find((each) => each.escapedName === property.escapedName);
94
+ if (!counterpart)
95
+ return `\`${name}\` is sent by the action but missing there`;
96
+ if (isOptional(property) && !isOptional(counterpart))
97
+ return `\`${name}\` is required there and optional in the action`;
98
+ const nested = firstDifference(checker, checker.getTypeOfSymbol(property), checker.getTypeOfSymbol(counterpart), name, depth + 1);
99
+ if (nested)
100
+ return nested;
101
+ }
102
+ const extra = creatorProperties.find((property) => !isOptional(property) && !toolkitProperties.some((each) => each.escapedName === property.escapedName));
103
+ return extra ? `\`${path ? `${path}.` : ''}${extra.name}\` is required there but not sent by the action` : null;
104
+ };
105
+ export const compareToolkitArgs = (tree, graph, toolkit, creator) => {
68
106
  if (toolkit.file === creator.file)
69
- return false;
107
+ return { fits: false, difference: null };
70
108
  const appended = new Map([
71
109
  [toolkit.file, `\nexport type ${TOOLKIT_PROBE} = ${toolkit.typeText ?? '{}'};\n`],
72
110
  [creator.file, `\nexport type ${CREATOR_PROBE} = ${creator.typeText ?? '{}'};\n`],
@@ -125,16 +163,25 @@ export const toolkitArgsFitCreator = (tree, graph, toolkit, creator) => {
125
163
  const toolkitSide = typeOf('ToolkitSide');
126
164
  const creatorSide = typeOf('CreatorSide');
127
165
  if (!toolkitSide || !creatorSide)
128
- return false;
129
- if (containsAny(checker, toolkitSide, new Set(), 0) || containsAny(checker, creatorSide, new Set(), 0))
130
- return false;
166
+ return { fits: false, difference: null };
167
+ if (containsAny(checker, toolkitSide, new Set(), 0) || containsAny(checker, creatorSide, new Set(), 0)) {
168
+ return { fits: false, difference: null };
169
+ }
131
170
  const keys = (type) => checker
132
171
  .getPropertiesOfType(type)
133
172
  .map((property) => property.name)
134
173
  .sort()
135
174
  .join(',');
136
- return (keys(toolkitSide) === keys(creatorSide) &&
175
+ const fits = keys(toolkitSide) === keys(creatorSide) &&
137
176
  checker.isTypeAssignableTo(toolkitSide, creatorSide) &&
138
- hasEveryKey(checker, toolkitSide, creatorSide, new Set(), 0));
177
+ hasEveryKey(checker, toolkitSide, creatorSide, new Set(), 0);
178
+ if (fits)
179
+ return { fits, difference: null };
180
+ const toolkitRaw = typeOf('ToolkitRaw');
181
+ const creatorRaw = typeOf('CreatorRaw');
182
+ return {
183
+ fits,
184
+ difference: toolkitRaw && creatorRaw ? firstDifference(checker, toolkitRaw, creatorRaw, '', 0) : null,
185
+ };
139
186
  };
140
187
  //# sourceMappingURL=args-assignability.js.map
@@ -17,7 +17,7 @@ const defineConsumerTasks = (report) => {
17
17
  });
18
18
  report.define(CONSUMER_TASK.UNCONVERTED_ACTION_GROUP, {
19
19
  title: 'Consumer types name an action group that is still on the toolkit',
20
- action: 'The file was left on `@tomtomb/ngrx-toolkit` because a `MappedEntityState` / `ActionCallArgs` type names an action group whose feature was not converted. Until then the file does not typecheck where it takes handles from facades that were migrated: the interop `MappedEntityState` has no `isPolling$`, `type$` or `entityId$`, and other generics. Resolve that feature’s task, then re-run the generator.',
20
+ action: 'A `MappedEntityState` / `ActionCallArgs` type names an action group whose feature was not converted. In a file that also names converted creators, only these types stay on the toolkit, as `ToolkitMappedEntityState` / `ToolkitActionCallArgs`. Otherwise the whole file was left on `@tomtomb/ngrx-toolkit`. Until then the file does not typecheck where it takes handles from facades that were migrated: the interop `MappedEntityState` has no `isPolling$`, `type$` or `entityId$`, and other generics. Resolve that feature’s task, then re-run the generator.',
21
21
  });
22
22
  report.define(CONSUMER_TASK.SELECT_BY_ACTION_ID, {
23
23
  title: 'Handle looked up by action id',
@@ -96,34 +96,47 @@ const namedImportOf = (sourceFile, local) => {
96
96
  const isToolkitActionsFile = (tree, filePath) => !!filePath && filePath.endsWith('.actions.ts') && !!tree.read(filePath, 'utf-8')?.includes('createHttpActionGroup');
97
97
  const unconvertedTypeQueries = (tree, graph, filePath, sourceFile, handleTypeNames) => {
98
98
  const unconverted = [];
99
+ const unconvertedReferences = [];
100
+ let convertedReferences = 0;
99
101
  const resolveQuery = (query) => {
100
102
  const name = query.exprName;
101
103
  const local = ts.isIdentifier(name) ? name.text : ts.isIdentifier(name.left) ? name.left.text : null;
102
104
  const origin = local ? namedImportOf(sourceFile, local) : null;
103
105
  if (!origin || origin.namespace === ts.isIdentifier(name))
104
- return;
106
+ return false;
105
107
  const declaringFile = origin.namespace
106
108
  ? graph.resolveFile(filePath, origin.specifier)
107
109
  : graph.findDeclaringFile(filePath, origin.specifier, origin.imported);
108
- if (isToolkitActionsFile(tree, declaringFile))
109
- unconverted.push(name.getText(sourceFile));
110
+ if (!isToolkitActionsFile(tree, declaringFile))
111
+ return false;
112
+ unconverted.push(name.getText(sourceFile));
113
+ return true;
110
114
  };
111
115
  const visit = (node) => {
112
116
  if (ts.isTypeReferenceNode(node) &&
113
117
  ts.isIdentifier(node.typeName) &&
114
118
  handleTypeNames.has(node.typeName.text) &&
115
119
  node.typeArguments) {
120
+ let queries = 0;
121
+ let unconvertedQueries = 0;
116
122
  const visitArgument = (inner) => {
117
- if (ts.isTypeQueryNode(inner))
118
- resolveQuery(inner);
123
+ if (ts.isTypeQueryNode(inner)) {
124
+ queries += 1;
125
+ if (resolveQuery(inner))
126
+ unconvertedQueries += 1;
127
+ }
119
128
  ts.forEachChild(inner, visitArgument);
120
129
  };
121
130
  node.typeArguments.forEach(visitArgument);
131
+ if (unconvertedQueries > 0)
132
+ unconvertedReferences.push(node);
133
+ else if (queries > 0)
134
+ convertedReferences += 1;
122
135
  }
123
136
  ts.forEachChild(node, visit);
124
137
  };
125
138
  visit(sourceFile);
126
- return unconverted;
139
+ return { names: unconverted, references: unconvertedReferences, convertedReferences };
127
140
  };
128
141
  const isCall = (node, name) => ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name;
129
142
  const readActionId = (node, createActionId) => {
@@ -191,15 +204,9 @@ const injectorOf = (classNode) => {
191
204
  }
192
205
  return null;
193
206
  };
194
- const planRefreshSites = (filePath, sourceFile, content, createActionId) => {
195
- const plan = { edits: [], rewritten: 0, needsInjectorImport: false, tasks: [] };
196
- const consumedActionIds = new Set();
207
+ const classInjectorResolver = (sourceFile) => {
197
208
  const classInjectors = new Map();
198
- const task = (node, summary) => plan.tasks.push({
199
- id: CONSUMER_TASK.SELECT_BY_ACTION_ID,
200
- summary,
201
- locations: [{ filePath, line: lineOf(sourceFile, node.getStart(sourceFile)) }],
202
- });
209
+ const edits = [];
203
210
  const injectorFor = (node) => {
204
211
  let current = node.parent;
205
212
  while (current && !ts.isClassLike(current))
@@ -215,14 +222,25 @@ const planRefreshSites = (filePath, sourceFile, content, createActionId) => {
215
222
  const taken = classNode.members.some((member) => member.name?.getText(sourceFile) === 'injector');
216
223
  const name = taken ? 'toolkitInjector' : 'injector';
217
224
  classInjectors.set(classNode, name);
218
- plan.needsInjectorImport = true;
219
- plan.edits.push({
225
+ edits.push({
220
226
  start: classNode.members.pos,
221
227
  end: classNode.members.pos,
222
228
  text: `\n private ${name} = inject(Injector);\n`,
223
229
  });
224
230
  return name;
225
231
  };
232
+ return { injectorFor, edits };
233
+ };
234
+ const planRefreshSites = (filePath, sourceFile, content, createActionId) => {
235
+ const plan = { edits: [], rewritten: 0, needsInjectorImport: false, tasks: [] };
236
+ const consumedActionIds = new Set();
237
+ const task = (node, summary) => plan.tasks.push({
238
+ id: CONSUMER_TASK.SELECT_BY_ACTION_ID,
239
+ summary,
240
+ locations: [{ filePath, line: lineOf(sourceFile, node.getStart(sourceFile)) }],
241
+ });
242
+ const injectors = classInjectorResolver(sourceFile);
243
+ const injectorFor = injectors.injectorFor;
226
244
  const localActionId = (select, identifier) => {
227
245
  let block = select.parent;
228
246
  while (block && !ts.isBlock(block))
@@ -275,6 +293,8 @@ const planRefreshSites = (filePath, sourceFile, content, createActionId) => {
275
293
  };
276
294
  visit(sourceFile);
277
295
  visitActionIds(sourceFile);
296
+ plan.edits.push(...injectors.edits);
297
+ plan.needsInjectorImport = injectors.edits.length > 0;
278
298
  return plan;
279
299
  };
280
300
  /** Moves one consumer file from `@tomtomb/ngrx-toolkit` to `@ethlete/query/ngrx-toolkit`. */
@@ -290,7 +310,12 @@ export const rewriteConsumer = (tree, graph, filePath, content) => {
290
310
  return null;
291
311
  const handleTypeNames = new Set(elements.filter((element) => HANDLE_TYPES.has(element.imported)).map((element) => element.local));
292
312
  const unconverted = unconvertedTypeQueries(tree, graph, filePath, sourceFile, handleTypeNames);
293
- if (unconverted.length > 0) {
313
+ const unconvertedTask = {
314
+ id: CONSUMER_TASK.UNCONVERTED_ACTION_GROUP,
315
+ summary: `${[...new Set(unconverted.names)].map((name) => `\`${name}\``).join(', ')} still name toolkit action groups.`,
316
+ locations: [{ filePath }],
317
+ };
318
+ if (unconverted.names.length > 0 && (hasStoreSide || unconverted.convertedReferences === 0)) {
294
319
  if (hasStoreSide)
295
320
  return null;
296
321
  return {
@@ -298,18 +323,23 @@ export const rewriteConsumer = (tree, graph, filePath, content) => {
298
323
  rewritten: false,
299
324
  leftOnToolkit: true,
300
325
  refreshSitesRewritten: 0,
301
- tasks: [
302
- {
303
- id: CONSUMER_TASK.UNCONVERTED_ACTION_GROUP,
304
- summary: `${[...new Set(unconverted)].map((name) => `\`${name}\``).join(', ')} still name toolkit action groups.`,
305
- locations: [{ filePath }],
306
- },
307
- ],
326
+ tasks: [unconvertedTask],
308
327
  };
309
328
  }
329
+ const importedOf = new Map(elements.map((element) => [element.local, element.imported]));
330
+ const aliased = new Set();
331
+ const aliasEdits = unconverted.references.map((reference) => {
332
+ const imported = importedOf.get(reference.typeName.getText(sourceFile));
333
+ aliased.add(imported);
334
+ return {
335
+ start: reference.typeName.getStart(sourceFile),
336
+ end: reference.typeName.getEnd(),
337
+ text: `Toolkit${imported}`,
338
+ };
339
+ });
310
340
  const createActionId = elements.find((element) => element.imported === 'createActionId')?.local ?? null;
311
341
  const refresh = planRefreshSites(filePath, sourceFile, content, createActionId);
312
- let result = applyEdits(content, refresh.edits);
342
+ let result = applyEdits(content, [...refresh.edits, ...aliasEdits]);
313
343
  const rewrittenSource = createSourceFile(result, filePath);
314
344
  const used = collectUsedIdentifiers(rewrittenSource);
315
345
  const moved = [];
@@ -333,7 +363,9 @@ export const rewriteConsumer = (tree, graph, filePath, content) => {
333
363
  statement.moduleSpecifier.text === INTEROP_MODULE);
334
364
  const replacement = [
335
365
  moved.length > 0 && !hasInteropImport ? `import { ${moved.join(', ')} } from '${INTEROP_MODULE}';` : null,
336
- kept.length > 0 ? `import { ${kept.join(', ')} } from '${TOOLKIT_MODULE}';` : null,
366
+ kept.length + aliased.size > 0
367
+ ? `import { ${[...kept, ...[...aliased].map((name) => `${name} as Toolkit${name}`)].join(', ')} } from '${TOOLKIT_MODULE}';`
368
+ : null,
337
369
  ].filter((line) => line !== null);
338
370
  const importEdits = toolkitImportsOf(rewrittenSource).map((statement, index) => index === 0 && replacement.length > 0
339
371
  ? { start: statement.getStart(rewrittenSource), end: statement.getEnd(), text: replacement.join('\n') }
@@ -350,6 +382,8 @@ export const rewriteConsumer = (tree, graph, filePath, content) => {
350
382
  result = ensureNamedImports({ content: result, importsNeeded: moved, moduleSpecifier: INTEROP_MODULE });
351
383
  }
352
384
  const tasks = [...refresh.tasks];
385
+ if (aliased.size > 0)
386
+ tasks.push(unconvertedTask);
353
387
  if (kept.length > 0) {
354
388
  tasks.push({
355
389
  id: CONSUMER_TASK.UNSUPPORTED_IMPORT,
@@ -360,12 +394,12 @@ export const rewriteConsumer = (tree, graph, filePath, content) => {
360
394
  return {
361
395
  content: result,
362
396
  rewritten: result !== content,
363
- leftOnToolkit: kept.length > 0,
397
+ leftOnToolkit: kept.length + aliased.size > 0,
364
398
  refreshSitesRewritten: refresh.rewritten,
365
399
  tasks,
366
400
  };
367
401
  };
368
- const isInterceptor = (node, sourceFile) => {
402
+ export const isInterceptor = (node, sourceFile) => {
369
403
  if (ts.isClassDeclaration(node)) {
370
404
  return !!node.heritageClauses?.some((clause) => clause.types.some((type) => type.expression.getText(sourceFile) === 'HttpInterceptor'));
371
405
  }
@@ -438,13 +472,14 @@ const migratedFacadeNames = (graph, migratedFacades, filePath, sourceFile) => {
438
472
  }
439
473
  return names;
440
474
  };
441
- const findFacadeEventCalls = (graph, migratedFacades, filePath, content) => {
475
+ const rewriteFacadeEventCalls = (tree, graph, migratedFacades, filePath, content) => {
476
+ const unchanged = { content, rewritten: 0, tasks: [] };
442
477
  if (!/\.(on|once)\s*\(/.test(content))
443
- return [];
478
+ return unchanged;
444
479
  const sourceFile = createSourceFile(content, filePath);
445
480
  const facadeNames = migratedFacadeNames(graph, migratedFacades, filePath, sourceFile);
446
481
  if (facadeNames.size === 0)
447
- return [];
482
+ return unchanged;
448
483
  const isFacadeType = (type) => !!type && ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && facadeNames.has(type.typeName.text);
449
484
  const isFacadeInject = (node) => !!node && isCall(node, 'inject') && !!node.arguments[0] && facadeNames.has(node.arguments[0].getText(sourceFile));
450
485
  const bound = new Set();
@@ -466,12 +501,50 @@ const findFacadeEventCalls = (graph, migratedFacades, filePath, content) => {
466
501
  receiver.expression.kind === ts.SyntaxKind.ThisKeyword &&
467
502
  bound.has(receiver.name.text));
468
503
  };
504
+ const isConvertedCreator = (local) => {
505
+ const origin = namedImportOf(sourceFile, local);
506
+ if (!origin || origin.namespace)
507
+ return false;
508
+ const declaringFile = graph.findDeclaringFile(filePath, origin.specifier, origin.imported);
509
+ return !!declaringFile && !isToolkitActionsFile(tree, declaringFile);
510
+ };
511
+ const successCreatorsText = (call) => {
512
+ const argument = call.arguments[0];
513
+ if (call.arguments.length !== 1 || !argument)
514
+ return null;
515
+ const items = ts.isArrayLiteralExpression(argument) ? [...argument.elements] : [argument];
516
+ const creators = [];
517
+ for (const item of items) {
518
+ if (!ts.isPropertyAccessExpression(item) ||
519
+ item.name.text !== 'success' ||
520
+ !ts.isIdentifier(item.expression) ||
521
+ !isConvertedCreator(item.expression.text)) {
522
+ return null;
523
+ }
524
+ creators.push(item.expression.text);
525
+ }
526
+ if (creators.length === 0)
527
+ return null;
528
+ return ts.isArrayLiteralExpression(argument) ? `[${creators.join(', ')}]` : creators[0];
529
+ };
530
+ const injectors = classInjectorResolver(sourceFile);
531
+ const edits = [];
469
532
  const tasks = [];
470
533
  const visit = (node) => {
471
534
  if (ts.isCallExpression(node) &&
472
535
  ts.isPropertyAccessExpression(node.expression) &&
473
536
  ['on', 'once'].includes(node.expression.name.text) &&
474
537
  isFacade(node.expression.expression)) {
538
+ const creators = node.expression.name.text === 'on' ? successCreatorsText(node) : null;
539
+ const injector = creators ? injectors.injectorFor(node) : null;
540
+ if (creators && injector) {
541
+ edits.push({
542
+ start: node.getStart(sourceFile),
543
+ end: node.getEnd(),
544
+ text: `toolkitOnSuccess(${creators}, { injector: this.${injector} })`,
545
+ });
546
+ return;
547
+ }
475
548
  tasks.push({
476
549
  id: CONSUMER_TASK.FACADE_ON,
477
550
  summary: `${code(node.getText(sourceFile).replace(/\s+/g, ' '))} listens to toolkit actions the migrated facade no longer emits.`,
@@ -481,7 +554,22 @@ const findFacadeEventCalls = (graph, migratedFacades, filePath, content) => {
481
554
  ts.forEachChild(node, visit);
482
555
  };
483
556
  visit(sourceFile);
484
- return tasks;
557
+ if (edits.length === 0)
558
+ return { ...unchanged, tasks };
559
+ let result = applyEdits(content, [...edits, ...injectors.edits]);
560
+ result = ensureNamedImports({
561
+ content: result,
562
+ importsNeeded: ['toolkitOnSuccess'],
563
+ moduleSpecifier: INTEROP_MODULE,
564
+ });
565
+ if (injectors.edits.length > 0) {
566
+ result = ensureNamedImports({
567
+ content: result,
568
+ importsNeeded: ['inject', 'Injector'],
569
+ moduleSpecifier: '@angular/core',
570
+ });
571
+ }
572
+ return { content: result, rewritten: edits.length, tasks };
485
573
  };
486
574
  /** Runs the consumer side of the migration on every scoped file that still imports `@tomtomb/ngrx-toolkit`. */
487
575
  export const migrateToolkitConsumers = (tree, graph, inScope, report, migratedFacades) => {
@@ -490,6 +578,7 @@ export const migrateToolkitConsumers = (tree, graph, inScope, report, migratedFa
490
578
  filesRewritten: 0,
491
579
  filesLeftOnToolkit: 0,
492
580
  refreshSitesRewritten: 0,
581
+ facadeListenersRewritten: 0,
493
582
  interceptorsFound: 0,
494
583
  };
495
584
  for (const filePath of inScope) {
@@ -501,16 +590,23 @@ export const migrateToolkitConsumers = (tree, graph, inScope, report, migratedFa
501
590
  const interceptors = findInterceptors(tree, inScope, filePath, content);
502
591
  interceptors.forEach((task) => report.add(task));
503
592
  stats.interceptorsFound += interceptors.length;
504
- findFacadeEventCalls(graph, migratedFacades, filePath, content).forEach((task) => report.add(task));
505
- if (!content.includes(TOOLKIT_MODULE))
593
+ const events = rewriteFacadeEventCalls(tree, graph, migratedFacades, filePath, content);
594
+ events.tasks.forEach((task) => report.add(task));
595
+ stats.facadeListenersRewritten += events.rewritten;
596
+ if (events.rewritten > 0) {
597
+ tree.write(filePath, events.content);
598
+ stats.filesRewritten += 1;
599
+ }
600
+ if (!events.content.includes(TOOLKIT_MODULE))
506
601
  continue;
507
- const result = rewriteConsumer(tree, graph, filePath, content);
602
+ const result = rewriteConsumer(tree, graph, filePath, events.content);
508
603
  if (!result)
509
604
  continue;
510
605
  result.tasks.forEach((task) => report.add(task));
511
606
  if (result.rewritten) {
512
607
  tree.write(filePath, result.content);
513
- stats.filesRewritten += 1;
608
+ if (events.rewritten === 0)
609
+ stats.filesRewritten += 1;
514
610
  }
515
611
  if (result.leftOnToolkit)
516
612
  stats.filesLeftOnToolkit += 1;
@@ -1,6 +1,6 @@
1
1
  import { getProjects } from '@nx/devkit';
2
2
  import * as ts from 'typescript';
3
- import { toolkitArgsFitCreator } from './args-assignability.js';
3
+ import { compareToolkitArgs } from './args-assignability.js';
4
4
  import { capitalizeFirstLetter, createSourceFile } from '../migrate-to-query-v3/shared.js';
5
5
  import { HTTP_VERBS, findTypeDeclaration } from './feature.js';
6
6
  import { TOOLKIT_TASK } from './report.js';
@@ -226,8 +226,8 @@ const decideReuse = (tree, graph, feature, call, existing) => {
226
226
  const response = call.responseType ? normalizeType(call.responseType) : null;
227
227
  const argTexts = toolkitArgTexts(call);
228
228
  const argMismatch = (creator) => COMPARED_ARG_KEYS.find((key) => creator.args?.[key] !== argTexts[key]) ?? null;
229
- const argsFit = (creator) => argMismatch(creator) === null ||
230
- toolkitArgsFitCreator(tree, graph, { file: feature.files.actions, typeText: call.args?.typeText ?? null }, { file: creator.filePath, typeText: creator.argsTypeText });
229
+ const compare = (creator) => compareToolkitArgs(tree, graph, { file: feature.files.actions, typeText: call.args?.typeText ?? null }, { file: creator.filePath, typeText: creator.argsTypeText });
230
+ const argsFit = (creator) => argMismatch(creator) === null || compare(creator).fits;
231
231
  const match = sameRoute.find((creator) => creator.responseType === response &&
232
232
  creator.pathParams.join(',') === call.route.pathParams.join(',') &&
233
233
  argsFit(creator));
@@ -240,6 +240,10 @@ const decideReuse = (tree, graph, feature, call, existing) => {
240
240
  const type = text.replace(/^\?/, '');
241
241
  return `\`${type.length > 120 ? `${type.slice(0, 120)}…` : type}\``;
242
242
  };
243
+ const argsDifference = (creator) => {
244
+ const difference = differingKey ? compare(creator).difference : null;
245
+ return difference ? `: ${difference}` : '';
246
+ };
243
247
  const reason = other.pathParams.join(',') !== call.route.pathParams.join(',')
244
248
  ? 'its path params are named differently'
245
249
  : other.responseType === null
@@ -248,7 +252,7 @@ const decideReuse = (tree, graph, feature, call, existing) => {
248
252
  ? `its response is \`${other.responseType}\`, not \`${response ?? 'unknown'}\``
249
253
  : other.args === null
250
254
  ? 'its args type is not an interface or type literal, so it cannot be compared'
251
- : `its ${differingKey === 'body' ? 'body' : 'query params'} are ${describe(other.args[differingKey])}, not ${describe(argTexts[differingKey])}`;
255
+ : `its ${differingKey === 'body' ? 'body' : 'query params'} are ${describe(other.args[differingKey])}, not ${describe(argTexts[differingKey])}${argsDifference(other)}`;
252
256
  return {
253
257
  mismatch: `\`${call.name}\` got its own creator: \`${other.name}\` (${other.filePath}:${other.line}) has the same ${call.verb.toUpperCase()} route, but ${reason}.`,
254
258
  };
@@ -245,6 +245,27 @@ const parseRoute = (node, sourceFile) => {
245
245
  }
246
246
  return { text, normalized, pathParams, staticPrefix: body.head.text };
247
247
  };
248
+ const isEmptyValue = (node) => node.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(node) && node.text === 'undefined');
249
+ const describeHeaders = (value, method, sourceFile) => {
250
+ const text = value.getText(sourceFile);
251
+ const owner = method.parent;
252
+ const property = ts.isPropertyAccessExpression(value) && value.expression.kind === ts.SyntaxKind.ThisKeyword && ts.isClassLike(owner)
253
+ ? owner.members.find((member) => ts.isPropertyDeclaration(member) && member.name.getText(sourceFile) === value.name.text)
254
+ : undefined;
255
+ const resolved = property?.initializer ?? value;
256
+ const source = property ? ` (from \`${text}\`)` : '';
257
+ if (ts.isConditionalExpression(resolved)) {
258
+ const condition = resolved.condition.getText(sourceFile);
259
+ if (isEmptyValue(resolved.whenFalse)) {
260
+ return `\`${resolved.whenTrue.getText(sourceFile)}\`${source} only when \`${condition}\``;
261
+ }
262
+ if (isEmptyValue(resolved.whenTrue)) {
263
+ return `\`${resolved.whenFalse.getText(sourceFile)}\`${source} only when \`${condition}\` is false`;
264
+ }
265
+ return `\`${resolved.whenTrue.getText(sourceFile)}\` or \`${resolved.whenFalse.getText(sourceFile)}\`${source} depending on \`${condition}\``;
266
+ }
267
+ return `\`${resolved.getText(sourceFile)}\`${source}`;
268
+ };
248
269
  const parseServiceMethod = (method, sourceFile) => {
249
270
  const statements = method.body?.statements ?? [];
250
271
  const statement = statements[0];
@@ -265,7 +286,7 @@ const parseServiceMethod = (method, sourceFile) => {
265
286
  return 'The request options are not an object literal.';
266
287
  }
267
288
  let route = 'The method has no `apiRoute`.';
268
- let headers = false;
289
+ let headers = null;
269
290
  let cacheExpiry = false;
270
291
  for (const property of options.properties) {
271
292
  if (!ts.isPropertyAssignment(property))
@@ -288,7 +309,13 @@ const parseServiceMethod = (method, sourceFile) => {
288
309
  (ts.isPropertyAssignment(element) && element.name.getText(sourceFile) === 'headers'));
289
310
  if (!spreadsArgs)
290
311
  return `Unsupported \`httpOpts\`: \`${value.getText(sourceFile)}\`.`;
291
- headers = true;
312
+ const header = value.properties.find((element) => ts.isPropertyAssignment(element) && element.name.getText(sourceFile) === 'headers');
313
+ if (header) {
314
+ headers = {
315
+ line: lineOf(sourceFile, header.getStart(sourceFile)),
316
+ description: describeHeaders(header.initializer, method, sourceFile),
317
+ };
318
+ }
292
319
  }
293
320
  else if (name === 'extras') {
294
321
  if (!ts.isObjectLiteralExpression(value))
@@ -450,8 +477,8 @@ const analyzeFeature = (tree, graph, input) => {
450
477
  if (method.headers) {
451
478
  warnings.push({
452
479
  id: TOOLKIT_TASK.SERVICE_HEADERS,
453
- summary: `\`${serviceMethod}\` added headers to the request; the creator \`${group.name}\` does not.`,
454
- locations: [{ filePath: files.service }],
480
+ summary: `\`${serviceMethod}\` added the headers ${method.headers.description}; the creator \`${group.name}\` does not.`,
481
+ locations: [{ filePath: files.service, line: method.headers.line }],
455
482
  });
456
483
  }
457
484
  if (method.cacheExpiry) {
@@ -6,6 +6,7 @@ import { migrateToolkitConsumers } from './consumers.js';
6
6
  import { rewriteFacade } from './facade.js';
7
7
  import { discoverFeatures } from './feature.js';
8
8
  import { TOOLKIT_TASK, ToolkitMigrationReport } from './report.js';
9
+ import { reportLegacyModuleResolution } from './module-resolution.js';
9
10
  import { removeFeatureStores } from './store-removal.js';
10
11
  const parsePublicRoutes = (value) => {
11
12
  if (value === undefined)
@@ -114,6 +115,7 @@ export const migrateToolkitStores = (tree, options, report) => {
114
115
  const removal = removeFeatureStores(tree, graph, inScope, ready, report);
115
116
  const migratedFacades = new Set(ready.map((feature) => feature.files.facade).filter((file) => !!file));
116
117
  const consumers = migrateToolkitConsumers(tree, createModuleGraph(tree), inScope, report, migratedFacades);
118
+ reportLegacyModuleResolution(tree, createModuleGraph(tree), inScope, report);
117
119
  return {
118
120
  featuresFound: discovered.features.length,
119
121
  featuresConverted: ready.length,
@@ -130,7 +132,7 @@ export default async function migrate(tree, schema) {
130
132
  const report = new ToolkitMigrationReport();
131
133
  const stats = migrateToolkitStores(tree, schema, report);
132
134
  console.log(` ${stats.featuresConverted}/${stats.featuresFound} features converted, ${stats.creatorsWritten} creators written, ${stats.creatorsReused} reused, ${stats.deletedFiles.length} files deleted.`);
133
- console.log(` ${stats.consumers.filesRewritten} consumer files moved to @ethlete/query/ngrx-toolkit, ${stats.consumers.refreshSitesRewritten} refresh sites rewritten.`);
135
+ console.log(` ${stats.consumers.filesRewritten} consumer files moved to @ethlete/query/ngrx-toolkit, ${stats.consumers.refreshSitesRewritten} refresh sites and ${stats.consumers.facadeListenersRewritten} facade listeners rewritten.`);
134
136
  report.writeToTree(tree);
135
137
  report.printSummary();
136
138
  if (!schema.skipFormat) {
@@ -0,0 +1,23 @@
1
+ import { findLegacyModuleResolutions } from '../migrate-to-query-v3/module-resolution.js';
2
+ import { INTEROP_MODULE } from './facade.js';
3
+ export const MODULE_RESOLUTION_TASK = 'NTK-MODULE-RESOLUTION';
4
+ /**
5
+ * Records a task for every project tsconfig that resolves modules with `node` / `node10` while the project imports
6
+ * `@ethlete/query/ngrx-toolkit`, directly or through a workspace path alias.
7
+ */
8
+ export const reportLegacyModuleResolution = (tree, graph, files, report) => {
9
+ report.define(MODULE_RESOLUTION_TASK, {
10
+ title: 'Project resolves modules with `node`, which cannot find `@ethlete/query/ngrx-toolkit`',
11
+ action: '`moduleResolution: node` (`node10`) ignores the `exports` map of `@ethlete/query`, so the `@ethlete/query/ngrx-toolkit` import does not resolve - also when it sits in another project this one imports through a path alias. Set `moduleResolution` to `bundler` in the listed tsconfig (or the base config it inherits it from).',
12
+ });
13
+ for (const { configPath, resolution, alias } of findLegacyModuleResolutions(tree, graph, files, (specifier) => specifier === INTEROP_MODULE)) {
14
+ report.add({
15
+ id: MODULE_RESOLUTION_TASK,
16
+ summary: alias
17
+ ? `${configPath} resolves modules with \`${resolution}\` and reaches \`${INTEROP_MODULE}\` through \`${alias}\`.`
18
+ : `${configPath} resolves modules with \`${resolution}\`.`,
19
+ locations: [{ filePath: configPath }],
20
+ });
21
+ }
22
+ };
23
+ //# sourceMappingURL=module-resolution.js.map