@ethlete/query 6.0.0-next.51 → 6.0.0-next.53
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 +78 -0
- package/fesm2022/ethlete-query-ngrx-toolkit.mjs +393 -0
- package/fesm2022/ethlete-query-ngrx-toolkit.mjs.map +1 -0
- package/fesm2022/ethlete-query-testing.mjs +67 -25
- package/fesm2022/ethlete-query-testing.mjs.map +1 -1
- package/fesm2022/ethlete-query.mjs +7895 -7226
- package/fesm2022/ethlete-query.mjs.map +1 -1
- package/generators/generators.json +5 -0
- package/generators/migrate-from-ngrx-toolkit/args-assignability.js +140 -0
- package/generators/migrate-from-ngrx-toolkit/consumers.js +521 -0
- package/generators/migrate-from-ngrx-toolkit/creators.js +301 -0
- package/generators/migrate-from-ngrx-toolkit/facade.js +295 -0
- package/generators/migrate-from-ngrx-toolkit/feature.js +513 -0
- package/generators/migrate-from-ngrx-toolkit/migration.js +140 -0
- package/generators/migrate-from-ngrx-toolkit/report.js +154 -0
- package/generators/migrate-from-ngrx-toolkit/schema.json +42 -0
- package/generators/migrate-from-ngrx-toolkit/store-removal.js +633 -0
- package/generators/migrate-from-ngrx-toolkit/ts-edits.js +128 -0
- package/generators/migrate-query-opt-in-features/migration.js +10 -9
- package/generators/migrate-to-query-v3/auth-provider-scaffold.js +29 -2
- package/generators/migrate-to-query-v3/cleanup-migration.js +163 -53
- package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +39 -2
- package/generators/migrate-to-query-v3/migration-scope.js +2 -0
- package/generators/migrate-to-query-v3/migration.js +5 -4
- package/generators/migrate-to-query-v3/module-graph.js +41 -29
- package/generators/migrate-to-query-v3/query-client-migration.js +209 -126
- package/generators/migrate-to-query-v3/rename-symbols.js +38 -11
- package/generators/migrate-to-query-v3/report.js +40 -7
- package/generators/migrate-to-query-v3/shared.js +46 -1
- package/generators/prep-for-query-v3/migration.js +400 -57
- package/migrations/query-field-default-types.md +43 -0
- package/migrations/search-query-field-string.md +43 -0
- package/migrations.json +16 -0
- package/package.json +5 -1
- package/types/ethlete-query-ngrx-toolkit.d.ts +143 -0
- package/types/ethlete-query-testing.d.ts +28 -11
- package/types/ethlete-query.d.ts +789 -584
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { pruneUnusedNamedImports } from '../migrate-to-query-v3/rename-symbols.js';
|
|
3
|
+
import { createSourceFile } from '../migrate-to-query-v3/shared.js';
|
|
4
|
+
/** Applies edits back to front. An edit nested inside an already applied one is dropped. */
|
|
5
|
+
export const applyEdits = (content, edits) => {
|
|
6
|
+
const sorted = [...edits].sort((left, right) => right.start - left.start || right.end - left.end);
|
|
7
|
+
let result = content;
|
|
8
|
+
let floor = Number.POSITIVE_INFINITY;
|
|
9
|
+
for (const edit of sorted) {
|
|
10
|
+
if (edit.end > floor)
|
|
11
|
+
continue;
|
|
12
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
13
|
+
floor = edit.start;
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
};
|
|
17
|
+
/** Removes `nodes[index]` from a comma separated list, together with the comma that belongs to it. */
|
|
18
|
+
export const removeListElement = (nodes, index, sourceFile) => {
|
|
19
|
+
const node = nodes[index];
|
|
20
|
+
if (index < nodes.length - 1) {
|
|
21
|
+
return { start: node.getStart(sourceFile), end: nodes[index + 1].getStart(sourceFile), text: '' };
|
|
22
|
+
}
|
|
23
|
+
if (index > 0) {
|
|
24
|
+
return { start: nodes[index - 1].getEnd(), end: node.getEnd(), text: '' };
|
|
25
|
+
}
|
|
26
|
+
return { start: node.getStart(sourceFile), end: node.getEnd(), text: '' };
|
|
27
|
+
};
|
|
28
|
+
/** Removes a whole statement or class member, including its leading comments and the line break after it. */
|
|
29
|
+
export const removeStatement = (node, content) => {
|
|
30
|
+
const comments = ts.getLeadingCommentRanges(content, node.getFullStart()) ?? [];
|
|
31
|
+
let start = comments[0]?.pos ?? node.getStart();
|
|
32
|
+
while (start > 0 && (content[start - 1] === ' ' || content[start - 1] === '\t'))
|
|
33
|
+
start -= 1;
|
|
34
|
+
let end = node.getEnd();
|
|
35
|
+
while (content[end] === ' ' || content[end] === '\t')
|
|
36
|
+
end += 1;
|
|
37
|
+
if (content[end] === '\r')
|
|
38
|
+
end += 1;
|
|
39
|
+
if (content[end] === '\n')
|
|
40
|
+
end += 1;
|
|
41
|
+
return { start, end, text: '' };
|
|
42
|
+
};
|
|
43
|
+
export const lineOf = (sourceFile, position) => sourceFile.getLineAndCharacterOfPosition(position).line + 1;
|
|
44
|
+
export const collectUsedIdentifiers = (sourceFile) => {
|
|
45
|
+
const used = new Set();
|
|
46
|
+
const visit = (node) => {
|
|
47
|
+
if (ts.isImportDeclaration(node))
|
|
48
|
+
return;
|
|
49
|
+
if (ts.isIdentifier(node))
|
|
50
|
+
used.add(node.text);
|
|
51
|
+
ts.forEachChild(node, visit);
|
|
52
|
+
};
|
|
53
|
+
visit(sourceFile);
|
|
54
|
+
return used;
|
|
55
|
+
};
|
|
56
|
+
/** Collapses runs of blank lines left behind by removed statements, outside template literals. */
|
|
57
|
+
export const collapseBlankLines = (content) => {
|
|
58
|
+
const sourceFile = createSourceFile(content);
|
|
59
|
+
const templates = [];
|
|
60
|
+
const visit = (node) => {
|
|
61
|
+
if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
62
|
+
templates.push([node.getStart(sourceFile), node.getEnd()]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
ts.forEachChild(node, visit);
|
|
66
|
+
};
|
|
67
|
+
visit(sourceFile);
|
|
68
|
+
return content.replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, (match, offset) => templates.some(([start, end]) => offset >= start && offset < end) ? match : '\n\n');
|
|
69
|
+
};
|
|
70
|
+
/** Drops unused namespace, default and named imports. Side-effect imports stay. */
|
|
71
|
+
export const pruneUnusedImports = (content) => {
|
|
72
|
+
const sourceFile = createSourceFile(content);
|
|
73
|
+
const used = collectUsedIdentifiers(sourceFile);
|
|
74
|
+
const edits = [];
|
|
75
|
+
for (const statement of sourceFile.statements) {
|
|
76
|
+
if (!ts.isImportDeclaration(statement) || !statement.importClause)
|
|
77
|
+
continue;
|
|
78
|
+
const { name, namedBindings } = statement.importClause;
|
|
79
|
+
if (namedBindings && ts.isNamespaceImport(namedBindings) && !used.has(namedBindings.name.text)) {
|
|
80
|
+
if (!name || !used.has(name.text))
|
|
81
|
+
edits.push(removeStatement(statement, content));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!namedBindings && name && !used.has(name.text)) {
|
|
85
|
+
edits.push(removeStatement(statement, content));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return pruneUnusedNamedImports(applyEdits(content, edits));
|
|
89
|
+
};
|
|
90
|
+
/** A relative module specifier from one workspace file to another, without the `.ts` extension. */
|
|
91
|
+
export const relativeSpecifier = (fromFile, toFile) => {
|
|
92
|
+
const fromSegments = fromFile.split('/').slice(0, -1);
|
|
93
|
+
const toSegments = toFile.replace(/\.ts$/, '').split('/');
|
|
94
|
+
let shared = 0;
|
|
95
|
+
while (shared < fromSegments.length &&
|
|
96
|
+
shared < toSegments.length - 1 &&
|
|
97
|
+
fromSegments[shared] === toSegments[shared]) {
|
|
98
|
+
shared += 1;
|
|
99
|
+
}
|
|
100
|
+
const ups = fromSegments.length - shared;
|
|
101
|
+
const rest = toSegments.slice(shared).join('/');
|
|
102
|
+
return ups === 0 ? `./${rest}` : `${'../'.repeat(ups)}${rest}`;
|
|
103
|
+
};
|
|
104
|
+
export const hasExportModifier = (node) => ts.canHaveModifiers(node) &&
|
|
105
|
+
!!ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
106
|
+
/** The names a top-level statement declares. */
|
|
107
|
+
export const declaredNames = (statement) => {
|
|
108
|
+
if (ts.isVariableStatement(statement)) {
|
|
109
|
+
return statement.declarationList.declarations.flatMap((declaration) => {
|
|
110
|
+
if (ts.isIdentifier(declaration.name))
|
|
111
|
+
return [declaration.name.text];
|
|
112
|
+
if (ts.isObjectBindingPattern(declaration.name)) {
|
|
113
|
+
return declaration.name.elements.flatMap((element) => ts.isIdentifier(element.name) ? [element.name.text] : []);
|
|
114
|
+
}
|
|
115
|
+
return [];
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if ((ts.isFunctionDeclaration(statement) ||
|
|
119
|
+
ts.isClassDeclaration(statement) ||
|
|
120
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
121
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
122
|
+
ts.isEnumDeclaration(statement)) &&
|
|
123
|
+
statement.name) {
|
|
124
|
+
return [statement.name.text];
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
};
|
|
128
|
+
//# sourceMappingURL=ts-edits.js.map
|
|
@@ -4,8 +4,16 @@ import { createMigrationScope } from '../migrate-to-query-v3/migration-scope.js'
|
|
|
4
4
|
import { createSourceFile, ensureImportFromQuery, getLineNumber } from '../migrate-to-query-v3/shared.js';
|
|
5
5
|
import { QueryOptInFeaturesMigrationReport } from './report.js';
|
|
6
6
|
const SOURCE = 'query-opt-in-features';
|
|
7
|
-
const CLIENT_FEATURE = 'withEthleteApiErrors';
|
|
7
|
+
export const CLIENT_FEATURE = 'withEthleteApiErrors';
|
|
8
8
|
const AUTH_FEATURE = 'withBearerAuthMultiTabSync';
|
|
9
|
+
export const reportClientErrorPipelineAdded = (report, location, source, summary = `\`${CLIENT_FEATURE}()\` was added so error parsing and retrying keep working exactly as before.`) => report.addFollowUp({
|
|
10
|
+
title: 'Client kept the full error pipeline',
|
|
11
|
+
summary,
|
|
12
|
+
action: 'Narrow it: `withHtmlErrorParsing()` only for an API behind a proxy that answers HTML, `withSymfonyErrors()` only for violation-list responses, `withDefaultRetry()` only if failed requests should retry themselves.',
|
|
13
|
+
locations: [location],
|
|
14
|
+
source,
|
|
15
|
+
dedupeKey: 'client-error-pipeline',
|
|
16
|
+
});
|
|
9
17
|
const isMigratableFile = (filePath) => /\.(ts|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts');
|
|
10
18
|
const propertyNamed = (config, sourceFile, name) => config.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText(sourceFile) === name);
|
|
11
19
|
const hasFeature = (config, sourceFile, feature) => {
|
|
@@ -87,14 +95,7 @@ const collectEdits = (filePath, sourceFile, report) => {
|
|
|
87
95
|
}
|
|
88
96
|
else if (isClient) {
|
|
89
97
|
const feature = `${CLIENT_FEATURE}()`;
|
|
90
|
-
report
|
|
91
|
-
title: 'Client kept the full error pipeline',
|
|
92
|
-
summary: `\`${CLIENT_FEATURE}()\` was added so error parsing and retrying keep working exactly as before.`,
|
|
93
|
-
action: 'Narrow it: `withHtmlErrorParsing()` only for an API behind a proxy that answers HTML, `withSymfonyErrors()` only for violation-list responses, `withDefaultRetry()` only if failed requests should retry themselves.',
|
|
94
|
-
locations: [{ filePath, line }],
|
|
95
|
-
source: SOURCE,
|
|
96
|
-
dedupeKey: 'client-error-pipeline',
|
|
97
|
-
});
|
|
98
|
+
reportClientErrorPipelineAdded(report, { filePath, line }, SOURCE);
|
|
98
99
|
edits.push({
|
|
99
100
|
start: config.getStart(sourceFile),
|
|
100
101
|
end: config.getEnd(),
|
|
@@ -8,6 +8,10 @@ const REFRESH_CONFIG_KEYS = new Set([
|
|
|
8
8
|
'cookieDomain',
|
|
9
9
|
'cookieExpiresInDays',
|
|
10
10
|
'expiresInPropertyName',
|
|
11
|
+
'cookiePath',
|
|
12
|
+
'cookieSameSite',
|
|
13
|
+
'cookieEnabled',
|
|
14
|
+
'refreshBuffer',
|
|
11
15
|
]);
|
|
12
16
|
export const collectV2BearerAuthConfigs = (tree, scope) => {
|
|
13
17
|
const configs = [];
|
|
@@ -77,12 +81,14 @@ const REFRESH_QUERY_KEY = 'tokenRefresh';
|
|
|
77
81
|
* than presented as finished.
|
|
78
82
|
*/
|
|
79
83
|
export const renderAuthProviderBody = (config) => {
|
|
84
|
+
const warnings = [];
|
|
80
85
|
if (!config?.refreshCreatorName) {
|
|
81
86
|
return {
|
|
82
87
|
queries: ' queries: [],',
|
|
83
88
|
features: '',
|
|
84
89
|
importsNeeded: [],
|
|
85
90
|
isScaffolded: false,
|
|
91
|
+
warnings,
|
|
86
92
|
};
|
|
87
93
|
}
|
|
88
94
|
const refreshEntries = [` queryCreator: ${config.refreshCreatorName},`];
|
|
@@ -95,6 +101,16 @@ export const renderAuthProviderBody = (config) => {
|
|
|
95
101
|
if (config.expiresInPropertyName) {
|
|
96
102
|
refreshEntries.push(` expiresInPropertyName: ${config.expiresInPropertyName},`);
|
|
97
103
|
}
|
|
104
|
+
if (config.refreshBuffer) {
|
|
105
|
+
refreshEntries.push(` refreshStrategy: ${renderRefreshStrategy(config.refreshBuffer)},`);
|
|
106
|
+
}
|
|
107
|
+
if (config.cookieName && config.cookieEnabled && config.cookieEnabled !== 'true') {
|
|
108
|
+
warnings.push({
|
|
109
|
+
title: 'Replace the v2 cookieEnabled switch',
|
|
110
|
+
summary: `The v2 provider set \`cookieEnabled: ${config.cookieEnabled}\`. \`withPersistentAuth\` has no switch for it: it always keeps the refresh token in the cookie, and \`setRememberMe(false)\` only turns that cookie into a session cookie. v2's \`enableCookie()\` / \`disableCookie()\` have no v3 counterpart either.`,
|
|
111
|
+
action: 'If the cookie was only written after an opt-in (a "remember me" box), drive `setRememberMe()` from it. If it was never written, remove `withPersistentAuth` from the scaffold.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
98
114
|
const queries = [
|
|
99
115
|
' queries: [',
|
|
100
116
|
` // Derived from the v2 BearerAuthProvider in ${config.filePath}:${config.line}. Verify before shipping.`,
|
|
@@ -106,13 +122,17 @@ export const renderAuthProviderBody = (config) => {
|
|
|
106
122
|
].join('\n');
|
|
107
123
|
const importsNeeded = ['withRefreshQuery'];
|
|
108
124
|
if (!config.cookieName) {
|
|
109
|
-
return { queries, features: '', importsNeeded, isScaffolded: true };
|
|
125
|
+
return { queries, features: '', importsNeeded, isScaffolded: true, warnings };
|
|
110
126
|
}
|
|
111
127
|
const cookieEntries = [` name: ${config.cookieName},`];
|
|
112
128
|
if (config.cookieDomain)
|
|
113
129
|
cookieEntries.push(` domain: ${config.cookieDomain},`);
|
|
114
130
|
if (config.cookieExpiresInDays)
|
|
115
131
|
cookieEntries.push(` expiresInDays: ${config.cookieExpiresInDays},`);
|
|
132
|
+
if (config.cookiePath)
|
|
133
|
+
cookieEntries.push(` path: ${config.cookiePath},`);
|
|
134
|
+
if (config.cookieSameSite)
|
|
135
|
+
cookieEntries.push(` sameSite: ${config.cookieSameSite},`);
|
|
116
136
|
const features = [
|
|
117
137
|
' features: [',
|
|
118
138
|
' withPersistentAuth({',
|
|
@@ -128,6 +148,13 @@ export const renderAuthProviderBody = (config) => {
|
|
|
128
148
|
' ],',
|
|
129
149
|
].join('\n');
|
|
130
150
|
importsNeeded.push('withPersistentAuth');
|
|
131
|
-
return { queries, features, importsNeeded, isScaffolded: true };
|
|
151
|
+
return { queries, features, importsNeeded, isScaffolded: true, warnings };
|
|
152
|
+
};
|
|
153
|
+
// A v3 `refreshStrategy` number from 0 to 1 is a fraction of the token lifetime, not milliseconds.
|
|
154
|
+
const renderRefreshStrategy = (refreshBuffer) => {
|
|
155
|
+
const value = Number(refreshBuffer.replace(/_/g, ''));
|
|
156
|
+
return Number.isFinite(value) && value > 1
|
|
157
|
+
? refreshBuffer
|
|
158
|
+
: `{ minBufferMs: ${refreshBuffer}, maxBufferMs: ${refreshBuffer} }`;
|
|
132
159
|
};
|
|
133
160
|
//# sourceMappingURL=auth-provider-scaffold.js.map
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
-
import {
|
|
2
|
+
import { createModuleGraph } from './module-graph.js';
|
|
3
|
+
import { isShadowedInNestedScope } from './rename-symbols.js';
|
|
4
|
+
import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, ensureImportFromQuery, ensureNamedImports, findRemovedExperimentalQueryHelpers, getLineNumber, printImportDeclaration, } from './shared.js';
|
|
5
|
+
export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
|
|
6
|
+
scope.visit(tree, (filePath) => {
|
|
7
|
+
if (!filePath.endsWith('.ts')) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const content = tree.read(filePath, 'utf-8');
|
|
11
|
+
if (!content?.includes('@ethlete/query')) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
findRemovedExperimentalQueryHelpers(createSourceFile(content, filePath)).forEach(({ name, line }) => {
|
|
15
|
+
report.addWarning({
|
|
16
|
+
title: `Replace the removed helper ${name}`,
|
|
17
|
+
summary: `\`${name}\` came from the pre-v3 \`ExperimentalQuery\` namespace and is no longer exported by \`@ethlete/query\`, so this import does not compile. The migration does not rewrite it.`,
|
|
18
|
+
action: REMOVED_EXPERIMENTAL_QUERY_HELPERS[name],
|
|
19
|
+
locations: [{ filePath, line }],
|
|
20
|
+
source: 'cleanup-migration',
|
|
21
|
+
dedupeKey: `removed-experimental-helper:${filePath}:${name}`,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
};
|
|
3
26
|
/**
|
|
4
27
|
* Points existing devtools usage at the v3 components instead of deleting it.
|
|
5
28
|
*
|
|
@@ -69,8 +92,9 @@ export const replaceAnyQueryWithLegacy = (tree, scope) => {
|
|
|
69
92
|
console.log(`\n✅ Replaced legacy AnyV2Query aliases in ${updatedFiles.length} files`);
|
|
70
93
|
}
|
|
71
94
|
};
|
|
72
|
-
export const migrateEmptyPrepareCalls = (tree, scope) => {
|
|
95
|
+
export const migrateEmptyPrepareCalls = (tree, scope, report) => {
|
|
73
96
|
const updatedFiles = [];
|
|
97
|
+
const graph = createModuleGraph(tree);
|
|
74
98
|
scope.visit(tree, (filePath) => {
|
|
75
99
|
if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts')) {
|
|
76
100
|
return;
|
|
@@ -79,7 +103,17 @@ export const migrateEmptyPrepareCalls = (tree, scope) => {
|
|
|
79
103
|
if (!content || !content.includes('.prepare()')) {
|
|
80
104
|
return;
|
|
81
105
|
}
|
|
82
|
-
const nextContent = transformEmptyPrepareCalls(content);
|
|
106
|
+
const { content: nextContent, unresolved } = transformEmptyPrepareCalls(tree, graph, filePath, content);
|
|
107
|
+
unresolved.forEach(({ line, receiver }) => {
|
|
108
|
+
report.addManualReview({
|
|
109
|
+
title: `Check the empty prepare() call on ${receiver}`,
|
|
110
|
+
summary: `The migration could not tell whether \`${receiver}\` is a legacy query creator, so it left \`${receiver}.prepare()\` as is. A legacy creator needs \`.prepare({})\` in v3.`,
|
|
111
|
+
action: `Change the call to \`${receiver}.prepare({})\` if \`${receiver}\` is a legacy query creator, otherwise leave it.`,
|
|
112
|
+
locations: [{ filePath, line }],
|
|
113
|
+
source: 'cleanup-migration',
|
|
114
|
+
dedupeKey: `unresolved-empty-prepare:${filePath}:${line}`,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
83
117
|
if (nextContent !== content) {
|
|
84
118
|
tree.write(filePath, nextContent);
|
|
85
119
|
updatedFiles.push(filePath);
|
|
@@ -90,31 +124,36 @@ export const migrateEmptyPrepareCalls = (tree, scope) => {
|
|
|
90
124
|
}
|
|
91
125
|
};
|
|
92
126
|
const migrateDevtoolsInFile = (content) => {
|
|
93
|
-
const
|
|
94
|
-
|
|
127
|
+
const componentNames = localNamesImportedFromQuery(content, 'QueryDevtoolsComponent');
|
|
128
|
+
const providerNames = new Set([
|
|
129
|
+
'provideQueryClientForDevtools',
|
|
130
|
+
...localNamesImportedFromQuery(content, 'provideQueryClientForDevtools'),
|
|
131
|
+
]);
|
|
132
|
+
let result = replaceDevtoolsProviderCalls(content, providerNames);
|
|
95
133
|
const hasProvider = result.includes('provideQueryDevtools()');
|
|
96
134
|
result = dropLegacyDevtoolsImports(result);
|
|
97
135
|
if (hasProvider) {
|
|
98
136
|
result = ensureImportFromQuery(result, ['provideQueryDevtools']);
|
|
99
137
|
}
|
|
100
|
-
if (
|
|
138
|
+
if (componentNames.length > 0) {
|
|
101
139
|
result = ensureNamedImports({
|
|
102
140
|
content: result,
|
|
103
|
-
importsNeeded:
|
|
141
|
+
importsNeeded: componentNames.map((name) => name === 'QueryDevtoolsComponent' ? name : `QueryDevtoolsComponent as ${name}`),
|
|
104
142
|
moduleSpecifier: '@ethlete/query-devtools',
|
|
105
143
|
});
|
|
106
144
|
}
|
|
107
|
-
return { content: result, importsComponent };
|
|
108
|
-
};
|
|
109
|
-
const importsFromQuery = (content, name) => {
|
|
110
|
-
const sourceFile = createSourceFile(content);
|
|
111
|
-
return sourceFile.statements.some((node) => ts.isImportDeclaration(node) &&
|
|
112
|
-
ts.isStringLiteral(node.moduleSpecifier) &&
|
|
113
|
-
node.moduleSpecifier.text === '@ethlete/query' &&
|
|
114
|
-
!!node.importClause?.namedBindings &&
|
|
115
|
-
ts.isNamedImports(node.importClause.namedBindings) &&
|
|
116
|
-
node.importClause.namedBindings.elements.some((element) => element.name.text === name));
|
|
145
|
+
return { content: result, importsComponent: componentNames.length > 0 };
|
|
117
146
|
};
|
|
147
|
+
const queryImports = (sourceFile) => sourceFile.statements.filter((node) => ts.isImportDeclaration(node) &&
|
|
148
|
+
ts.isStringLiteral(node.moduleSpecifier) &&
|
|
149
|
+
node.moduleSpecifier.text === '@ethlete/query');
|
|
150
|
+
const namedImportElements = (node) => node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)
|
|
151
|
+
? node.importClause.namedBindings.elements
|
|
152
|
+
: [];
|
|
153
|
+
const importedName = (element) => (element.propertyName ?? element.name).text;
|
|
154
|
+
const localNamesImportedFromQuery = (content, name) => queryImports(createSourceFile(content)).flatMap((node) => namedImportElements(node)
|
|
155
|
+
.filter((element) => importedName(element) === name)
|
|
156
|
+
.map((element) => element.name.text));
|
|
118
157
|
/**
|
|
119
158
|
* Turns every `provideQueryClientForDevtools({ client, displayName })` into a single
|
|
120
159
|
* `provideQueryDevtools()`.
|
|
@@ -123,13 +162,11 @@ const importsFromQuery = (content, name) => {
|
|
|
123
162
|
* first call site keeps its position - it is already where the app wanted its devtools - and the
|
|
124
163
|
* rest are removed along with the comma that separated them.
|
|
125
164
|
*/
|
|
126
|
-
const replaceDevtoolsProviderCalls = (content) => {
|
|
165
|
+
const replaceDevtoolsProviderCalls = (content, providerNames) => {
|
|
127
166
|
const sourceFile = createSourceFile(content);
|
|
128
167
|
const calls = [];
|
|
129
168
|
const visit = (node) => {
|
|
130
|
-
if (ts.isCallExpression(node) &&
|
|
131
|
-
ts.isIdentifier(node.expression) &&
|
|
132
|
-
node.expression.text === 'provideQueryClientForDevtools') {
|
|
169
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && providerNames.has(node.expression.text)) {
|
|
133
170
|
calls.push(node);
|
|
134
171
|
}
|
|
135
172
|
ts.forEachChild(node, visit);
|
|
@@ -169,30 +206,27 @@ const withSurroundingComma = (content, start, end) => {
|
|
|
169
206
|
}
|
|
170
207
|
return { start, end };
|
|
171
208
|
};
|
|
172
|
-
|
|
209
|
+
const LEGACY_DEVTOOLS_IMPORTS = new Set(['QueryDevtoolsComponent', 'provideQueryClientForDevtools']);
|
|
210
|
+
/** Drops the v2 devtools names from the `@ethlete/query` imports; both moved or were renamed. */
|
|
173
211
|
const dropLegacyDevtoolsImports = (content) => {
|
|
174
212
|
const sourceFile = createSourceFile(content);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
213
|
+
const replacements = [];
|
|
214
|
+
for (const node of queryImports(sourceFile)) {
|
|
215
|
+
const elements = namedImportElements(node);
|
|
216
|
+
const nextElements = elements.filter((element) => !LEGACY_DEVTOOLS_IMPORTS.has(importedName(element)));
|
|
217
|
+
if (nextElements.length === elements.length) {
|
|
218
|
+
continue;
|
|
181
219
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
220
|
+
const nextImport = printImportDeclaration(node, sourceFile, nextElements.map((element) => element.getText(sourceFile)));
|
|
221
|
+
const end = !nextImport && content[node.getEnd()] === '\n' ? node.getEnd() + 1 : node.getEnd();
|
|
222
|
+
replacements.push({ start: node.getStart(sourceFile), end, replacement: nextImport });
|
|
185
223
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return content.slice(0, importNode.getStart(sourceFile)) + content.slice(nextLineEnd);
|
|
193
|
-
}
|
|
194
|
-
const nextImport = `import { ${nextElements.map((element) => element.getText(sourceFile)).join(', ')} } from '@ethlete/query';`;
|
|
195
|
-
return content.slice(0, importNode.getStart(sourceFile)) + nextImport + content.slice(importNode.getEnd());
|
|
224
|
+
let result = content;
|
|
225
|
+
replacements.sort((left, right) => right.start - left.start);
|
|
226
|
+
replacements.forEach(({ start, end, replacement }) => {
|
|
227
|
+
result = result.slice(0, start) + replacement + result.slice(end);
|
|
228
|
+
});
|
|
229
|
+
return result;
|
|
196
230
|
};
|
|
197
231
|
const replaceAnyQueryInFile = (content) => {
|
|
198
232
|
const sourceFile = createSourceFile(content);
|
|
@@ -224,20 +258,96 @@ const replaceAnyQueryInFile = (content) => {
|
|
|
224
258
|
});
|
|
225
259
|
return result;
|
|
226
260
|
};
|
|
227
|
-
const
|
|
228
|
-
const
|
|
261
|
+
const findTopLevelVariable = (sourceFile, name) => {
|
|
262
|
+
for (const statement of sourceFile.statements) {
|
|
263
|
+
if (!ts.isVariableStatement(statement))
|
|
264
|
+
continue;
|
|
265
|
+
const declaration = statement.declarationList.declarations.find((candidate) => ts.isIdentifier(candidate.name) && candidate.name.text === name);
|
|
266
|
+
if (declaration)
|
|
267
|
+
return declaration;
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
};
|
|
271
|
+
const findNamedImport = (sourceFile, localName) => {
|
|
272
|
+
for (const statement of sourceFile.statements) {
|
|
273
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
274
|
+
continue;
|
|
275
|
+
const element = namedImportElements(statement).find((candidate) => candidate.name.text === localName);
|
|
276
|
+
if (element)
|
|
277
|
+
return { specifier: statement.moduleSpecifier.text, importedName: importedName(element) };
|
|
278
|
+
}
|
|
279
|
+
return undefined;
|
|
280
|
+
};
|
|
281
|
+
const QUERY_CLIENT_CONSTRUCTORS = new Set(['V2QueryClient', 'QueryClient']);
|
|
282
|
+
/**
|
|
283
|
+
* Classifies what a top-level binding of `sourceFile` holds: a query creator (a call to an
|
|
284
|
+
* `@ethlete/query` export such as `createLegacyQueryCreator`, or a method call on a v2 client), something
|
|
285
|
+
* provably unrelated, or `unknown` when the binding cannot be followed.
|
|
286
|
+
*/
|
|
287
|
+
const classifyBinding = (tree, graph, filePath, sourceFile, name, isClient, depth) => {
|
|
288
|
+
if (depth > 4)
|
|
289
|
+
return 'unknown';
|
|
290
|
+
const declaration = findTopLevelVariable(sourceFile, name);
|
|
291
|
+
if (declaration) {
|
|
292
|
+
const initializer = declaration.initializer;
|
|
293
|
+
if (isClient) {
|
|
294
|
+
return initializer &&
|
|
295
|
+
ts.isNewExpression(initializer) &&
|
|
296
|
+
ts.isIdentifier(initializer.expression) &&
|
|
297
|
+
QUERY_CLIENT_CONSTRUCTORS.has(initializer.expression.text) &&
|
|
298
|
+
findNamedImport(sourceFile, initializer.expression.text)?.specifier === '@ethlete/query'
|
|
299
|
+
? 'query'
|
|
300
|
+
: 'foreign';
|
|
301
|
+
}
|
|
302
|
+
if (!initializer || !ts.isCallExpression(initializer))
|
|
303
|
+
return 'foreign';
|
|
304
|
+
const callee = initializer.expression;
|
|
305
|
+
if (ts.isIdentifier(callee)) {
|
|
306
|
+
return findNamedImport(sourceFile, callee.text)?.specifier === '@ethlete/query' ? 'query' : 'foreign';
|
|
307
|
+
}
|
|
308
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
|
|
309
|
+
return classifyBinding(tree, graph, filePath, sourceFile, callee.expression.text, true, depth + 1);
|
|
310
|
+
}
|
|
311
|
+
return 'foreign';
|
|
312
|
+
}
|
|
313
|
+
const binding = findNamedImport(sourceFile, name);
|
|
314
|
+
if (!binding || binding.specifier === '@ethlete/query')
|
|
315
|
+
return 'unknown';
|
|
316
|
+
const declaringFile = graph.findDeclaringFile(filePath, binding.specifier, binding.importedName);
|
|
317
|
+
if (!declaringFile)
|
|
318
|
+
return graph.isWorkspaceSpecifier(binding.specifier) ? 'unknown' : 'foreign';
|
|
319
|
+
const declaringContent = tree.read(declaringFile, 'utf-8');
|
|
320
|
+
if (!declaringContent)
|
|
321
|
+
return 'unknown';
|
|
322
|
+
return classifyBinding(tree, graph, declaringFile, createSourceFile(declaringContent, declaringFile), binding.importedName, isClient, depth + 1);
|
|
323
|
+
};
|
|
324
|
+
const classifyPrepareReceiver = (tree, graph, filePath, sourceFile, receiver) => {
|
|
325
|
+
if (!ts.isIdentifier(receiver) || isShadowedInNestedScope(receiver))
|
|
326
|
+
return 'unknown';
|
|
327
|
+
return classifyBinding(tree, graph, filePath, sourceFile, receiver.text, false, 0);
|
|
328
|
+
};
|
|
329
|
+
const transformEmptyPrepareCalls = (tree, graph, filePath, content) => {
|
|
330
|
+
const sourceFile = createSourceFile(content, filePath);
|
|
229
331
|
const replacements = [];
|
|
332
|
+
const unresolved = [];
|
|
230
333
|
const visit = (node) => {
|
|
231
|
-
if (ts.isCallExpression(node) &&
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
334
|
+
if (ts.isCallExpression(node) &&
|
|
335
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
336
|
+
ts.isIdentifier(node.expression.name) &&
|
|
337
|
+
node.expression.name.text === 'prepare' &&
|
|
338
|
+
node.arguments.length === 0) {
|
|
339
|
+
const kind = classifyPrepareReceiver(tree, graph, filePath, sourceFile, node.expression.expression);
|
|
340
|
+
if (kind === 'query') {
|
|
237
341
|
replacements.push({
|
|
238
|
-
start: node.
|
|
239
|
-
end: node.
|
|
240
|
-
replacement:
|
|
342
|
+
start: node.arguments.pos,
|
|
343
|
+
end: node.arguments.end,
|
|
344
|
+
replacement: '{}',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
else if (kind === 'unknown') {
|
|
348
|
+
unresolved.push({
|
|
349
|
+
line: getLineNumber(node, sourceFile),
|
|
350
|
+
receiver: node.expression.expression.getText(sourceFile),
|
|
241
351
|
});
|
|
242
352
|
}
|
|
243
353
|
}
|
|
@@ -249,6 +359,6 @@ const transformEmptyPrepareCalls = (content) => {
|
|
|
249
359
|
replacements.forEach(({ start, end, replacement }) => {
|
|
250
360
|
result = result.slice(0, start) + replacement + result.slice(end);
|
|
251
361
|
});
|
|
252
|
-
return result;
|
|
362
|
+
return { content: result, unresolved };
|
|
253
363
|
};
|
|
254
364
|
//# sourceMappingURL=cleanup-migration.js.map
|
|
@@ -10,6 +10,7 @@ const LEGACY_HTTP_METHOD = {
|
|
|
10
10
|
PATCH: 'patch',
|
|
11
11
|
DELETE: 'delete',
|
|
12
12
|
};
|
|
13
|
+
const LEGACY_GQL_METHODS = ['gqlQuery', 'gqlMutate'];
|
|
13
14
|
const HTTP_OPTION_NAMES = ['reportProgress', 'responseType', 'transferCache', 'withCredentials'];
|
|
14
15
|
const isLegacyHttpMethod = (value) => {
|
|
15
16
|
return Object.values(LEGACY_HTTP_METHOD).includes(value);
|
|
@@ -219,6 +220,16 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
|
|
|
219
220
|
ts.forEachChild(node, visit);
|
|
220
221
|
return;
|
|
221
222
|
}
|
|
223
|
+
if (oldClientNames.has(objectExpression.text) && LEGACY_GQL_METHODS.includes(methodExpression.text)) {
|
|
224
|
+
report.addManualReview({
|
|
225
|
+
title: `Rewrite the GraphQL creator ${node.name.text} by hand`,
|
|
226
|
+
summary: `${node.name.text} is built with \`${objectExpression.text}.${methodExpression.text}()\`. The migration only rewrites REST creators, so this one still calls a method the migrated client no longer has.`,
|
|
227
|
+
action: `Rebuild it on the v3 client with \`${methodExpression.text === 'gqlQuery' ? 'createGqlQueryViaPost' : 'createGqlMutationViaPost'}\` (or the \`ViaGet\` variant) and a \`gql\` document, then wrap it in \`createLegacyQueryCreator\` if its call sites stay on v2. See https://ethlete-sdk-docs.web.app/query/gql.`,
|
|
228
|
+
locations: [{ filePath, line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 }],
|
|
229
|
+
source: 'legacy-query-creator-migration',
|
|
230
|
+
dedupeKey: `gql-creator:${filePath}:${node.name.text}`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
222
233
|
if (!oldClientNames.has(objectExpression.text) || !isLegacyHttpMethod(methodExpression.text)) {
|
|
223
234
|
ts.forEachChild(node, visit);
|
|
224
235
|
return;
|
|
@@ -270,7 +281,10 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
|
|
|
270
281
|
if (HTTP_OPTION_NAMES.includes(property.name.text)) {
|
|
271
282
|
info.httpOptions.set(property.name.text, property.name.text);
|
|
272
283
|
}
|
|
273
|
-
if (property.name.text
|
|
284
|
+
if (property.name.text === 'entity') {
|
|
285
|
+
info.entity = property.name.text;
|
|
286
|
+
}
|
|
287
|
+
if (!['route', 'entity'].includes(property.name.text) && !HTTP_OPTION_NAMES.includes(property.name.text)) {
|
|
274
288
|
warnUnreadableConfigValue(property, 'as a shorthand property');
|
|
275
289
|
}
|
|
276
290
|
return;
|
|
@@ -281,6 +295,9 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
|
|
|
281
295
|
if (property.name.text === 'route') {
|
|
282
296
|
info.route = property.initializer.getText(sourceFile);
|
|
283
297
|
}
|
|
298
|
+
if (property.name.text === 'entity') {
|
|
299
|
+
info.entity = property.initializer.getText(sourceFile);
|
|
300
|
+
}
|
|
284
301
|
if (property.name.text === 'secure') {
|
|
285
302
|
if (property.initializer.kind === ts.SyntaxKind.TrueKeyword) {
|
|
286
303
|
info.secure = true;
|
|
@@ -329,6 +346,18 @@ const analyzeLegacyQueryCreators = (content, queryClientFiles, filePath, report)
|
|
|
329
346
|
}
|
|
330
347
|
if (info.route) {
|
|
331
348
|
creators.push(info);
|
|
349
|
+
if (info.entity) {
|
|
350
|
+
report.addFollowUp({
|
|
351
|
+
title: `Check the entity config carried onto ${toLegacyName(creatorName)}`,
|
|
352
|
+
summary: `${creatorName} had an \`entity\` config. It was moved onto the \`${toLegacyName(creatorName)}\` interop wrapper, which still writes to the EntityStore; the v3 creator \`${creatorName}\` does not, and v3 has no EntityStore.`,
|
|
353
|
+
action: 'Keep the call sites that rely on the store on the legacy wrapper. `set` now only runs on a real response, never on `prepare()` or a failure.',
|
|
354
|
+
locations: [
|
|
355
|
+
{ filePath, line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 },
|
|
356
|
+
],
|
|
357
|
+
source: 'legacy-query-creator-migration',
|
|
358
|
+
dedupeKey: `entity-config:${filePath}:${creatorName}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
332
361
|
}
|
|
333
362
|
}
|
|
334
363
|
ts.forEachChild(node, visit);
|
|
@@ -509,7 +538,7 @@ const getTypeParameter = (creator) => {
|
|
|
509
538
|
const generateLegacyWrapper = (creator, exportPrefix) => {
|
|
510
539
|
// `name` is what lets a `prepare()` called outside an injection context name itself in the error
|
|
511
540
|
// instead of surfacing as a bare NG0203.
|
|
512
|
-
return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\n${exportPrefix}const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
|
|
541
|
+
return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\n${exportPrefix}const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name}${creator.entity ? `, entity: ${creator.entity}` : ''} });`;
|
|
513
542
|
};
|
|
514
543
|
const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, creatorToClient, }) => {
|
|
515
544
|
const createdProviders = [];
|
|
@@ -532,6 +561,14 @@ const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, cre
|
|
|
532
561
|
}
|
|
533
562
|
tree.write(filePath, nextContent);
|
|
534
563
|
createdProviders.push(filePath);
|
|
564
|
+
body.warnings.forEach((warning) => {
|
|
565
|
+
report.addWarning({
|
|
566
|
+
...warning,
|
|
567
|
+
locations: [{ filePath }, ...(v2Config ? [{ filePath: v2Config.filePath, line: v2Config.line }] : [])],
|
|
568
|
+
source: 'legacy-query-creator-migration',
|
|
569
|
+
dedupeKey: `auth-provider-warning:${filePath}:${authProviderName}:${warning.title}`,
|
|
570
|
+
});
|
|
571
|
+
});
|
|
535
572
|
if (body.isScaffolded) {
|
|
536
573
|
report.addFollowUp({
|
|
537
574
|
title: `Finish the scaffolded auth provider ${authProviderName}`,
|
|
@@ -24,6 +24,7 @@ export const createMigrationScope = (tree, options) => {
|
|
|
24
24
|
return {
|
|
25
25
|
visit: (targetTree, callback) => visitNotIgnoredFiles(targetTree, '', callback),
|
|
26
26
|
describe: () => 'the whole workspace',
|
|
27
|
+
isScoped: false,
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
return {
|
|
@@ -33,6 +34,7 @@ export const createMigrationScope = (tree, options) => {
|
|
|
33
34
|
}
|
|
34
35
|
},
|
|
35
36
|
describe: () => roots.join(', '),
|
|
37
|
+
isScoped: true,
|
|
36
38
|
};
|
|
37
39
|
};
|
|
38
40
|
//# sourceMappingURL=migration-scope.js.map
|