@ethlete/query 6.0.0-next.22 → 6.0.0-next.24

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 (26) hide show
  1. package/CHANGELOG.md +124 -8
  2. package/fesm2022/ethlete-query-testing.mjs +235 -5
  3. package/fesm2022/ethlete-query-testing.mjs.map +1 -1
  4. package/fesm2022/ethlete-query.mjs +7657 -5448
  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 +182 -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 +3 -3
  16. package/generators/migrate-to-query-v3/cleanup-migration.js +2 -2
  17. package/generators/migrate-to-query-v3/http-client-check.js +5 -5
  18. package/generators/migrate-to-query-v3/legacy-prepare-migration.js +111 -18
  19. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +5 -4
  20. package/generators/migrate-to-query-v3/query-client-migration.js +6 -5
  21. package/generators/migrate-to-query-v3/rename-symbols.js +4 -4
  22. package/generators/migrate-to-query-v3/schema.json +1 -1
  23. package/generators/migrate-to-query-v3/shared.js +45 -0
  24. package/package.json +18 -1
  25. package/types/ethlete-query-testing.d.ts +98 -12
  26. package/types/ethlete-query.d.ts +6055 -3819
@@ -0,0 +1,143 @@
1
+ export const QUERY_OPT_IN_FEATURES_REPORT_PATH = 'query-opt-in-features-migration-tasks.md';
2
+ export const MIGRATION_TASK_KIND = {
3
+ WARNING: 'warning',
4
+ MANUAL_REVIEW: 'manual-review',
5
+ FOLLOW_UP: 'follow-up',
6
+ };
7
+ const renderTaskLocations = (locations) => {
8
+ if (locations.length === 0) {
9
+ return '- None recorded';
10
+ }
11
+ return locations.map((location) => `- ${location.filePath}${location.line ? `:${location.line}` : ''}`).join('\n');
12
+ };
13
+ export class QueryOptInFeaturesMigrationReport {
14
+ constructor() {
15
+ this.tasks = [];
16
+ }
17
+ addTask(input) {
18
+ const dedupeKey = input.dedupeKey ?? `${input.kind}:${input.title}:${input.summary}:${input.action}`;
19
+ const existing = this.tasks.find((task) => task.dedupeKey === dedupeKey);
20
+ if (existing) {
21
+ existing.locations.push(...(input.locations ?? []));
22
+ return;
23
+ }
24
+ this.tasks.push({
25
+ id: `QOF-${String(this.tasks.length + 1).padStart(3, '0')}`,
26
+ kind: input.kind,
27
+ title: input.title,
28
+ summary: input.summary,
29
+ action: input.action,
30
+ locations: input.locations ?? [],
31
+ source: input.source,
32
+ dedupeKey,
33
+ });
34
+ }
35
+ addWarning(input) {
36
+ this.addTask({ ...input, kind: MIGRATION_TASK_KIND.WARNING });
37
+ }
38
+ addManualReview(input) {
39
+ this.addTask({ ...input, kind: MIGRATION_TASK_KIND.MANUAL_REVIEW });
40
+ }
41
+ addFollowUp(input) {
42
+ this.addTask({ ...input, kind: MIGRATION_TASK_KIND.FOLLOW_UP });
43
+ }
44
+ printSummary() {
45
+ if (this.tasks.length === 0) {
46
+ return;
47
+ }
48
+ console.warn(`\n⚠️ Generated ${this.tasks.length} follow-up task${this.tasks.length === 1 ? '' : 's'}:`);
49
+ this.tasks.forEach((task) => {
50
+ const locations = task.locations
51
+ .map((location) => `${location.filePath}${location.line ? `:${location.line}` : ''}`)
52
+ .join(', ');
53
+ console.warn(` - ${task.id} [${task.kind}] ${task.title}${locations ? ` (${locations})` : ''}`);
54
+ });
55
+ console.warn(`\n📄 See ${QUERY_OPT_IN_FEATURES_REPORT_PATH} for the AI-ready follow-up list.`);
56
+ }
57
+ writeToTree(tree) {
58
+ tree.write(QUERY_OPT_IN_FEATURES_REPORT_PATH, renderMigrationReportMarkdown(this.tasks));
59
+ }
60
+ }
61
+ export const renderMigrationReportMarkdown = (tasks) => {
62
+ const header = [
63
+ '# Query Opt-In Features Migration Follow-Up',
64
+ '',
65
+ 'This file is generated by the `migrate-query-opt-in-features` generator.',
66
+ '',
67
+ '## Context',
68
+ '',
69
+ 'Two things that used to be on by default are now opt-in features:',
70
+ '',
71
+ '- error-response parsing (HTML error pages, Symfony / class-validator violation lists) and the',
72
+ ' default retry policy, now `withEthleteApiErrors()` on `createQueryClient`,',
73
+ '- bearer-auth multi-tab sync and leader election, now `withBearerAuthMultiTabSync()` on',
74
+ ' `createBearerAuthProvider` (the `multiTabSync` config option is gone).',
75
+ '',
76
+ 'The codemod is **behavior preserving**: it adds the features every call site used to get for free.',
77
+ 'That is rarely what an app wants long term — an API that never answers with an HTML error page, or',
78
+ 'an app that is only ever open in one tab, ships that code for nothing. Review each added feature',
79
+ 'and delete the ones you do not need; `withEthleteApiErrors()` in particular splits into',
80
+ '`withHtmlErrorParsing()`, `withSymfonyErrors()` and `withDefaultRetry()`.',
81
+ '',
82
+ '## AI Instructions',
83
+ '',
84
+ '1. Re-read the referenced files before changing anything. The migration output is only a starting point.',
85
+ '2. Prefer minimal fixes that align with the current runtime APIs in `@ethlete/query`.',
86
+ '3. Update or remove a task only after the code and tests confirm that it is resolved.',
87
+ '4. If a task is still ambiguous after inspection, keep it open and append a short note instead of guessing.',
88
+ '',
89
+ '## Task Format',
90
+ '',
91
+ '- `Status`: always treat generated tasks as `open` until verified manually or by tests.',
92
+ '- `Kind`: `warning`, `manual-review`, or `follow-up`.',
93
+ '- `Source`: which migration phase raised the task.',
94
+ '',
95
+ ].join('\n');
96
+ if (tasks.length === 0) {
97
+ return `${header}## Tasks\n\nNo open follow-up tasks were recorded during this migration run.\n`;
98
+ }
99
+ const tasksSection = tasks
100
+ .map((task) => [
101
+ `### ${task.id} - ${task.title}`,
102
+ '',
103
+ '- Status: open',
104
+ `- Kind: ${task.kind}`,
105
+ `- Source: ${task.source}`,
106
+ `- Summary: ${task.summary}`,
107
+ `- Suggested action: ${task.action}`,
108
+ '- Locations:',
109
+ renderTaskLocations(task.locations),
110
+ '',
111
+ ].join('\n'))
112
+ .join('');
113
+ const machineSection = [
114
+ '## Machine Summary',
115
+ '',
116
+ '```yaml',
117
+ 'tasks:',
118
+ ...tasks.flatMap((task) => {
119
+ const nestedLocations = task.locations.flatMap((location) => {
120
+ const lines = [` - filePath: ${location.filePath}`];
121
+ if (location.line) {
122
+ lines.push(` line: ${location.line}`);
123
+ }
124
+ return lines;
125
+ });
126
+ return [
127
+ ` - id: ${task.id}`,
128
+ ` status: open`,
129
+ ` kind: ${task.kind}`,
130
+ ` source: ${task.source}`,
131
+ ` title: ${task.title}`,
132
+ ` summary: ${task.summary}`,
133
+ ` action: ${task.action}`,
134
+ ` locations:`,
135
+ ...(nestedLocations.length > 0 ? nestedLocations : [' []']),
136
+ ];
137
+ }),
138
+ '```',
139
+ '',
140
+ ].join('\n');
141
+ return `${header}## Tasks\n\n${tasksSection}${machineSection}`;
142
+ };
143
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "migrate-query-opt-in-features",
4
+ "title": "Migrate query clients and auth providers to opt-in features",
5
+ "type": "object",
6
+ "properties": {
7
+ "projects": {
8
+ "type": "array",
9
+ "items": { "type": "string" },
10
+ "description": "Nx projects to migrate. Defaults to every project in the workspace.",
11
+ "alias": "project"
12
+ },
13
+ "include": {
14
+ "type": "array",
15
+ "items": { "type": "string" },
16
+ "description": "Path prefixes to migrate, relative to the workspace root (e.g. 'libs/queries'). Unioned with `projects`. Defaults to the whole workspace.",
17
+ "alias": "includePath"
18
+ },
19
+ "reportOnly": {
20
+ "type": "boolean",
21
+ "description": "Only list the affected call sites in the report file, without changing any code.",
22
+ "default": false
23
+ },
24
+ "skipFormat": {
25
+ "type": "boolean",
26
+ "description": "Skip formatting files after migration",
27
+ "default": false
28
+ }
29
+ },
30
+ "required": []
31
+ }
@@ -69,8 +69,8 @@ const REFRESH_QUERY_KEY = 'tokenRefresh';
69
69
  /**
70
70
  * Renders the `queries` / `features` arguments of a v3 provider from a v2 config.
71
71
  *
72
- * The mapping is close to 1:1 — `queryCreator` → `withRefreshQuery`, `responseAdapter` →
73
- * `extractTokens`, `cookieName` → `withPersistentAuth({ cookie })` — but the adapters are copied
72
+ * The mapping is close to 1:1 - `queryCreator` → `withRefreshQuery`, `responseAdapter` →
73
+ * `extractTokens`, `cookieName` → `withPersistentAuth({ cookie })` - but the adapters are copied
74
74
  * verbatim from v2 and their parameter shapes differ, so each one is emitted with a TODO rather
75
75
  * than presented as finished.
76
76
  */
@@ -85,7 +85,7 @@ export const renderAuthProviderBody = (config) => {
85
85
  }
86
86
  const refreshEntries = [` queryCreator: ${config.refreshCreatorName},`];
87
87
  if (config.responseAdapter) {
88
- refreshEntries.push(` // TODO(query-v3): v2 responseAdapter. It must return { accessToken, refreshToken } — v2 returned { token, refreshToken }.`, ` extractTokens: ${config.responseAdapter},`);
88
+ refreshEntries.push(` // TODO(query-v3): v2 responseAdapter. It must return { accessToken, refreshToken } - v2 returned { token, refreshToken }.`, ` extractTokens: ${config.responseAdapter},`);
89
89
  }
90
90
  if (config.requestArgsAdapter) {
91
91
  refreshEntries.push(` // TODO(query-v3): v2 requestArgsAdapter took { token, refreshToken }; buildArgs takes the refresh token alone.`, ` buildArgs: ${config.requestArgsAdapter},`);
@@ -3,7 +3,7 @@ import { createSourceFile, ensureImportFromQuery, ensureNamedImports, getLineNum
3
3
  /**
4
4
  * Points existing devtools usage at the v3 components instead of deleting it.
5
5
  *
6
- * Both versions render `<et-query-devtools>`, so templates need no change at all — only the provider
6
+ * Both versions render `<et-query-devtools>`, so templates need no change at all - only the provider
7
7
  * call and the component's import move. Stripping the markup (as this phase used to) threw away
8
8
  * something that would have kept working, and left every migrated app without devtools until someone
9
9
  * noticed they were gone.
@@ -119,7 +119,7 @@ const importsFromQuery = (content, name) => {
119
119
  * `provideQueryDevtools()`.
120
120
  *
121
121
  * v3 registers every client and auth provider at once, so N per-client calls collapse to one. The
122
- * first call site keeps its position — it is already where the app wanted its devtools — and the
122
+ * first call site keeps its position - it is already where the app wanted its devtools - and the
123
123
  * rest are removed along with the comma that separated them.
124
124
  */
125
125
  const replaceDevtoolsProviderCalls = (content) => {
@@ -3,7 +3,7 @@ const APP_CONFIG_MARKERS = ['bootstrapApplication(', 'ApplicationConfig', 'provi
3
3
  /**
4
4
  * Flags the v2 default-header APIs, which the client migration drops on the floor.
5
5
  *
6
- * `setDefaultHeaders` is not cosmetic — an API token or a preview credential lives there, so losing
6
+ * `setDefaultHeaders` is not cosmetic - an API token or a preview credential lives there, so losing
7
7
  * it means every request goes out unauthenticated. v3 has a home for it (`headers` on
8
8
  * `createQueryClient`), but the call sites are imperative and scattered, so this is a pointer rather
9
9
  * than a rewrite.
@@ -31,7 +31,7 @@ export const reportDefaultHeaderUsages = (tree, report, scope) => {
31
31
  report.addWarning({
32
32
  title: 'Move default headers onto the query client',
33
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.',
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
35
  locations,
36
36
  source: 'http-client-check',
37
37
  dedupeKey: 'default-headers',
@@ -41,7 +41,7 @@ export const reportDefaultHeaderUsages = (tree, report, scope) => {
41
41
  * Flags applications that will build cleanly and then fail on their first request.
42
42
  *
43
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 —
44
+ * `provideHttpClient()` therefore keeps compiling after the migration and dies at runtime instead -
45
45
  * the one failure mode a codemod should never leave behind silently.
46
46
  */
47
47
  export const reportMissingHttpClientProviders = (tree, report, scope) => {
@@ -71,8 +71,8 @@ export const reportMissingHttpClientProviders = (tree, report, scope) => {
71
71
  if (appConfigFiles.length > 0 && filesWithProvider.length === 0) {
72
72
  report.addWarning({
73
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.',
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
76
  locations: appConfigFiles,
77
77
  source: 'http-client-check',
78
78
  dedupeKey: 'missing-provide-http-client',
@@ -1,5 +1,12 @@
1
1
  import * as ts from 'typescript';
2
2
  import { createSourceFile, ensureAngularCoreImports, getIndentation, getLineNumber, getLineNumberFromPosition, } from './shared.js';
3
+ /** Contexts that already have an injector of their own, so the call site needs no rewrite. */
4
+ const CONTEXTS_WITH_INJECTOR = [
5
+ 'queryComputed',
6
+ 'injection-context',
7
+ 'class-field',
8
+ 'constructor',
9
+ ];
3
10
  export const migrateLegacyPrepareCalls = (tree, report, scope) => {
4
11
  console.log('\n🔍 Checking for legacy query creator usages...');
5
12
  const allUsages = [];
@@ -98,32 +105,88 @@ const findLegacyCreatorNames = (sourceFile) => {
98
105
  visit(sourceFile);
99
106
  return names;
100
107
  };
108
+ /** Helpers that run their callback inside an injection context, so a `prepare()` in one needs nothing. */
109
+ const CONTEXT_PROVIDING_CALLEES = new Set([
110
+ 'runInInjectionContext',
111
+ 'queryComputed',
112
+ 'queryComputedTillTruthy',
113
+ 'queryArrayComputed',
114
+ ]);
115
+ /**
116
+ * Array methods, which call back synchronously in the caller's own context. Matched only on a property
117
+ * access (`items.map(…)`) - the bare-identifier form is an RxJS operator, whose callback runs later.
118
+ */
119
+ const TRANSPARENT_ARRAY_METHODS = new Set([
120
+ 'map',
121
+ 'flatMap',
122
+ 'filter',
123
+ 'forEach',
124
+ 'find',
125
+ 'findLast',
126
+ 'findIndex',
127
+ 'some',
128
+ 'every',
129
+ 'reduce',
130
+ 'reduceRight',
131
+ 'sort',
132
+ 'flat',
133
+ ]);
134
+ const isCallbackFunction = (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node);
135
+ /** The call a function literal is being passed to, if it is an argument rather than a value. */
136
+ const getCallbackHost = (node) => {
137
+ const parent = node.parent;
138
+ if (!ts.isCallExpression(parent) || !parent.arguments.some((argument) => argument === node)) {
139
+ return undefined;
140
+ }
141
+ return parent;
142
+ };
143
+ const isContextProvidingCallback = (node) => {
144
+ const host = getCallbackHost(node);
145
+ if (!host) {
146
+ return false;
147
+ }
148
+ if (ts.isIdentifier(host.expression)) {
149
+ return CONTEXT_PROVIDING_CALLEES.has(host.expression.text);
150
+ }
151
+ return ts.isPropertyAccessExpression(host.expression) && host.expression.name.text === 'runInContext';
152
+ };
153
+ const isTransparentCallback = (node) => {
154
+ const host = getCallbackHost(node);
155
+ return (!!host && ts.isPropertyAccessExpression(host.expression) && TRANSPARENT_ARRAY_METHODS.has(host.expression.name.text));
156
+ };
157
+ /**
158
+ * Classifies the **innermost** function boundary the call sits behind, not the first structural ancestor.
159
+ * The difference is the whole point: `effect(() => prepare())` in a constructor and
160
+ * `computed(() => prepare())` at a class field both run long after the context that created them, so they
161
+ * need an injector just like a plain method does - reading the constructor or the field as the answer
162
+ * skipped exactly the call sites that go on to throw ET950.
163
+ */
101
164
  const determineUsageContext = (node) => {
102
165
  let current = node;
103
166
  while (current) {
104
- if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
105
- const parent = current.parent;
106
- if (ts.isCallExpression(parent) &&
107
- ts.isIdentifier(parent.expression) &&
108
- parent.expression.text === 'queryComputed') {
109
- return 'queryComputed';
167
+ if (isCallbackFunction(current)) {
168
+ if (isContextProvidingCallback(current)) {
169
+ const host = getCallbackHost(current);
170
+ const callee = host && ts.isIdentifier(host.expression) ? host.expression.text : undefined;
171
+ return callee?.startsWith('query') ? 'queryComputed' : 'injection-context';
172
+ }
173
+ if (!isTransparentCallback(current)) {
174
+ return isInsideClass(current) ? 'method' : 'function';
110
175
  }
111
176
  }
177
+ // Reached without crossing a callback: the call really does run while the class is being built, which
178
+ // is an injection context.
112
179
  if (ts.isConstructorDeclaration(current)) {
113
180
  return 'constructor';
114
181
  }
115
- if (ts.isMethodDeclaration(current)) {
182
+ if (ts.isMethodDeclaration(current) ||
183
+ ts.isGetAccessorDeclaration(current) ||
184
+ ts.isSetAccessorDeclaration(current)) {
116
185
  return 'method';
117
186
  }
118
187
  if (ts.isPropertyDeclaration(current)) {
119
188
  return 'class-field';
120
189
  }
121
- if (ts.isFunctionDeclaration(current)) {
122
- return 'function';
123
- }
124
- if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && !isInsideClass(current)) {
125
- return 'function';
126
- }
127
190
  current = current.parent;
128
191
  }
129
192
  return 'unknown';
@@ -177,7 +240,7 @@ const findOrCreateInjectorMembers = (tree, usages) => {
177
240
  const classInjectors = new Map();
178
241
  const usagesByFile = new Map();
179
242
  usages.forEach((usage) => {
180
- if (['queryComputed', 'class-field', 'constructor'].includes(usage.context)) {
243
+ if (CONTEXTS_WITH_INJECTOR.includes(usage.context)) {
181
244
  return;
182
245
  }
183
246
  if (!usagesByFile.has(usage.filePath)) {
@@ -342,7 +405,7 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
342
405
  return;
343
406
  }
344
407
  const context = determineUsageContext(node);
345
- if (['queryComputed', 'constructor', 'class-field'].includes(context)) {
408
+ if (CONTEXTS_WITH_INJECTOR.includes(context)) {
346
409
  ts.forEachChild(node, visit);
347
410
  return;
348
411
  }
@@ -535,7 +598,7 @@ const transformSinglePrepareCall = (callNode, injectorReference, sourceFile, pol
535
598
  }
536
599
  nextArgumentProperties.push(`injector: ${injectorReference}`);
537
600
  const queryVariableName = findQueryVariableNameForPrepareCall(callNode, sourceFile);
538
- const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo);
601
+ const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo, isDiscardedPrepareCall(callNode));
539
602
  if (existingConfig && ts.isObjectLiteralExpression(existingConfig.initializer)) {
540
603
  const configProperties = [];
541
604
  existingConfig.initializer.properties.forEach((property) => {
@@ -763,9 +826,39 @@ const detectPollingInTemplate = (content, filePath, pollingInfo) => {
763
826
  }
764
827
  }
765
828
  };
766
- const shouldAddDestroyOnResponse = (queryVariableName, pollingInfo) => {
829
+ /**
830
+ * Whether the prepared query is thrown away - `legacyGetUsers.prepare().execute()` as a statement, with
831
+ * nothing holding on to it. Chained `.poll()` disqualifies it: that query is meant to keep running.
832
+ */
833
+ const isDiscardedPrepareCall = (callNode) => {
834
+ let current = callNode;
835
+ while (current.parent) {
836
+ const parent = current.parent;
837
+ if (ts.isParenthesizedExpression(parent) || ts.isAwaitExpression(parent) || ts.isNonNullExpression(parent)) {
838
+ current = parent;
839
+ continue;
840
+ }
841
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === current) {
842
+ if (parent.name.text === 'poll') {
843
+ return false;
844
+ }
845
+ current = parent;
846
+ continue;
847
+ }
848
+ // Only a chained call keeps it discarded; being an *argument* means something else receives the query.
849
+ if (ts.isCallExpression(parent) && parent.expression === current) {
850
+ current = parent;
851
+ continue;
852
+ }
853
+ return ts.isExpressionStatement(parent);
854
+ }
855
+ return false;
856
+ };
857
+ const shouldAddDestroyOnResponse = (queryVariableName, pollingInfo, isDiscarded) => {
767
858
  if (!queryVariableName) {
768
- return false;
859
+ // A discarded query is the one case where nothing else can clean it up: no variable for a container
860
+ // to hold, no caller to destroy it. That is exactly what `destroyOnResponse` is for.
861
+ return isDiscarded;
769
862
  }
770
863
  return !pollingInfo.get(queryVariableName)?.hasPolling;
771
864
  };
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
2
2
  import { collectV2BearerAuthConfigs, renderAuthProviderBody } from './auth-provider-scaffold.js';
3
3
  import { createModuleGraph } from './module-graph.js';
4
4
  import { pruneUnusedNamedImports, renameImportedReferences } from './rename-symbols.js';
5
- import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromQuery } from './shared.js';
5
+ import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, legacyQueryDeprecationTag, } from './shared.js';
6
6
  const LEGACY_HTTP_METHOD = {
7
7
  GET: 'get',
8
8
  POST: 'post',
@@ -443,7 +443,7 @@ const getTypeParameter = (creator) => {
443
443
  const generateLegacyWrapper = (creator) => {
444
444
  // `name` is what lets a `prepare()` called outside an injection context name itself in the error
445
445
  // instead of surfacing as a bare NG0203.
446
- return `export const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
446
+ return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\nexport const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
447
447
  };
448
448
  const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, creatorToClient, }) => {
449
449
  const createdProviders = [];
@@ -504,7 +504,7 @@ const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, cre
504
504
  }
505
505
  };
506
506
  const addAuthProviderToFile = (content, clientName, body) => {
507
- const nextContent = ensureImportFromQuery(content, ['createBearerAuthProvider', ...body.importsNeeded]);
507
+ const nextContent = ensureImportFromEthleteCore(ensureImportFromQuery(content, ['createBearerAuthProvider', ...body.importsNeeded]), ['toInjectFn', 'toProvideFn']);
508
508
  const sourceFile = createSourceFile(nextContent);
509
509
  const configName = ensureConfigSuffix(clientName);
510
510
  const configPosition = findVariableStatementEnd(sourceFile, configName, 'createQueryClient');
@@ -522,7 +522,8 @@ const addAuthProviderToFile = (content, clientName, body) => {
522
522
  ...(body.features ? [body.features] : []),
523
523
  '});',
524
524
  '',
525
- `export const [provide${capitalizedClientName}AuthProvider, inject${capitalizedClientName}AuthProvider] = ${authProviderName};`,
525
+ `export const provide${capitalizedClientName}AuthProvider = toProvideFn(${authProviderName});`,
526
+ `export const inject${capitalizedClientName}AuthProvider = toInjectFn(${authProviderName});`,
526
527
  ].join('\n');
527
528
  return nextContent.slice(0, configPosition) + authProviderBlock + nextContent.slice(configPosition);
528
529
  };
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromQuery, getVariableStatementEnd, } from './shared.js';
2
+ import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, getVariableStatementEnd, } from './shared.js';
3
3
  export const migrateQueryClients = (tree, report, scope) => {
4
4
  const queryClientFiles = new Map();
5
5
  const variableRenames = new Map();
@@ -249,7 +249,7 @@ const renameVariables = (content, renames) => {
249
249
  * report from production.
250
250
  */
251
251
  const DROPPED_CLIENT_OPTIONS = {
252
- 'request.autoRefreshQueriesOnWindowFocus': 'No direct equivalent. Re-run the affected queries yourself — `withAutoRefresh({ onSignalChanges: [windowFocusSignal] })` per query, or `injectMyClient().refreshQueriesInUse()` from a focus listener.',
252
+ 'request.autoRefreshQueriesOnWindowFocus': 'No direct equivalent. Re-run the affected queries yourself - `withAutoRefresh({ onSignalChanges: [windowFocusSignal] })` per query, or `injectMyClient().refreshQueriesInUse()` from a focus listener.',
253
253
  'request.enableSmartPolling': 'No direct equivalent. `withPolling({ interval })` polls unconditionally; pause it yourself when the tab is hidden if that matters.',
254
254
  'logging.preparedQuerySubscriptions': 'Removed. Use the v3 devtools (`provideQueryDevtools()`) or `withLogging({ logFn })` on the queries you want to trace.',
255
255
  'logging.queryStateChanges': 'Removed. Use the v3 devtools (`provideQueryDevtools()`) or `withLogging({ logFn })` on the queries you want to trace.',
@@ -374,19 +374,20 @@ const addProviderAliasesToFile = (content, configNames) => {
374
374
  return;
375
375
  }
376
376
  const { injectName, provideName } = getProviderAliasNames(variableName);
377
- if (result.includes(`export const [${provideName}, ${injectName}] = ${variableName};`)) {
377
+ if (result.includes(`export const ${injectName} = toInjectFn(${variableName});`)) {
378
378
  return;
379
379
  }
380
380
  insertions.push({
381
381
  position,
382
- text: `\n\nexport const [${provideName}, ${injectName}] = ${variableName};`,
382
+ text: `\n\nexport const ${provideName} = toProvideFn(${variableName});` +
383
+ `\nexport const ${injectName} = toInjectFn(${variableName});`,
383
384
  });
384
385
  });
385
386
  insertions.sort((left, right) => right.position - left.position);
386
387
  insertions.forEach(({ position, text }) => {
387
388
  result = result.slice(0, position) + text + result.slice(position);
388
389
  });
389
- return result;
390
+ return insertions.length > 0 ? ensureImportFromEthleteCore(result, ['toInjectFn', 'toProvideFn']) : result;
390
391
  };
391
392
  const getProviderAliasNames = (configVariableName) => {
392
393
  const baseName = configVariableName.replace(/Config$/, '');
@@ -1,7 +1,7 @@
1
1
  import * as ts from 'typescript';
2
2
  import { createSourceFile } from './shared.js';
3
3
  /**
4
- * Whether an identifier is a *name* rather than a *reference* — a declaration, a property key, the
4
+ * Whether an identifier is a *name* rather than a *reference* - a declaration, a property key, the
5
5
  * right-hand side of a member access. Renaming those is what turned `PeopleDetailDataSource.getPerson`
6
6
  * and the `postLogin` property of a config type into `legacy…` during the first migration run.
7
7
  */
@@ -9,7 +9,7 @@ const isNamePosition = (node) => {
9
9
  const parent = node.parent;
10
10
  if (!parent)
11
11
  return false;
12
- // `foo.bar` / `Foo.Bar` — the part after the dot is never the imported binding.
12
+ // `foo.bar` / `Foo.Bar` - the part after the dot is never the imported binding.
13
13
  if (ts.isPropertyAccessExpression(parent) && parent.name === node)
14
14
  return true;
15
15
  if (ts.isQualifiedName(parent) && parent.right === node)
@@ -63,7 +63,7 @@ const isNamePosition = (node) => {
63
63
  return true;
64
64
  return false;
65
65
  };
66
- /** Every name the file declares itself — a local by the same name means the import is shadowed. */
66
+ /** Every name the file declares itself - a local by the same name means the import is shadowed. */
67
67
  const collectLocalDeclarations = (sourceFile) => {
68
68
  const declared = new Set();
69
69
  const add = (name) => {
@@ -134,7 +134,7 @@ export const renameImportedReferences = (content, renames) => {
134
134
  * The migration rewrites away the constructs that needed `def`, `AnyLegacyQueryCreator` and friends;
135
135
  * leaving the imports behind turns every touched file into a lint error, and `formatFiles` runs in
136
136
  * the same generator invocation so they land that way. Identifiers inside template strings count as
137
- * usage too — a component referenced only from an inline template must not be pruned.
137
+ * usage too - a component referenced only from an inline template must not be pruned.
138
138
  */
139
139
  export const pruneUnusedNamedImports = (content, moduleSpecifiers) => {
140
140
  const sourceFile = createSourceFile(content);
@@ -7,7 +7,7 @@
7
7
  "projects": {
8
8
  "type": "array",
9
9
  "items": { "type": "string" },
10
- "description": "Nx projects to migrate. Defaults to every project in the workspace. Use this to adopt v3 one app or library at a time instead of rewriting the whole monorepo in one run — but keep a query client and the creators built from it in the same run, since they are migrated together.",
10
+ "description": "Nx projects to migrate. Defaults to every project in the workspace. Use this to adopt v3 one app or library at a time instead of rewriting the whole monorepo in one run - but keep a query client and the creators built from it in the same run, since they are migrated together.",
11
11
  "alias": "project"
12
12
  },
13
13
  "include": {
@@ -55,6 +55,8 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
55
55
  };
56
56
  export const ensureImportFromQuery = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/query' });
57
57
  export const ensureAngularCoreImports = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@angular/core' });
58
+ /** `toProvideFn` / `toInjectFn` - the provider-definition extractors every generated ref pair needs. */
59
+ export const ensureImportFromEthleteCore = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/core' });
58
60
  export const getVariableStatementEnd = (sourceFile, variableName, initializerName) => {
59
61
  let variableStatementEnd;
60
62
  const visit = (node) => {
@@ -94,4 +96,47 @@ export const getLineNumber = (node, sourceFile) => {
94
96
  return line + 1;
95
97
  };
96
98
  export const getLineNumberFromPosition = (content, position) => content.slice(0, position).split('\n').length;
99
+ const MIGRATION_GUIDE_URL = 'https://ethlete-sdk-docs.web.app/query/migrating-from-v2';
100
+ /**
101
+ * The tag every `createLegacyQueryCreator(…)` wrapper carries. The wrapper is a migration
102
+ * scaffold, so marking it is what turns "which call sites are still on v2" from a grep into
103
+ * strikethrough in the editor.
104
+ */
105
+ export const legacyQueryDeprecationTag = (creatorName) => `@deprecated Legacy (v2) query wrapper. Migrate the call sites to ${creatorName ? `\`${creatorName}\`` : 'the query creator it wraps'} and delete this wrapper - see ${MIGRATION_GUIDE_URL}.`;
106
+ /**
107
+ * Adds a tag to `node`'s JSDoc, creating the block when there is none. Returns `null` when the
108
+ * tag is already there, so a caller can tell "nothing to do" from "rewritten".
109
+ *
110
+ * Leading `//` comments stay glued to the node - an `eslint-disable-next-line` only works from
111
+ * the line directly above.
112
+ */
113
+ export const addJsDocTag = (content, sourceFile, node, tag) => {
114
+ const jsDocs = node.jsDoc ?? [];
115
+ const existing = jsDocs[jsDocs.length - 1];
116
+ const tagName = /^@\w+/.exec(tag)?.[0];
117
+ if (existing && tagName && new RegExp(`${tagName}\\b`).test(existing.getText())) {
118
+ return null;
119
+ }
120
+ if (existing) {
121
+ const start = existing.getStart(sourceFile);
122
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
123
+ const text = existing.getText();
124
+ const body = text.slice(0, text.lastIndexOf('*/')).replace(/\s+$/, '');
125
+ // A one-line `/** … */` has to become a block before a tag line can be appended to it.
126
+ const opened = body.includes('\n') ? body : `/**\n${indentation} * ${body.slice(3).trim()}`;
127
+ return (content.slice(0, start) +
128
+ `${opened}\n${indentation} *\n${indentation} * ${tag}\n${indentation} */` +
129
+ content.slice(existing.getEnd()));
130
+ }
131
+ let start = node.getStart(sourceFile, false);
132
+ for (const comment of (ts.getLeadingCommentRanges(content, node.getFullStart()) ?? []).reverse()) {
133
+ if (comment.kind !== ts.SyntaxKind.SingleLineCommentTrivia)
134
+ break;
135
+ if (!/^\n[ \t]*$/.test(content.slice(comment.end, start)))
136
+ break;
137
+ start = comment.pos;
138
+ }
139
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
140
+ return (content.slice(0, start) + `/**\n${indentation} * ${tag}\n${indentation} */\n${indentation}` + content.slice(start));
141
+ };
97
142
  //# sourceMappingURL=shared.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/query",
3
- "version": "6.0.0-next.22",
3
+ "version": "6.0.0-next.24",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {
@@ -17,6 +17,23 @@
17
17
  "typescript": "6.0.3",
18
18
  "vite": "8.1.5"
19
19
  },
20
+ "peerDependenciesMeta": {
21
+ "@analogjs/vite-plugin-angular": {
22
+ "optional": true
23
+ },
24
+ "@nx/devkit": {
25
+ "optional": true
26
+ },
27
+ "socket.io-client": {
28
+ "optional": true
29
+ },
30
+ "typescript": {
31
+ "optional": true
32
+ },
33
+ "vite": {
34
+ "optional": true
35
+ }
36
+ },
20
37
  "generators": "./generators/generators.json",
21
38
  "module": "fesm2022/ethlete-query.mjs",
22
39
  "typings": "types/ethlete-query.d.ts",