@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.
- package/CHANGELOG.md +124 -8
- package/fesm2022/ethlete-query-testing.mjs +235 -5
- package/fesm2022/ethlete-query-testing.mjs.map +1 -1
- package/fesm2022/ethlete-query.mjs +7657 -5448
- package/fesm2022/ethlete-query.mjs.map +1 -1
- package/generators/deprecate-legacy-queries/migration.js +77 -0
- package/generators/deprecate-legacy-queries/schema.json +26 -0
- package/generators/generators.json +15 -0
- package/generators/migrate-query-client-features/migration.js +142 -0
- package/generators/migrate-query-client-features/report.js +136 -0
- package/generators/migrate-query-client-features/schema.json +26 -0
- package/generators/migrate-query-opt-in-features/migration.js +182 -0
- package/generators/migrate-query-opt-in-features/report.js +143 -0
- package/generators/migrate-query-opt-in-features/schema.json +31 -0
- package/generators/migrate-to-query-v3/auth-provider-scaffold.js +3 -3
- package/generators/migrate-to-query-v3/cleanup-migration.js +2 -2
- package/generators/migrate-to-query-v3/http-client-check.js +5 -5
- package/generators/migrate-to-query-v3/legacy-prepare-migration.js +111 -18
- package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +5 -4
- package/generators/migrate-to-query-v3/query-client-migration.js +6 -5
- package/generators/migrate-to-query-v3/rename-symbols.js +4 -4
- package/generators/migrate-to-query-v3/schema.json +1 -1
- package/generators/migrate-to-query-v3/shared.js +45 -0
- package/package.json +18 -1
- package/types/ethlete-query-testing.d.ts +98 -12
- 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
|
|
73
|
-
* `extractTokens`, `cookieName` → `withPersistentAuth({ cookie })`
|
|
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 }
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
75
|
-
action: 'Add `provideHttpClient()` to every app config that uses a query client. If anything relies on upload progress, use `provideHttpClient(withXhr())`
|
|
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 (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
ts.isIdentifier(
|
|
108
|
-
|
|
109
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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*
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|