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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +766 -0
  2. package/fesm2022/ethlete-query-testing.mjs +441 -38
  3. package/fesm2022/ethlete-query-testing.mjs.map +1 -1
  4. package/fesm2022/ethlete-query.mjs +15946 -5331
  5. package/fesm2022/ethlete-query.mjs.map +1 -1
  6. package/generators/deprecate-legacy-queries/migration.js +77 -0
  7. package/generators/deprecate-legacy-queries/schema.json +26 -0
  8. package/generators/generators.json +15 -0
  9. package/generators/migrate-query-client-features/migration.js +142 -0
  10. package/generators/migrate-query-client-features/report.js +136 -0
  11. package/generators/migrate-query-client-features/schema.json +26 -0
  12. package/generators/migrate-query-opt-in-features/migration.js +194 -0
  13. package/generators/migrate-query-opt-in-features/report.js +143 -0
  14. package/generators/migrate-query-opt-in-features/schema.json +31 -0
  15. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +133 -0
  16. package/generators/migrate-to-query-v3/cleanup-migration.js +111 -99
  17. package/generators/migrate-to-query-v3/http-client-check.js +93 -0
  18. package/generators/migrate-to-query-v3/legacy-prepare-migration.js +129 -40
  19. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +237 -152
  20. package/generators/migrate-to-query-v3/migration-scope.js +38 -0
  21. package/generators/migrate-to-query-v3/migration.js +17 -9
  22. package/generators/migrate-to-query-v3/module-graph.js +153 -0
  23. package/generators/migrate-to-query-v3/query-client-migration.js +152 -53
  24. package/generators/migrate-to-query-v3/rename-symbols.js +206 -0
  25. package/generators/migrate-to-query-v3/schema.json +12 -0
  26. package/generators/migrate-to-query-v3/shared.js +51 -2
  27. package/generators/prep-for-query-v3/migration.js +71 -14
  28. package/migrations.json +44 -0
  29. package/package.json +29 -13
  30. package/types/ethlete-query-testing.d.ts +179 -12
  31. package/types/ethlete-query.d.ts +8935 -3203
@@ -0,0 +1,77 @@
1
+ import { formatFiles } from '@nx/devkit';
2
+ import * as ts from 'typescript';
3
+ import { createMigrationScope } from '../migrate-to-query-v3/migration-scope.js';
4
+ import { addJsDocTag, createSourceFile, legacyQueryDeprecationTag } from '../migrate-to-query-v3/shared.js';
5
+ const CREATOR_FN = 'createLegacyQueryCreator';
6
+ const isMigratableFile = (filePath) => /\.(ts|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts');
7
+ const creatorNameOf = (call, sourceFile) => {
8
+ const [options] = call.arguments;
9
+ if (!options || !ts.isObjectLiteralExpression(options)) {
10
+ return undefined;
11
+ }
12
+ const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && candidate.name.getText(sourceFile) === 'creator');
13
+ if (!property || !ts.isIdentifier(property.initializer)) {
14
+ return undefined;
15
+ }
16
+ return property.initializer.text;
17
+ };
18
+ const collectWrappers = (sourceFile) => {
19
+ const wrappers = [];
20
+ const visit = (node) => {
21
+ if (ts.isVariableDeclaration(node) &&
22
+ node.initializer &&
23
+ ts.isCallExpression(node.initializer) &&
24
+ ts.isIdentifier(node.initializer.expression) &&
25
+ node.initializer.expression.text === CREATOR_FN) {
26
+ const statement = node.parent.parent;
27
+ // A wrapper declared inside a function body has no importable call sites to strike through.
28
+ if (ts.isVariableStatement(statement) && ts.isSourceFile(statement.parent)) {
29
+ wrappers.push({ statement, creatorName: creatorNameOf(node.initializer, sourceFile) });
30
+ }
31
+ }
32
+ ts.forEachChild(node, visit);
33
+ };
34
+ visit(sourceFile);
35
+ return wrappers;
36
+ };
37
+ export const deprecateLegacyQueries = (tree, scope) => {
38
+ let deprecatedWrappers = 0;
39
+ let touchedFiles = 0;
40
+ scope.visit(tree, (filePath) => {
41
+ if (!isMigratableFile(filePath))
42
+ return;
43
+ const content = tree.read(filePath, 'utf-8');
44
+ if (!content?.includes(CREATOR_FN))
45
+ return;
46
+ const sourceFile = createSourceFile(content, filePath);
47
+ const wrappers = collectWrappers(sourceFile);
48
+ let nextContent = content;
49
+ let tagged = 0;
50
+ // Back to front, so an insertion never shifts the position the next wrapper was parsed at.
51
+ for (const wrapper of wrappers.sort((a, b) => b.statement.getStart(sourceFile) - a.statement.getStart(sourceFile))) {
52
+ const updated = addJsDocTag(nextContent, sourceFile, wrapper.statement, legacyQueryDeprecationTag(wrapper.creatorName));
53
+ if (updated === null)
54
+ continue;
55
+ nextContent = updated;
56
+ tagged += 1;
57
+ }
58
+ if (tagged === 0)
59
+ return;
60
+ tree.write(filePath, nextContent);
61
+ deprecatedWrappers += tagged;
62
+ touchedFiles += 1;
63
+ });
64
+ return { deprecatedWrappers, touchedFiles };
65
+ };
66
+ export default async function migrate(tree, schema) {
67
+ console.log('\nšŸ”„ Deprecating legacy query wrappers...');
68
+ const scope = createMigrationScope(tree, schema);
69
+ console.log(` Scope: ${scope.describe()}`);
70
+ const { deprecatedWrappers, touchedFiles } = deprecateLegacyQueries(tree, scope);
71
+ console.log(` Tagged ${deprecatedWrappers} wrapper${deprecatedWrappers === 1 ? '' : 's'} in ${touchedFiles} file${touchedFiles === 1 ? '' : 's'}.`);
72
+ if (!schema.skipFormat) {
73
+ await formatFiles(tree);
74
+ }
75
+ console.log('\nāœ… Legacy query wrappers deprecated successfully!');
76
+ }
77
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "deprecate-legacy-queries",
4
+ "title": "Deprecate legacy query wrappers",
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
+ "skipFormat": {
20
+ "type": "boolean",
21
+ "description": "Skip formatting files after migration",
22
+ "default": false
23
+ }
24
+ },
25
+ "required": []
26
+ }
@@ -5,10 +5,25 @@
5
5
  "schema": "./migrate-to-query-v3/schema.json",
6
6
  "description": "Migrate from legacy query system to new query system using interop"
7
7
  },
8
+ "migrate-query-client-features": {
9
+ "factory": "./migrate-query-client-features/migration",
10
+ "schema": "./migrate-query-client-features/schema.json",
11
+ "description": "Migrate createQueryClient multiTabSync/persistence options to opt-in features"
12
+ },
8
13
  "prep-for-query-v3": {
9
14
  "factory": "./prep-for-query-v3/migration",
10
15
  "schema": "./prep-for-query-v3/schema.json",
11
16
  "description": "Update code to prepare for migration to query v3"
17
+ },
18
+ "deprecate-legacy-queries": {
19
+ "factory": "./deprecate-legacy-queries/migration",
20
+ "schema": "./deprecate-legacy-queries/schema.json",
21
+ "description": "Mark existing createLegacyQueryCreator wrappers as @deprecated so their call sites surface as still on v2"
22
+ },
23
+ "migrate-query-opt-in-features": {
24
+ "factory": "./migrate-query-opt-in-features/migration",
25
+ "schema": "./migrate-query-opt-in-features/schema.json",
26
+ "description": "Migrate query clients to the opt-in error pipeline and bearer auth providers to the opt-in multi-tab sync feature"
12
27
  }
13
28
  }
14
29
  }
@@ -0,0 +1,142 @@
1
+ import { formatFiles } from '@nx/devkit';
2
+ import * as ts from 'typescript';
3
+ import { createMigrationScope } from '../migrate-to-query-v3/migration-scope.js';
4
+ import { createSourceFile, ensureImportFromQuery, getLineNumber } from '../migrate-to-query-v3/shared.js';
5
+ import { QueryClientFeaturesMigrationReport } from './report.js';
6
+ const SOURCE = 'query-client-features';
7
+ const isMigratableFile = (filePath) => /\.(ts|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts');
8
+ const featureCallFor = (property, featureFn, sourceFile) => {
9
+ if (!property)
10
+ return `${featureFn}()`;
11
+ const value = property.initializer;
12
+ if (value.kind === ts.SyntaxKind.FalseKeyword)
13
+ return null;
14
+ if (value.kind === ts.SyntaxKind.TrueKeyword)
15
+ return `${featureFn}()`;
16
+ return `${featureFn}(${value.getText(sourceFile)})`;
17
+ };
18
+ const collectClientEdits = (filePath, sourceFile, report) => {
19
+ const edits = [];
20
+ const visit = (node) => {
21
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'createQueryClient') {
22
+ const [config] = node.arguments;
23
+ const line = getLineNumber(node, sourceFile);
24
+ if (!config || !ts.isObjectLiteralExpression(config)) {
25
+ report.addManualReview({
26
+ title: 'createQueryClient called with a non-literal config',
27
+ summary: 'The client config is not an object literal, so the codemod cannot tell whether it sets `multiTabSync` or `persistence`.',
28
+ action: 'Move the config inline, or replace the removed `multiTabSync` / `persistence` options with `features: [withMultiTabSync(), withQueryPersistence()]` by hand. Consider dropping a feature the app does not rely on instead — that removes the whole subsystem from the bundle.',
29
+ locations: [{ filePath, line }],
30
+ source: SOURCE,
31
+ dedupeKey: 'non-literal-config',
32
+ });
33
+ }
34
+ else if (config.properties.some((property) => ts.isSpreadAssignment(property))) {
35
+ report.addManualReview({
36
+ title: 'createQueryClient config spreads another object',
37
+ summary: 'The spread may carry `multiTabSync` or `persistence`, which no longer exist on the client config.',
38
+ action: 'Check what the spread contributes and express it as `features: [withMultiTabSync(), withQueryPersistence()]` by hand. Consider dropping a feature the app does not rely on instead — that removes the whole subsystem from the bundle.',
39
+ locations: [{ filePath, line }],
40
+ source: SOURCE,
41
+ dedupeKey: 'spread-config',
42
+ });
43
+ }
44
+ else {
45
+ const named = (name) => config.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText(sourceFile) === name);
46
+ const syncProperty = named('multiTabSync');
47
+ const persistenceProperty = named('persistence');
48
+ const featuresProperty = named('features');
49
+ if (featuresProperty) {
50
+ report.addManualReview({
51
+ title: 'createQueryClient already has a features array',
52
+ summary: 'The client was left untouched so an existing `features` array is not overwritten.',
53
+ action: 'Fold the removed `multiTabSync` / `persistence` options into the existing `features` array.',
54
+ locations: [{ filePath, line }],
55
+ source: SOURCE,
56
+ dedupeKey: 'existing-features',
57
+ });
58
+ }
59
+ else {
60
+ for (const property of [syncProperty, persistenceProperty]) {
61
+ const value = property?.initializer;
62
+ if (!value)
63
+ continue;
64
+ if (value.kind === ts.SyntaxKind.TrueKeyword ||
65
+ value.kind === ts.SyntaxKind.FalseKeyword ||
66
+ ts.isObjectLiteralExpression(value)) {
67
+ continue;
68
+ }
69
+ report.addWarning({
70
+ title: 'Client option value is not a literal',
71
+ summary: 'A `multiTabSync` / `persistence` value that is a variable or expression was passed through as the feature config, which is wrong if it can be a boolean.',
72
+ action: 'Check the value: a config object is fine as is, a boolean has to become a present or absent feature.',
73
+ locations: [{ filePath, line }],
74
+ source: SOURCE,
75
+ dedupeKey: 'non-literal-option-value',
76
+ });
77
+ }
78
+ const features = [
79
+ featureCallFor(syncProperty, 'withMultiTabSync', sourceFile),
80
+ featureCallFor(persistenceProperty, 'withQueryPersistence', sourceFile),
81
+ ].filter((feature) => feature !== null);
82
+ const kept = config.properties
83
+ .filter((property) => property !== syncProperty && property !== persistenceProperty)
84
+ .map((property) => property.getText(sourceFile));
85
+ const properties = [...kept, ...(features.length > 0 ? [`features: [${features.join(', ')}]`] : [])];
86
+ edits.push({
87
+ start: config.getStart(sourceFile),
88
+ end: config.getEnd(),
89
+ text: `{ ${properties.join(', ')} }`,
90
+ features,
91
+ });
92
+ }
93
+ }
94
+ }
95
+ ts.forEachChild(node, visit);
96
+ };
97
+ visit(sourceFile);
98
+ return edits;
99
+ };
100
+ export const migrateQueryClientFeatures = (tree, report, scope) => {
101
+ let migratedClients = 0;
102
+ scope.visit(tree, (filePath) => {
103
+ if (!isMigratableFile(filePath))
104
+ return;
105
+ const content = tree.read(filePath, 'utf-8');
106
+ if (!content?.includes('createQueryClient'))
107
+ return;
108
+ const sourceFile = createSourceFile(content, filePath);
109
+ const edits = collectClientEdits(filePath, sourceFile, report);
110
+ if (edits.length === 0)
111
+ return;
112
+ let nextContent = content;
113
+ // Applied back to front so an earlier edit cannot shift a later one's offsets.
114
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
115
+ nextContent = nextContent.slice(0, edit.start) + edit.text + nextContent.slice(edit.end);
116
+ }
117
+ const importsNeeded = [...new Set(edits.flatMap((edit) => edit.features))]
118
+ .map((feature) => feature.slice(0, feature.indexOf('(')))
119
+ .sort();
120
+ if (importsNeeded.length > 0) {
121
+ nextContent = ensureImportFromQuery(nextContent, importsNeeded);
122
+ }
123
+ tree.write(filePath, nextContent);
124
+ migratedClients += edits.length;
125
+ });
126
+ return { migratedClients };
127
+ };
128
+ export default async function migrate(tree, schema) {
129
+ console.log('\nšŸ”„ Migrating query clients to opt-in features...');
130
+ const scope = createMigrationScope(tree, schema);
131
+ console.log(` Scope: ${scope.describe()}`);
132
+ const report = new QueryClientFeaturesMigrationReport();
133
+ const { migratedClients } = migrateQueryClientFeatures(tree, report, scope);
134
+ console.log(` Migrated ${migratedClients} query client${migratedClients === 1 ? '' : 's'}.`);
135
+ report.writeToTree(tree);
136
+ report.printSummary();
137
+ if (!schema.skipFormat) {
138
+ await formatFiles(tree);
139
+ }
140
+ console.log('\nāœ… Query client feature migration completed successfully!');
141
+ }
142
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1,136 @@
1
+ export const QUERY_CLIENT_FEATURES_REPORT_PATH = 'query-client-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 QueryClientFeaturesMigrationReport {
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: `QCF-${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_CLIENT_FEATURES_REPORT_PATH} for the AI-ready follow-up list.`);
56
+ }
57
+ writeToTree(tree) {
58
+ tree.write(QUERY_CLIENT_FEATURES_REPORT_PATH, renderMigrationReportMarkdown(this.tasks));
59
+ }
60
+ }
61
+ export const renderMigrationReportMarkdown = (tasks) => {
62
+ const header = [
63
+ '# Query Client Features Migration Follow-Up',
64
+ '',
65
+ 'This file is generated by the `migrate-query-client-features` generator.',
66
+ '',
67
+ '## Context',
68
+ '',
69
+ 'Multi-tab sync and query persistence used to be on by default and are now opt-in features of',
70
+ '`createQueryClient`. The codemod is **behavior preserving**: every client that did not opt out',
71
+ 'gets `withMultiTabSync()` / `withQueryPersistence()` so it keeps working exactly as before.',
72
+ 'That is rarely what an app wants long term — dropping a feature it never relied on removes the',
73
+ 'whole subsystem from the bundle, so review each added feature and delete the ones you do not need.',
74
+ '',
75
+ '## AI Instructions',
76
+ '',
77
+ '1. Re-read the referenced files before changing anything. The migration output is only a starting point.',
78
+ '2. Prefer minimal fixes that align with the current runtime APIs in `@ethlete/query`.',
79
+ '3. Update or remove a task only after the code and tests confirm that it is resolved.',
80
+ '4. If a task is still ambiguous after inspection, keep it open and append a short note instead of guessing.',
81
+ '',
82
+ '## Task Format',
83
+ '',
84
+ '- `Status`: always treat generated tasks as `open` until verified manually or by tests.',
85
+ '- `Kind`: `warning`, `manual-review`, or `follow-up`.',
86
+ '- `Source`: which migration phase raised the task.',
87
+ '',
88
+ ].join('\n');
89
+ if (tasks.length === 0) {
90
+ return `${header}## Tasks\n\nNo open follow-up tasks were recorded during this migration run.\n`;
91
+ }
92
+ const tasksSection = tasks
93
+ .map((task) => [
94
+ `### ${task.id} - ${task.title}`,
95
+ '',
96
+ '- Status: open',
97
+ `- Kind: ${task.kind}`,
98
+ `- Source: ${task.source}`,
99
+ `- Summary: ${task.summary}`,
100
+ `- Suggested action: ${task.action}`,
101
+ '- Locations:',
102
+ renderTaskLocations(task.locations),
103
+ '',
104
+ ].join('\n'))
105
+ .join('');
106
+ const machineSection = [
107
+ '## Machine Summary',
108
+ '',
109
+ '```yaml',
110
+ 'tasks:',
111
+ ...tasks.flatMap((task) => {
112
+ const nestedLocations = task.locations.flatMap((location) => {
113
+ const lines = [` - filePath: ${location.filePath}`];
114
+ if (location.line) {
115
+ lines.push(` line: ${location.line}`);
116
+ }
117
+ return lines;
118
+ });
119
+ return [
120
+ ` - id: ${task.id}`,
121
+ ` status: open`,
122
+ ` kind: ${task.kind}`,
123
+ ` source: ${task.source}`,
124
+ ` title: ${task.title}`,
125
+ ` summary: ${task.summary}`,
126
+ ` action: ${task.action}`,
127
+ ` locations:`,
128
+ ...(nestedLocations.length > 0 ? nestedLocations : [' []']),
129
+ ];
130
+ }),
131
+ '```',
132
+ '',
133
+ ].join('\n');
134
+ return `${header}## Tasks\n\n${tasksSection}${machineSection}`;
135
+ };
136
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "migrate-query-client-features",
4
+ "title": "Migrate query clients 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
+ "skipFormat": {
20
+ "type": "boolean",
21
+ "description": "Skip formatting files after migration",
22
+ "default": false
23
+ }
24
+ },
25
+ "required": []
26
+ }
@@ -0,0 +1,194 @@
1
+ import { formatFiles } from '@nx/devkit';
2
+ import * as ts from 'typescript';
3
+ import { createMigrationScope } from '../migrate-to-query-v3/migration-scope.js';
4
+ import { createSourceFile, ensureImportFromQuery, getLineNumber } from '../migrate-to-query-v3/shared.js';
5
+ import { QueryOptInFeaturesMigrationReport } from './report.js';
6
+ const SOURCE = 'query-opt-in-features';
7
+ const CLIENT_FEATURE = 'withEthleteApiErrors';
8
+ const AUTH_FEATURE = 'withBearerAuthMultiTabSync';
9
+ const isMigratableFile = (filePath) => /\.(ts|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts');
10
+ const propertyNamed = (config, sourceFile, name) => config.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText(sourceFile) === name);
11
+ const hasFeature = (config, sourceFile, feature) => {
12
+ const features = propertyNamed(config, sourceFile, 'features')?.initializer;
13
+ return (!!features &&
14
+ ts.isArrayLiteralExpression(features) &&
15
+ features.elements.some((element) => ts.isCallExpression(element) && ts.isIdentifier(element.expression) && element.expression.text === feature));
16
+ };
17
+ /** `{ enabled: false, channelName: 'x' }` -> `withBearerAuthMultiTabSync({ channelName: 'x' })`, or `null` when off. */
18
+ const authFeatureCallFor = (property, sourceFile) => {
19
+ if (!property)
20
+ return `${AUTH_FEATURE}()`;
21
+ const value = property.initializer;
22
+ if (value.kind === ts.SyntaxKind.FalseKeyword)
23
+ return null;
24
+ if (value.kind === ts.SyntaxKind.TrueKeyword)
25
+ return `${AUTH_FEATURE}()`;
26
+ if (ts.isObjectLiteralExpression(value)) {
27
+ const enabled = propertyNamed(value, sourceFile, 'enabled');
28
+ if (enabled?.initializer.kind === ts.SyntaxKind.FalseKeyword)
29
+ return null;
30
+ const kept = value.properties.filter((entry) => entry !== enabled).map((entry) => entry.getText(sourceFile));
31
+ return kept.length > 0 ? `${AUTH_FEATURE}({ ${kept.join(', ')} })` : `${AUTH_FEATURE}()`;
32
+ }
33
+ return `${AUTH_FEATURE}(${value.getText(sourceFile)})`;
34
+ };
35
+ const withFeatureAdded = (config, sourceFile, removed, feature) => {
36
+ const featuresProperty = propertyNamed(config, sourceFile, 'features');
37
+ const properties = [];
38
+ for (const property of config.properties) {
39
+ if (property === removed)
40
+ continue;
41
+ if (property === featuresProperty && feature) {
42
+ const existing = featuresProperty.initializer;
43
+ const entries = ts.isArrayLiteralExpression(existing)
44
+ ? existing.elements.map((element) => element.getText(sourceFile))
45
+ : [`...${existing.getText(sourceFile)}`];
46
+ properties.push(`features: [${[...entries, feature].join(', ')}]`);
47
+ continue;
48
+ }
49
+ properties.push(property.getText(sourceFile));
50
+ }
51
+ if (!featuresProperty && feature)
52
+ properties.push(`features: [${feature}]`);
53
+ return `{ ${properties.join(', ')} }`;
54
+ };
55
+ const collectEdits = (filePath, sourceFile, report) => {
56
+ const edits = [];
57
+ const visit = (node) => {
58
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
59
+ const callee = node.expression.text;
60
+ const isClient = callee === 'createQueryClient';
61
+ const isAuth = callee === 'createBearerAuthProvider';
62
+ if (isClient || isAuth) {
63
+ const [config] = node.arguments;
64
+ const line = getLineNumber(node, sourceFile);
65
+ if (!config || !ts.isObjectLiteralExpression(config)) {
66
+ report.addManualReview({
67
+ title: `${callee} called with a non-literal config`,
68
+ summary: 'The config is not an object literal, so the codemod cannot add the opt-in feature to it.',
69
+ action: `Add \`${isClient ? CLIENT_FEATURE : AUTH_FEATURE}()\` to the config's \`features\` array by hand, or drop the behavior if the app does not rely on it.`,
70
+ locations: [{ filePath, line }],
71
+ source: SOURCE,
72
+ dedupeKey: `non-literal-config:${callee}`,
73
+ });
74
+ }
75
+ else if (config.properties.some((property) => ts.isSpreadAssignment(property))) {
76
+ report.addManualReview({
77
+ title: `${callee} config spreads another object`,
78
+ summary: 'The spread may already carry `features`, or the removed `multiTabSync` option.',
79
+ action: `Check what the spread contributes and add \`${isClient ? CLIENT_FEATURE : AUTH_FEATURE}()\` to the resulting \`features\` array by hand.`,
80
+ locations: [{ filePath, line }],
81
+ source: SOURCE,
82
+ dedupeKey: `spread-config:${callee}`,
83
+ });
84
+ }
85
+ else if (isClient && hasFeature(config, sourceFile, CLIENT_FEATURE)) {
86
+ return;
87
+ }
88
+ else if (isClient) {
89
+ 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
+ edits.push({
99
+ start: config.getStart(sourceFile),
100
+ end: config.getEnd(),
101
+ text: withFeatureAdded(config, sourceFile, undefined, feature),
102
+ features: [feature],
103
+ });
104
+ }
105
+ else {
106
+ const syncProperty = propertyNamed(config, sourceFile, 'multiTabSync');
107
+ const alreadyAdded = hasFeature(config, sourceFile, AUTH_FEATURE);
108
+ if (alreadyAdded && !syncProperty)
109
+ return;
110
+ const value = syncProperty?.initializer;
111
+ if (value &&
112
+ value.kind !== ts.SyntaxKind.TrueKeyword &&
113
+ value.kind !== ts.SyntaxKind.FalseKeyword &&
114
+ !ts.isObjectLiteralExpression(value)) {
115
+ report.addWarning({
116
+ title: 'multiTabSync value is not a literal',
117
+ summary: 'A `multiTabSync` value that is a variable or expression was passed through as the feature config, which is wrong if it can be a boolean.',
118
+ action: 'Check the value: a config object is fine as is, a boolean has to become a present or absent feature.',
119
+ locations: [{ filePath, line }],
120
+ source: SOURCE,
121
+ dedupeKey: 'non-literal-multi-tab-sync',
122
+ });
123
+ }
124
+ const feature = alreadyAdded ? null : authFeatureCallFor(syncProperty, sourceFile);
125
+ if (feature) {
126
+ report.addFollowUp({
127
+ title: 'Auth provider kept multi-tab sync',
128
+ summary: `\`${AUTH_FEATURE}()\` was added so cross-tab login/logout and leader election keep working as before.`,
129
+ action: 'Drop it if the app is only ever open in one tab (a kiosk, an embedded webview) - that removes the BroadcastChannel sync and the Web Locks leader election from the bundle.',
130
+ locations: [{ filePath, line }],
131
+ source: SOURCE,
132
+ dedupeKey: 'auth-multi-tab-sync',
133
+ });
134
+ }
135
+ edits.push({
136
+ start: config.getStart(sourceFile),
137
+ end: config.getEnd(),
138
+ text: withFeatureAdded(config, sourceFile, syncProperty, feature),
139
+ features: feature ? [feature] : [],
140
+ });
141
+ }
142
+ }
143
+ }
144
+ ts.forEachChild(node, visit);
145
+ };
146
+ visit(sourceFile);
147
+ return edits;
148
+ };
149
+ export const migrateQueryOptInFeatures = (tree, report, scope, { reportOnly = false } = {}) => {
150
+ let migratedSites = 0;
151
+ scope.visit(tree, (filePath) => {
152
+ if (!isMigratableFile(filePath))
153
+ return;
154
+ const content = tree.read(filePath, 'utf-8');
155
+ if (!content?.includes('createQueryClient') && !content?.includes('createBearerAuthProvider'))
156
+ return;
157
+ const sourceFile = createSourceFile(content, filePath);
158
+ const edits = collectEdits(filePath, sourceFile, report);
159
+ if (edits.length === 0)
160
+ return;
161
+ migratedSites += edits.length;
162
+ if (reportOnly)
163
+ return;
164
+ let nextContent = content;
165
+ // Applied back to front so an earlier edit cannot shift a later one's offsets.
166
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
167
+ nextContent = nextContent.slice(0, edit.start) + edit.text + nextContent.slice(edit.end);
168
+ }
169
+ const importsNeeded = [...new Set(edits.flatMap((edit) => edit.features))]
170
+ .map((feature) => feature.slice(0, feature.indexOf('(')))
171
+ .sort();
172
+ if (importsNeeded.length > 0) {
173
+ nextContent = ensureImportFromQuery(nextContent, importsNeeded);
174
+ }
175
+ tree.write(filePath, nextContent);
176
+ });
177
+ return { migratedSites };
178
+ };
179
+ export default async function migrate(tree, schema) {
180
+ const reportOnly = schema.reportOnly ?? false;
181
+ console.log(`\nšŸ”„ ${reportOnly ? 'Reporting' : 'Migrating'} query clients and auth providers to opt-in features...`);
182
+ const scope = createMigrationScope(tree, schema);
183
+ console.log(` Scope: ${scope.describe()}`);
184
+ const report = new QueryOptInFeaturesMigrationReport();
185
+ const { migratedSites } = migrateQueryOptInFeatures(tree, report, scope, { reportOnly });
186
+ console.log(` ${reportOnly ? 'Found' : 'Migrated'} ${migratedSites} call site${migratedSites === 1 ? '' : 's'}.${reportOnly ? ' No files were changed.' : ''}`);
187
+ report.writeToTree(tree);
188
+ report.printSummary();
189
+ if (!schema.skipFormat && !reportOnly) {
190
+ await formatFiles(tree);
191
+ }
192
+ console.log('\nāœ… Query opt-in feature migration completed successfully!');
193
+ }
194
+ //# sourceMappingURL=migration.js.map