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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/fesm2022/ethlete-query-ngrx-toolkit.mjs +321 -0
  3. package/fesm2022/ethlete-query-ngrx-toolkit.mjs.map +1 -0
  4. package/fesm2022/ethlete-query.mjs +359 -139
  5. package/fesm2022/ethlete-query.mjs.map +1 -1
  6. package/generators/generators.json +5 -0
  7. package/generators/migrate-from-ngrx-toolkit/args-assignability.js +140 -0
  8. package/generators/migrate-from-ngrx-toolkit/consumers.js +453 -0
  9. package/generators/migrate-from-ngrx-toolkit/creators.js +301 -0
  10. package/generators/migrate-from-ngrx-toolkit/facade.js +295 -0
  11. package/generators/migrate-from-ngrx-toolkit/feature.js +513 -0
  12. package/generators/migrate-from-ngrx-toolkit/migration.js +139 -0
  13. package/generators/migrate-from-ngrx-toolkit/report.js +154 -0
  14. package/generators/migrate-from-ngrx-toolkit/schema.json +42 -0
  15. package/generators/migrate-from-ngrx-toolkit/store-removal.js +633 -0
  16. package/generators/migrate-from-ngrx-toolkit/ts-edits.js +128 -0
  17. package/generators/migrate-query-opt-in-features/migration.js +10 -9
  18. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +29 -2
  19. package/generators/migrate-to-query-v3/cleanup-migration.js +22 -1
  20. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +39 -2
  21. package/generators/migrate-to-query-v3/migration.js +2 -1
  22. package/generators/migrate-to-query-v3/module-graph.js +2 -0
  23. package/generators/migrate-to-query-v3/query-client-migration.js +71 -2
  24. package/generators/migrate-to-query-v3/shared.js +27 -0
  25. package/generators/prep-for-query-v3/migration.js +83 -1
  26. package/migrations/query-field-default-types.md +43 -0
  27. package/migrations/search-query-field-string.md +43 -0
  28. package/migrations.json +16 -0
  29. package/package.json +5 -1
  30. package/types/ethlete-query-ngrx-toolkit.d.ts +143 -0
  31. package/types/ethlete-query.d.ts +129 -34
@@ -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.addFollowUp({
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,26 @@
1
1
  import * as ts from 'typescript';
2
- import { createSourceFile, ensureImportFromQuery, ensureNamedImports } from './shared.js';
2
+ import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, ensureImportFromQuery, ensureNamedImports, findRemovedExperimentalQueryHelpers, } from './shared.js';
3
+ export const reportRemovedExperimentalQueryHelpers = (tree, scope, report) => {
4
+ scope.visit(tree, (filePath) => {
5
+ if (!filePath.endsWith('.ts')) {
6
+ return;
7
+ }
8
+ const content = tree.read(filePath, 'utf-8');
9
+ if (!content?.includes('@ethlete/query')) {
10
+ return;
11
+ }
12
+ findRemovedExperimentalQueryHelpers(createSourceFile(content, filePath)).forEach(({ name, line }) => {
13
+ report.addWarning({
14
+ title: `Replace the removed helper ${name}`,
15
+ 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.`,
16
+ action: REMOVED_EXPERIMENTAL_QUERY_HELPERS[name],
17
+ locations: [{ filePath, line }],
18
+ source: 'cleanup-migration',
19
+ dedupeKey: `removed-experimental-helper:${filePath}:${name}`,
20
+ });
21
+ });
22
+ });
23
+ };
3
24
  /**
4
25
  * Points existing devtools usage at the v3 components instead of deleting it.
5
26
  *
@@ -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 !== 'route' && !HTTP_OPTION_NAMES.includes(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}`,
@@ -1,5 +1,5 @@
1
1
  import { formatFiles } from '@nx/devkit';
2
- import { migrateDevtoolsUsage, migrateEmptyPrepareCalls, replaceAnyQueryWithLegacy } from './cleanup-migration.js';
2
+ import { migrateDevtoolsUsage, migrateEmptyPrepareCalls, replaceAnyQueryWithLegacy, reportRemovedExperimentalQueryHelpers, } from './cleanup-migration.js';
3
3
  import { reportDefaultHeaderUsages, reportMissingHttpClientProviders } from './http-client-check.js';
4
4
  import { migrateLegacyPrepareCalls } from './legacy-prepare-migration.js';
5
5
  import { createNewQueryCreators, updateLegacyCreatorImportsAndUsages } from './legacy-query-creator-migration.js';
@@ -25,6 +25,7 @@ export default async function migrate(tree, schema) {
25
25
  migrateDevtoolsUsage(tree, scope, report);
26
26
  migrateEmptyPrepareCalls(tree, scope);
27
27
  migrateLegacyPrepareCalls(tree, report, scope);
28
+ reportRemovedExperimentalQueryHelpers(tree, scope, report);
28
29
  if (queryClientFiles.size > 0) {
29
30
  reportMissingHttpClientProviders(tree, report, scope);
30
31
  reportDefaultHeaderUsages(tree, report, scope);
@@ -138,6 +138,8 @@ export const createModuleGraph = (tree) => {
138
138
  return null;
139
139
  };
140
140
  return {
141
+ resolveFile: resolveEntryFile,
142
+ pathAliases: () => [...tsConfigPaths.keys()],
141
143
  findDeclaringFile: (fromFile, specifier, symbolName) => {
142
144
  const cacheKey = `${fromFile}|${specifier}|${symbolName}`;
143
145
  const cached = declaringFileCache.get(cacheKey);
@@ -1,4 +1,5 @@
1
1
  import * as ts from 'typescript';
2
+ import { CLIENT_FEATURE, reportClientErrorPipelineAdded } from '../migrate-query-opt-in-features/migration.js';
2
3
  import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, getVariableStatementEnd, } from './shared.js';
3
4
  export const migrateQueryClients = (tree, report, scope) => {
4
5
  const queryClientFiles = new Map();
@@ -151,6 +152,7 @@ const migrateQueryClientToConfig = ({ content, filePath, renamesOut, report }) =
151
152
  }
152
153
  }
153
154
  reportDroppedClientOptions({ configArgument, sourceFile, filePath, report });
155
+ reportChangedClientDefaults({ configArgument, sourceFile, filePath, report });
154
156
  replacements.push({
155
157
  start: node.getStart(sourceFile),
156
158
  end: node.getEnd(),
@@ -190,6 +192,7 @@ const removeQueryClientImport = (content, keepLegacyClient) => {
190
192
  'createPostQuery',
191
193
  'createPutQuery',
192
194
  'createQueryClient',
195
+ CLIENT_FEATURE,
193
196
  ];
194
197
  if (!queryImportNode?.importClause?.namedBindings || !ts.isNamedImports(queryImportNode.importClause.namedBindings)) {
195
198
  return ensureImportFromQuery(content, requiredImports);
@@ -253,6 +256,12 @@ const renameVariables = (content, renames) => {
253
256
  });
254
257
  return result;
255
258
  };
259
+ const WINDOW_FOCUS_REPLACEMENT = 'Opt in per query: `withPolling({ interval, refetchOnFocus: true })` on a polled query, `withAutoRefresh({ onSignalChanges: [windowFocusSignal] })` on any other, or `injectMyClient().refreshQueriesInUse()` from a focus listener.';
260
+ const SMART_POLLING_REPLACEMENT = 'Opt in per query: `withPolling({ interval, pauseWhileHidden: true })`. Without it v3 keeps polling in a hidden tab.';
261
+ const V2_ON_BY_DEFAULT_REQUEST_OPTIONS = {
262
+ autoRefreshQueriesOnWindowFocus: WINDOW_FOCUS_REPLACEMENT,
263
+ enableSmartPolling: SMART_POLLING_REPLACEMENT,
264
+ };
256
265
  /**
257
266
  * What each v2 client option turns into, or why it has nowhere to go.
258
267
  *
@@ -261,8 +270,8 @@ const renameVariables = (content, renames) => {
261
270
  * report from production.
262
271
  */
263
272
  const DROPPED_CLIENT_OPTIONS = {
264
- 'request.autoRefreshQueriesOnWindowFocus': 'No direct equivalent. Re-run the affected queries yourself - `withAutoRefresh({ onSignalChanges: [windowFocusSignal] })` per query, or `injectMyClient().refreshQueriesInUse()` from a focus listener.',
265
- 'request.enableSmartPolling': 'No direct equivalent. `withPolling({ interval })` polls unconditionally; pause it yourself when the tab is hidden if that matters.',
273
+ 'request.autoRefreshQueriesOnWindowFocus': WINDOW_FOCUS_REPLACEMENT,
274
+ 'request.enableSmartPolling': SMART_POLLING_REPLACEMENT,
266
275
  'logging.preparedQuerySubscriptions': 'Removed. Use the v3 devtools (`provideQueryDevtools()`) or `withLogging({ logFn })` on the queries you want to trace.',
267
276
  'logging.queryStateChanges': 'Removed. Use the v3 devtools (`provideQueryDevtools()`) or `withLogging({ logFn })` on the queries you want to trace.',
268
277
  'request.autoRefreshOn': 'No direct equivalent. Re-run the affected queries yourself with `withAutoRefresh` or `refreshQueriesInUse()`.',
@@ -315,6 +324,12 @@ const reportDroppedClientOptions = ({ configArgument, sourceFile, filePath, repo
315
324
  if (name === 'request' && ['queryParams', 'cacheAdapter', 'retryFn'].includes(nested.name.text)) {
316
325
  return;
317
326
  }
327
+ if (name === 'request' &&
328
+ nested.name.text in V2_ON_BY_DEFAULT_REQUEST_OPTIONS &&
329
+ ts.isPropertyAssignment(nested) &&
330
+ nested.initializer.kind === ts.SyntaxKind.FalseKeyword) {
331
+ return;
332
+ }
318
333
  raise(`${name}.${nested.name.text}`, nested);
319
334
  });
320
335
  return;
@@ -324,6 +339,59 @@ const reportDroppedClientOptions = ({ configArgument, sourceFile, filePath, repo
324
339
  }
325
340
  });
326
341
  };
342
+ const reportChangedClientDefaults = ({ configArgument, sourceFile, filePath, report, }) => {
343
+ const location = {
344
+ filePath,
345
+ line: sourceFile.getLineAndCharacterOfPosition(configArgument.getStart(sourceFile)).line + 1,
346
+ };
347
+ const requestProperty = findProperty(configArgument, 'request');
348
+ const requestConfig = requestProperty &&
349
+ ts.isPropertyAssignment(requestProperty) &&
350
+ ts.isObjectLiteralExpression(requestProperty.initializer)
351
+ ? requestProperty.initializer
352
+ : undefined;
353
+ const hasUnreadableRequestConfig = (!!requestProperty && !requestConfig) || !!requestConfig?.properties.some(ts.isSpreadAssignment);
354
+ reportClientErrorPipelineAdded(report, location, 'query-client-migration', `\`${CLIENT_FEATURE}()\` was added so failed requests still retry and Symfony / HTML error bodies are still parsed, as they were by default in v2.`);
355
+ if (!requestConfig || !findProperty(requestConfig, 'retryFn')) {
356
+ report.addWarning({
357
+ title: 'Check the retry behaviour of the migrated client',
358
+ summary: 'v2 retried every request method on a 5xx (500 included), 408, 425 and 429, up to four times with a linear delay. The v3 default policy that `withEthleteApiErrors()` installs retries three times with an exponential, jittered delay, never retries a `POST`, a `PATCH` or a plain `500`, and also retries a connection failure.',
359
+ action: 'Accept the v3 policy, or pass `withEthleteApiErrors({ retry: { maxAttempts, retryableStatusCodes, retryNonIdempotent } })` - only set `retryNonIdempotent` for endpoints that deduplicate on their own.',
360
+ locations: [location],
361
+ source: 'query-client-migration',
362
+ dedupeKey: `changed-retry-default:${filePath}`,
363
+ });
364
+ }
365
+ const cacheAdapter = requestConfig && findProperty(requestConfig, 'cacheAdapter');
366
+ if (cacheAdapter) {
367
+ report.addWarning({
368
+ title: 'Check the carried-over cacheAdapter',
369
+ summary: 'The v2 `cacheAdapter` was carried over as is and still sets the freshness TTL: a TTL of `0` means an `execute({ options: { allowCache: true } })` always hits the server, as a v2 `execute()` did. Two things differ: v3 passes Angular `HttpHeaders` (read them with `headers.get(name)`) where v2 passed a plain record, and v3 keeps an entry for `keepUnusedFor` (5 minutes) after its last consumer, rendering the old response on a remount while it refetches.',
370
+ action: 'Rewrite an adapter that reads headers for `HttpHeaders`. If the adapter was meant to switch caching off, also set `keepUnusedFor: 0` on the client.',
371
+ locations: [
372
+ { filePath, line: sourceFile.getLineAndCharacterOfPosition(cacheAdapter.getStart(sourceFile)).line + 1 },
373
+ ],
374
+ source: 'query-client-migration',
375
+ dedupeKey: `carried-cache-adapter:${filePath}`,
376
+ });
377
+ }
378
+ if (hasUnreadableRequestConfig) {
379
+ return;
380
+ }
381
+ Object.entries(V2_ON_BY_DEFAULT_REQUEST_OPTIONS).forEach(([option, action]) => {
382
+ if (requestConfig && findProperty(requestConfig, option)) {
383
+ return;
384
+ }
385
+ report.addWarning({
386
+ title: `Restore the v2 default "request.${option}"`,
387
+ summary: `The v2 client did not set \`request.${option}\`, so it ran with the v2 default \`true\`. v3 has no client-wide equivalent and the behaviour is off for every migrated query.`,
388
+ action,
389
+ locations: [location],
390
+ source: 'query-client-migration',
391
+ dedupeKey: `dropped-client-default:${filePath}:${option}`,
392
+ });
393
+ });
394
+ };
327
395
  const getPropertyValueText = (property, sourceFile) => {
328
396
  if (ts.isShorthandPropertyAssignment(property)) {
329
397
  return property.name.text;
@@ -361,6 +429,7 @@ const migrateConfigObject = (configArgument, node, sourceFile) => {
361
429
  }
362
430
  }
363
431
  }
432
+ nextConfig.push(`features: [${CLIENT_FEATURE}()]`);
364
433
  return `createQueryClient({\n ${nextConfig.join(',\n ')}\n})`;
365
434
  };
366
435
  const getVariableNameForQueryClient = (node) => {
@@ -143,4 +143,31 @@ export const addJsDocTag = (content, sourceFile, node, tag) => {
143
143
  const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
144
144
  return (content.slice(0, start) + `/**\n${indentation} * ${tag}\n${indentation} */\n${indentation}` + content.slice(start));
145
145
  };
146
+ const TUPLE_PROVIDER_ACTION = 'Delete the call: a v3 client, auth provider or socket is root-provided the moment something injects it. For a test or an override, provide it with `toProvideFn(ref)` from `@ethlete/core`.';
147
+ export const REMOVED_EXPERIMENTAL_QUERY_HELPERS = {
148
+ createQueryClientConfig: 'Replace it with `createQueryClient({ name, baseUrl })`, which returns the provider tuple, and derive `toProvideFn` / `toInjectFn` from it.',
149
+ createBearerAuthProviderConfig: 'Replace it with `createBearerAuthProvider({ name, queryClientRef, queries, features })`, which returns the provider tuple, and derive `toProvideFn` / `toInjectFn` from it.',
150
+ provideQueryClient: TUPLE_PROVIDER_ACTION,
151
+ provideBearerAuthProvider: TUPLE_PROVIDER_ACTION,
152
+ provideWebSocketClient: `Build the socket with \`createWebSocketClient({ name, url, io })\`. ${TUPLE_PROVIDER_ACTION}`,
153
+ };
154
+ export const findRemovedExperimentalQueryHelpers = (sourceFile) => {
155
+ const found = [];
156
+ sourceFile.statements.forEach((statement) => {
157
+ if (!ts.isImportDeclaration(statement) ||
158
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
159
+ statement.moduleSpecifier.text !== '@ethlete/query' ||
160
+ !statement.importClause?.namedBindings ||
161
+ !ts.isNamedImports(statement.importClause.namedBindings)) {
162
+ return;
163
+ }
164
+ statement.importClause.namedBindings.elements.forEach((element) => {
165
+ const name = (element.propertyName ?? element.name).text;
166
+ if (name in REMOVED_EXPERIMENTAL_QUERY_HELPERS) {
167
+ found.push({ name, line: getLineNumber(element, sourceFile) });
168
+ }
169
+ });
170
+ });
171
+ return found;
172
+ };
146
173
  //# sourceMappingURL=shared.js.map
@@ -1,9 +1,11 @@
1
- import { formatFiles, visitNotIgnoredFiles } from '@nx/devkit';
1
+ import { formatFiles, joinPathFragments, readJson, visitNotIgnoredFiles } from '@nx/devkit';
2
2
  import * as ts from 'typescript';
3
+ import { REMOVED_EXPERIMENTAL_QUERY_HELPERS, createSourceFile, findRemovedExperimentalQueryHelpers, } from '../migrate-to-query-v3/shared.js';
3
4
  export default async function migrate(tree, schema) {
4
5
  console.log('\n🔄 Starting Query v3 symbol conflict migration...');
5
6
  renameConflictingSymbols(tree);
6
7
  replaceExperimentalQueryNamespace(tree);
8
+ reportPrebuiltPackagesImportingRenamedSymbols(tree);
7
9
  if (!schema.skipFormat) {
8
10
  await formatFiles(tree);
9
11
  }
@@ -224,6 +226,7 @@ function updateImports(sourceFile) {
224
226
  //#region ExperimentalQuery namespace replacement
225
227
  function replaceExperimentalQueryNamespace(tree) {
226
228
  const updatedFiles = [];
229
+ const removedHelpers = [];
227
230
  let totalReplacements = 0;
228
231
  visitNotIgnoredFiles(tree, '', (filePath) => {
229
232
  if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts'))
@@ -240,7 +243,13 @@ function replaceExperimentalQueryNamespace(tree) {
240
243
  updatedFiles.push(filePath);
241
244
  totalReplacements += result.replacementCount;
242
245
  }
246
+ findRemovedExperimentalQueryHelpers(createSourceFile(result.content, filePath)).forEach(({ name, line }) => {
247
+ removedHelpers.push(` - ${filePath}:${line} ${name}: ${REMOVED_EXPERIMENTAL_QUERY_HELPERS[name]}`);
248
+ });
243
249
  });
250
+ if (removedHelpers.length > 0) {
251
+ console.warn(`\n⚠️ These ExperimentalQuery helpers no longer exist in v3 and were left for you to replace by hand (migrate-to-query-v3 lists them again in its report):\n${removedHelpers.join('\n')}`);
252
+ }
244
253
  if (updatedFiles.length > 0) {
245
254
  console.log(`\n📦 Replaced ExperimentalQuery namespace in ${updatedFiles.length} files (${totalReplacements} usages)`);
246
255
  }
@@ -382,5 +391,78 @@ function applyReplacements(content, replacements, replacementCount) {
382
391
  });
383
392
  return { content: result, modified: replacements.length > 0, replacementCount };
384
393
  }
394
+ const isRenamedSymbol = (name) => TYPE_RENAMES.has(name) || FUNCTION_RENAMES.has(name);
395
+ function reportPrebuiltPackagesImportingRenamedSymbols(tree) {
396
+ if (!tree.exists('package.json'))
397
+ return;
398
+ const packageJson = readJson(tree, 'package.json');
399
+ const packageNames = new Set([packageJson.dependencies, packageJson.devDependencies, packageJson.optionalDependencies].flatMap((deps) => Object.keys(deps ?? {})));
400
+ const hits = [];
401
+ [...packageNames]
402
+ .filter((name) => !name.startsWith('@ethlete/'))
403
+ .sort()
404
+ .forEach((packageName) => {
405
+ const importedNames = new Set();
406
+ collectDeclarationFiles(tree, joinPathFragments('node_modules', packageName)).forEach((filePath) => {
407
+ const content = tree.read(filePath, 'utf-8');
408
+ if (!content?.includes('@ethlete/query'))
409
+ return;
410
+ findRenamedSymbolsInDeclaration(createSourceFile(content, filePath)).forEach((name) => importedNames.add(name));
411
+ });
412
+ if (importedNames.size > 0) {
413
+ hits.push(` - ${packageName}: ${[...importedNames].sort().join(', ')}`);
414
+ }
415
+ });
416
+ if (hits.length > 0) {
417
+ console.warn(`\n⚠️ These installed packages were built against @ethlete/query v2 and import names v3 no longer exports. No codemod can fix them here: run prep-for-query-v3 in each package's own repository and release a rebuild before upgrading this workspace:\n${hits.join('\n')}`);
418
+ }
419
+ }
420
+ function collectDeclarationFiles(tree, dir) {
421
+ if (!tree.exists(dir))
422
+ return [];
423
+ return tree.children(dir).flatMap((child) => {
424
+ if (child === 'node_modules')
425
+ return [];
426
+ const childPath = joinPathFragments(dir, child);
427
+ if (tree.isFile(childPath)) {
428
+ return /\.d\.(c|m)?ts$/.test(child) ? [childPath] : [];
429
+ }
430
+ return collectDeclarationFiles(tree, childPath);
431
+ });
432
+ }
433
+ function findRenamedSymbolsInDeclaration(sourceFile) {
434
+ const names = new Set();
435
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
436
+ const isQueryModule = (node) => !!node && ts.isStringLiteral(node) && node.text === '@ethlete/query';
437
+ function visit(node) {
438
+ if (ts.isImportDeclaration(node) && isQueryModule(node.moduleSpecifier)) {
439
+ const namedBindings = node.importClause?.namedBindings;
440
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
441
+ namedBindings.elements.forEach((element) => names.add((element.propertyName ?? element.name).text));
442
+ }
443
+ }
444
+ if (ts.isExportDeclaration(node) && isQueryModule(node.moduleSpecifier) && node.exportClause) {
445
+ if (ts.isNamedExports(node.exportClause)) {
446
+ node.exportClause.elements.forEach((element) => names.add((element.propertyName ?? element.name).text));
447
+ }
448
+ }
449
+ if (ts.isImportTypeNode(node) &&
450
+ ts.isLiteralTypeNode(node.argument) &&
451
+ isQueryModule(node.argument.literal) &&
452
+ node.qualifier) {
453
+ names.add(getLeftmostIdentifier(node.qualifier).text);
454
+ }
455
+ const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
456
+ if (namespaceMember) {
457
+ names.add(namespaceMember.text);
458
+ }
459
+ ts.forEachChild(node, visit);
460
+ }
461
+ visit(sourceFile);
462
+ return new Set([...names].filter(isRenamedSymbol));
463
+ }
464
+ function getLeftmostIdentifier(name) {
465
+ return ts.isIdentifier(name) ? name : getLeftmostIdentifier(name.left);
466
+ }
385
467
  //#endregion
386
468
  //# sourceMappingURL=migration.js.map