@ethlete/query 6.0.0-next.5 → 6.0.0-next.50

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 (31) hide show
  1. package/CHANGELOG.md +766 -0
  2. package/fesm2022/ethlete-query-testing.mjs +441 -38
  3. package/fesm2022/ethlete-query-testing.mjs.map +1 -1
  4. package/fesm2022/ethlete-query.mjs +15946 -5331
  5. package/fesm2022/ethlete-query.mjs.map +1 -1
  6. package/generators/deprecate-legacy-queries/migration.js +77 -0
  7. package/generators/deprecate-legacy-queries/schema.json +26 -0
  8. package/generators/generators.json +15 -0
  9. package/generators/migrate-query-client-features/migration.js +142 -0
  10. package/generators/migrate-query-client-features/report.js +136 -0
  11. package/generators/migrate-query-client-features/schema.json +26 -0
  12. package/generators/migrate-query-opt-in-features/migration.js +194 -0
  13. package/generators/migrate-query-opt-in-features/report.js +143 -0
  14. package/generators/migrate-query-opt-in-features/schema.json +31 -0
  15. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +133 -0
  16. package/generators/migrate-to-query-v3/cleanup-migration.js +111 -99
  17. package/generators/migrate-to-query-v3/http-client-check.js +93 -0
  18. package/generators/migrate-to-query-v3/legacy-prepare-migration.js +129 -40
  19. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +237 -152
  20. package/generators/migrate-to-query-v3/migration-scope.js +38 -0
  21. package/generators/migrate-to-query-v3/migration.js +17 -9
  22. package/generators/migrate-to-query-v3/module-graph.js +153 -0
  23. package/generators/migrate-to-query-v3/query-client-migration.js +152 -53
  24. package/generators/migrate-to-query-v3/rename-symbols.js +206 -0
  25. package/generators/migrate-to-query-v3/schema.json +12 -0
  26. package/generators/migrate-to-query-v3/shared.js +51 -2
  27. package/generators/prep-for-query-v3/migration.js +71 -14
  28. package/migrations.json +44 -0
  29. package/package.json +29 -13
  30. package/types/ethlete-query-testing.d.ts +179 -12
  31. package/types/ethlete-query.d.ts +8935 -3203
@@ -0,0 +1,93 @@
1
+ import { getLineNumberFromPosition } from './shared.js';
2
+ const APP_CONFIG_MARKERS = ['bootstrapApplication(', 'ApplicationConfig', 'provideServerRendering('];
3
+ /**
4
+ * Flags the v2 default-header APIs, which the client migration drops on the floor.
5
+ *
6
+ * `setDefaultHeaders` is not cosmetic - an API token or a preview credential lives there, so losing
7
+ * it means every request goes out unauthenticated. v3 has a home for it (`headers` on
8
+ * `createQueryClient`), but the call sites are imperative and scattered, so this is a pointer rather
9
+ * than a rewrite.
10
+ */
11
+ export const reportDefaultHeaderUsages = (tree, report, scope) => {
12
+ const locations = [];
13
+ scope.visit(tree, (filePath) => {
14
+ if (!filePath.endsWith('.ts')) {
15
+ return;
16
+ }
17
+ const content = tree.read(filePath, 'utf-8');
18
+ if (!content) {
19
+ return;
20
+ }
21
+ for (const marker of ['setDefaultHeaders', 'globalHeaders']) {
22
+ const index = content.indexOf(marker);
23
+ if (index >= 0) {
24
+ locations.push({ filePath, line: getLineNumberFromPosition(content, index) });
25
+ }
26
+ }
27
+ });
28
+ if (locations.length === 0) {
29
+ return;
30
+ }
31
+ report.addWarning({
32
+ title: 'Move default headers onto the query client',
33
+ summary: 'This workspace sets client-wide headers through the v2 `setDefaultHeaders` / `globalHeaders` API, which `createQueryClient` does not have. Left as is, those headers are simply not sent.',
34
+ action: 'Pass `headers` to `createQueryClient` - a function form re-reads on every request, so a signal can drive it. `refreshQueriesInUse: true` becomes `injectMyClient().refreshQueriesInUse()` after the value changes.',
35
+ locations,
36
+ source: 'http-client-check',
37
+ dedupeKey: 'default-headers',
38
+ });
39
+ };
40
+ /**
41
+ * Flags applications that will build cleanly and then fail on their first request.
42
+ *
43
+ * The v2 client shipped its own transport; v3 does `inject(HttpClient)`. An app that never needed
44
+ * `provideHttpClient()` therefore keeps compiling after the migration and dies at runtime instead -
45
+ * the one failure mode a codemod should never leave behind silently.
46
+ */
47
+ export const reportMissingHttpClientProviders = (tree, report, scope) => {
48
+ const appConfigFiles = [];
49
+ const filesWithProvider = [];
50
+ const filesWithUploadProgress = [];
51
+ scope.visit(tree, (filePath) => {
52
+ if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
53
+ return;
54
+ }
55
+ const content = tree.read(filePath, 'utf-8');
56
+ if (!content) {
57
+ return;
58
+ }
59
+ if (content.includes('provideHttpClient(')) {
60
+ filesWithProvider.push(filePath);
61
+ }
62
+ const markerIndex = APP_CONFIG_MARKERS.map((marker) => content.indexOf(marker)).find((index) => index >= 0);
63
+ if (markerIndex !== undefined) {
64
+ appConfigFiles.push({ filePath, line: getLineNumberFromPosition(content, markerIndex) });
65
+ }
66
+ const progressIndex = content.indexOf('reportProgress');
67
+ if (progressIndex >= 0) {
68
+ filesWithUploadProgress.push({ filePath, line: getLineNumberFromPosition(content, progressIndex) });
69
+ }
70
+ });
71
+ if (appConfigFiles.length > 0 && filesWithProvider.length === 0) {
72
+ report.addWarning({
73
+ title: 'Add provideHttpClient() to the application providers',
74
+ summary: 'v3 queries run on Angular’s `HttpClient`, but no `provideHttpClient()` was found. The app compiles and then throws on the first request - `@ethlete/query` never provides it, that is the application’s job.',
75
+ action: 'Add `provideHttpClient()` to every app config that uses a query client. If anything relies on upload progress, use `provideHttpClient(withXhr())` - on Angular ≥ 22 the default is fetch, which emits no upload progress events.',
76
+ locations: appConfigFiles,
77
+ source: 'http-client-check',
78
+ dedupeKey: 'missing-provide-http-client',
79
+ });
80
+ return;
81
+ }
82
+ if (filesWithUploadProgress.length > 0) {
83
+ report.addManualReview({
84
+ title: 'Confirm the HttpClient backend supports upload progress',
85
+ summary: 'Queries in this workspace set `reportProgress`. On Angular ≥ 22 `provideHttpClient()` defaults to the fetch backend, which never emits upload progress events, so those queries would report download progress only.',
86
+ action: 'Switch the affected app to `provideHttpClient(withXhr())`, or drop `reportProgress` where it is unused.',
87
+ locations: filesWithUploadProgress,
88
+ source: 'http-client-check',
89
+ dedupeKey: 'upload-progress-backend',
90
+ });
91
+ }
92
+ };
93
+ //# sourceMappingURL=http-client-check.js.map
@@ -1,10 +1,19 @@
1
- import { visitNotIgnoredFiles } from '@nx/devkit';
2
1
  import * as ts from 'typescript';
3
2
  import { createSourceFile, ensureAngularCoreImports, getIndentation, getLineNumber, getLineNumberFromPosition, } from './shared.js';
4
- export const migrateLegacyPrepareCalls = (tree, report) => {
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;
6
+ /** Contexts that already have an injector of their own, so the call site needs no rewrite. */
7
+ const CONTEXTS_WITH_INJECTOR = [
8
+ 'queryComputed',
9
+ 'injection-context',
10
+ 'class-field',
11
+ 'constructor',
12
+ ];
13
+ export const migrateLegacyPrepareCalls = (tree, report, scope) => {
5
14
  console.log('\n🔍 Checking for legacy query creator usages...');
6
15
  const allUsages = [];
7
- visitNotIgnoredFiles(tree, '', (filePath) => {
16
+ scope.visit(tree, (filePath) => {
8
17
  if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
9
18
  return;
10
19
  }
@@ -27,7 +36,7 @@ export const migrateLegacyPrepareCalls = (tree, report) => {
27
36
  console.log(` - ${context}: ${count} usages`);
28
37
  });
29
38
  console.log('\n🔍 Detecting polling usage...');
30
- const pollingInfo = detectPollingUsage(tree);
39
+ const pollingInfo = detectPollingUsage(tree, scope);
31
40
  if (pollingInfo.size > 0) {
32
41
  console.log(`\n⚠️ Found ${pollingInfo.size} queries with polling:`);
33
42
  pollingInfo.forEach((info) => {
@@ -38,7 +47,7 @@ export const migrateLegacyPrepareCalls = (tree, report) => {
38
47
  }
39
48
  const classInjectors = findOrCreateInjectorMembers(tree, allUsages);
40
49
  createInjectorMembers(tree, classInjectors);
41
- transformLegacyCreatorPrepareCalls(tree, classInjectors, pollingInfo);
50
+ transformLegacyCreatorPrepareCalls(tree, classInjectors, pollingInfo, scope);
42
51
  reportManualReviewLocations(tree, allUsages, report);
43
52
  };
44
53
  const findLegacyCreatorUsages = (content, filePath) => {
@@ -99,32 +108,88 @@ const findLegacyCreatorNames = (sourceFile) => {
99
108
  visit(sourceFile);
100
109
  return names;
101
110
  };
111
+ /** Helpers that run their callback inside an injection context, so a `prepare()` in one needs nothing. */
112
+ const CONTEXT_PROVIDING_CALLEES = new Set([
113
+ 'runInInjectionContext',
114
+ 'queryComputed',
115
+ 'queryComputedTillTruthy',
116
+ 'queryArrayComputed',
117
+ ]);
118
+ /**
119
+ * Array methods, which call back synchronously in the caller's own context. Matched only on a property
120
+ * access (`items.map(…)`) - the bare-identifier form is an RxJS operator, whose callback runs later.
121
+ */
122
+ const TRANSPARENT_ARRAY_METHODS = new Set([
123
+ 'map',
124
+ 'flatMap',
125
+ 'filter',
126
+ 'forEach',
127
+ 'find',
128
+ 'findLast',
129
+ 'findIndex',
130
+ 'some',
131
+ 'every',
132
+ 'reduce',
133
+ 'reduceRight',
134
+ 'sort',
135
+ 'flat',
136
+ ]);
137
+ const isCallbackFunction = (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node);
138
+ /** The call a function literal is being passed to, if it is an argument rather than a value. */
139
+ const getCallbackHost = (node) => {
140
+ const parent = node.parent;
141
+ if (!ts.isCallExpression(parent) || !parent.arguments.some((argument) => argument === node)) {
142
+ return undefined;
143
+ }
144
+ return parent;
145
+ };
146
+ const isContextProvidingCallback = (node) => {
147
+ const host = getCallbackHost(node);
148
+ if (!host) {
149
+ return false;
150
+ }
151
+ if (ts.isIdentifier(host.expression)) {
152
+ return CONTEXT_PROVIDING_CALLEES.has(host.expression.text);
153
+ }
154
+ return ts.isPropertyAccessExpression(host.expression) && host.expression.name.text === 'runInContext';
155
+ };
156
+ const isTransparentCallback = (node) => {
157
+ const host = getCallbackHost(node);
158
+ return (!!host && ts.isPropertyAccessExpression(host.expression) && TRANSPARENT_ARRAY_METHODS.has(host.expression.name.text));
159
+ };
160
+ /**
161
+ * Classifies the **innermost** function boundary the call sits behind, not the first structural ancestor.
162
+ * The difference is the whole point: `effect(() => prepare())` in a constructor and
163
+ * `computed(() => prepare())` at a class field both run long after the context that created them, so they
164
+ * need an injector just like a plain method does - reading the constructor or the field as the answer
165
+ * skipped exactly the call sites that go on to throw ET950.
166
+ */
102
167
  const determineUsageContext = (node) => {
103
168
  let current = node;
104
169
  while (current) {
105
- if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
106
- const parent = current.parent;
107
- if (ts.isCallExpression(parent) &&
108
- ts.isIdentifier(parent.expression) &&
109
- parent.expression.text === 'queryComputed') {
110
- return 'queryComputed';
170
+ if (isCallbackFunction(current)) {
171
+ if (isContextProvidingCallback(current)) {
172
+ const host = getCallbackHost(current);
173
+ const callee = host && ts.isIdentifier(host.expression) ? host.expression.text : undefined;
174
+ return callee?.startsWith('query') ? 'queryComputed' : 'injection-context';
175
+ }
176
+ if (!isTransparentCallback(current)) {
177
+ return isInsideClass(current) ? 'method' : 'function';
111
178
  }
112
179
  }
180
+ // Reached without crossing a callback: the call really does run while the class is being built, which
181
+ // is an injection context.
113
182
  if (ts.isConstructorDeclaration(current)) {
114
183
  return 'constructor';
115
184
  }
116
- if (ts.isMethodDeclaration(current)) {
185
+ if (ts.isMethodDeclaration(current) ||
186
+ ts.isGetAccessorDeclaration(current) ||
187
+ ts.isSetAccessorDeclaration(current)) {
117
188
  return 'method';
118
189
  }
119
190
  if (ts.isPropertyDeclaration(current)) {
120
191
  return 'class-field';
121
192
  }
122
- if (ts.isFunctionDeclaration(current)) {
123
- return 'function';
124
- }
125
- if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && !isInsideClass(current)) {
126
- return 'function';
127
- }
128
193
  current = current.parent;
129
194
  }
130
195
  return 'unknown';
@@ -178,7 +243,7 @@ const findOrCreateInjectorMembers = (tree, usages) => {
178
243
  const classInjectors = new Map();
179
244
  const usagesByFile = new Map();
180
245
  usages.forEach((usage) => {
181
- if (['queryComputed', 'class-field', 'constructor'].includes(usage.context)) {
246
+ if (CONTEXTS_WITH_INJECTOR.includes(usage.context)) {
182
247
  return;
183
248
  }
184
249
  if (!usagesByFile.has(usage.filePath)) {
@@ -203,7 +268,7 @@ const findClassesNeedingInjector = (sourceFile, usages) => {
203
268
  const filePath = usages[0]?.filePath ?? sourceFile.fileName;
204
269
  const visit = (node) => {
205
270
  if (ts.isClassDeclaration(node)) {
206
- const className = node.name?.text ?? 'UnnamedClass';
271
+ const className = getClassKey(node);
207
272
  const usagesInClass = usages.filter((usage) => {
208
273
  return usage.position.start >= node.getStart(sourceFile) && usage.position.end <= node.getEnd();
209
274
  });
@@ -269,7 +334,7 @@ const addInjectorMemberToClass = (content, classInfo) => {
269
334
  const sourceFile = createSourceFile(content);
270
335
  let targetClass;
271
336
  const visit = (node) => {
272
- if (ts.isClassDeclaration(node) && node.name?.text === classInfo.className) {
337
+ if (ts.isClassDeclaration(node) && getClassKey(node) === classInfo.className) {
273
338
  targetClass = node;
274
339
  }
275
340
  ts.forEachChild(node, visit);
@@ -280,10 +345,10 @@ const addInjectorMemberToClass = (content, classInfo) => {
280
345
  }
281
346
  const classBodyStart = targetClass.members.length > 0 ? targetClass.members[0].getStart(sourceFile) : targetClass.getEnd() - 1;
282
347
  const indentation = getIndentation(content, classBodyStart);
283
- const injectorMember = `${indentation}private ${classInfo.injectorMemberName} = inject(Injector);\n\n`;
348
+ const injectorMember = `private ${classInfo.injectorMemberName} = inject(Injector);\n\n${indentation}`;
284
349
  return content.slice(0, classBodyStart) + injectorMember + content.slice(classBodyStart);
285
350
  };
286
- const transformLegacyCreatorPrepareCalls = (tree, classInjectors, pollingInfo) => {
351
+ const transformLegacyCreatorPrepareCalls = (tree, classInjectors, pollingInfo, scope) => {
287
352
  const updatedFiles = [];
288
353
  const filesByPath = new Map();
289
354
  classInjectors.forEach((classInfo) => {
@@ -292,7 +357,7 @@ const transformLegacyCreatorPrepareCalls = (tree, classInjectors, pollingInfo) =
292
357
  }
293
358
  filesByPath.get(classInfo.filePath).push(classInfo);
294
359
  });
295
- visitNotIgnoredFiles(tree, '', (filePath) => {
360
+ scope.visit(tree, (filePath) => {
296
361
  if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts') || filesByPath.has(filePath)) {
297
362
  return;
298
363
  }
@@ -343,19 +408,14 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
343
408
  return;
344
409
  }
345
410
  const context = determineUsageContext(node);
346
- if (['queryComputed', 'constructor', 'class-field'].includes(context)) {
411
+ if (CONTEXTS_WITH_INJECTOR.includes(context)) {
347
412
  ts.forEachChild(node, visit);
348
413
  return;
349
414
  }
350
415
  const containingClass = findContainingClass(node);
351
416
  const containingFunction = findContainingStandaloneFunction(node);
352
417
  if (containingClass) {
353
- const className = containingClass.name?.text;
354
- if (!className) {
355
- ts.forEachChild(node, visit);
356
- return;
357
- }
358
- const injectorMember = classNameToInjector.get(className);
418
+ const injectorMember = classNameToInjector.get(getClassKey(containingClass));
359
419
  if (!injectorMember) {
360
420
  ts.forEachChild(node, visit);
361
421
  return;
@@ -419,7 +479,7 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
419
479
  }
420
480
  injectorDeclarations.push({
421
481
  position: bodyStart,
422
- text: `${getIndentation(content, bodyStart)}const ${injectorName} = inject(Injector);\n`,
482
+ text: `const ${injectorName} = inject(Injector);\n${getIndentation(content, bodyStart)}`,
423
483
  });
424
484
  });
425
485
  injectorDeclarations.sort((left, right) => right.position - left.position);
@@ -536,7 +596,7 @@ const transformSinglePrepareCall = (callNode, injectorReference, sourceFile, pol
536
596
  }
537
597
  nextArgumentProperties.push(`injector: ${injectorReference}`);
538
598
  const queryVariableName = findQueryVariableNameForPrepareCall(callNode, sourceFile);
539
- const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo);
599
+ const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo, isDiscardedPrepareCall(callNode));
540
600
  if (existingConfig && ts.isObjectLiteralExpression(existingConfig.initializer)) {
541
601
  const configProperties = [];
542
602
  existingConfig.initializer.properties.forEach((property) => {
@@ -569,7 +629,7 @@ const transformSinglePrepareCall = (callNode, injectorReference, sourceFile, pol
569
629
  : `{\n ${nextArgumentProperties.join(',\n ')}\n}`;
570
630
  return `${creatorName}.prepare(${nextArgumentString})`;
571
631
  };
572
- const findQueryVariableNameForPrepareCall = (prepareCallNode, sourceFile) => {
632
+ const findQueryVariableNameForPrepareCall = (prepareCallNode, _sourceFile) => {
573
633
  let parent = prepareCallNode.parent;
574
634
  while (parent) {
575
635
  if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
@@ -663,9 +723,9 @@ const containsInjectCall = (node) => {
663
723
  visit(node);
664
724
  return hasInject;
665
725
  };
666
- const detectPollingUsage = (tree) => {
726
+ const detectPollingUsage = (tree, scope) => {
667
727
  const pollingInfo = new Map();
668
- visitNotIgnoredFiles(tree, '', (filePath) => {
728
+ scope.visit(tree, (filePath) => {
669
729
  if (filePath.endsWith('.spec.ts')) {
670
730
  return;
671
731
  }
@@ -717,8 +777,7 @@ const getQueryVariableName = (expression) => {
717
777
  return currentExpression.text;
718
778
  }
719
779
  if (ts.isPropertyAccessExpression(currentExpression)) {
720
- currentExpression = currentExpression.expression;
721
- continue;
780
+ return currentExpression.name.text;
722
781
  }
723
782
  if (ts.isCallExpression(currentExpression)) {
724
783
  currentExpression = currentExpression.expression;
@@ -764,9 +823,39 @@ const detectPollingInTemplate = (content, filePath, pollingInfo) => {
764
823
  }
765
824
  }
766
825
  };
767
- const shouldAddDestroyOnResponse = (queryVariableName, pollingInfo) => {
826
+ /**
827
+ * Whether the prepared query is thrown away - `legacyGetUsers.prepare().execute()` as a statement, with
828
+ * nothing holding on to it. Chained `.poll()` disqualifies it: that query is meant to keep running.
829
+ */
830
+ const isDiscardedPrepareCall = (callNode) => {
831
+ let current = callNode;
832
+ while (current.parent) {
833
+ const parent = current.parent;
834
+ if (ts.isParenthesizedExpression(parent) || ts.isAwaitExpression(parent) || ts.isNonNullExpression(parent)) {
835
+ current = parent;
836
+ continue;
837
+ }
838
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === current) {
839
+ if (parent.name.text === 'poll') {
840
+ return false;
841
+ }
842
+ current = parent;
843
+ continue;
844
+ }
845
+ // Only a chained call keeps it discarded; being an *argument* means something else receives the query.
846
+ if (ts.isCallExpression(parent) && parent.expression === current) {
847
+ current = parent;
848
+ continue;
849
+ }
850
+ return ts.isExpressionStatement(parent);
851
+ }
852
+ return false;
853
+ };
854
+ const shouldAddDestroyOnResponse = (queryVariableName, pollingInfo, isDiscarded) => {
768
855
  if (!queryVariableName) {
769
- return false;
856
+ // A discarded query is the one case where nothing else can clean it up: no variable for a container
857
+ // to hold, no caller to destroy it. That is exactly what `destroyOnResponse` is for.
858
+ return isDiscarded;
770
859
  }
771
860
  return !pollingInfo.get(queryVariableName)?.hasPolling;
772
861
  };