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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -15,6 +15,11 @@
15
15
  "schema": "./prep-for-query-v3/schema.json",
16
16
  "description": "Update code to prepare for migration to query v3"
17
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
+ },
18
23
  "migrate-query-opt-in-features": {
19
24
  "factory": "./migrate-query-opt-in-features/migration",
20
25
  "schema": "./migrate-query-opt-in-features/schema.json",
@@ -1,5 +1,12 @@
1
1
  import * as ts from 'typescript';
2
2
  import { createSourceFile, ensureAngularCoreImports, getIndentation, getLineNumber, getLineNumberFromPosition, } from './shared.js';
3
+ /** Contexts that already have an injector of their own, so the call site needs no rewrite. */
4
+ const CONTEXTS_WITH_INJECTOR = [
5
+ 'queryComputed',
6
+ 'injection-context',
7
+ 'class-field',
8
+ 'constructor',
9
+ ];
3
10
  export const migrateLegacyPrepareCalls = (tree, report, scope) => {
4
11
  console.log('\nšŸ” Checking for legacy query creator usages...');
5
12
  const allUsages = [];
@@ -98,32 +105,88 @@ const findLegacyCreatorNames = (sourceFile) => {
98
105
  visit(sourceFile);
99
106
  return names;
100
107
  };
108
+ /** Helpers that run their callback inside an injection context, so a `prepare()` in one needs nothing. */
109
+ const CONTEXT_PROVIDING_CALLEES = new Set([
110
+ 'runInInjectionContext',
111
+ 'queryComputed',
112
+ 'queryComputedTillTruthy',
113
+ 'queryArrayComputed',
114
+ ]);
115
+ /**
116
+ * Array methods, which call back synchronously in the caller's own context. Matched only on a property
117
+ * access (`items.map(…)`) - the bare-identifier form is an RxJS operator, whose callback runs later.
118
+ */
119
+ const TRANSPARENT_ARRAY_METHODS = new Set([
120
+ 'map',
121
+ 'flatMap',
122
+ 'filter',
123
+ 'forEach',
124
+ 'find',
125
+ 'findLast',
126
+ 'findIndex',
127
+ 'some',
128
+ 'every',
129
+ 'reduce',
130
+ 'reduceRight',
131
+ 'sort',
132
+ 'flat',
133
+ ]);
134
+ const isCallbackFunction = (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node);
135
+ /** The call a function literal is being passed to, if it is an argument rather than a value. */
136
+ const getCallbackHost = (node) => {
137
+ const parent = node.parent;
138
+ if (!ts.isCallExpression(parent) || !parent.arguments.some((argument) => argument === node)) {
139
+ return undefined;
140
+ }
141
+ return parent;
142
+ };
143
+ const isContextProvidingCallback = (node) => {
144
+ const host = getCallbackHost(node);
145
+ if (!host) {
146
+ return false;
147
+ }
148
+ if (ts.isIdentifier(host.expression)) {
149
+ return CONTEXT_PROVIDING_CALLEES.has(host.expression.text);
150
+ }
151
+ return ts.isPropertyAccessExpression(host.expression) && host.expression.name.text === 'runInContext';
152
+ };
153
+ const isTransparentCallback = (node) => {
154
+ const host = getCallbackHost(node);
155
+ return (!!host && ts.isPropertyAccessExpression(host.expression) && TRANSPARENT_ARRAY_METHODS.has(host.expression.name.text));
156
+ };
157
+ /**
158
+ * Classifies the **innermost** function boundary the call sits behind, not the first structural ancestor.
159
+ * The difference is the whole point: `effect(() => prepare())` in a constructor and
160
+ * `computed(() => prepare())` at a class field both run long after the context that created them, so they
161
+ * need an injector just like a plain method does - reading the constructor or the field as the answer
162
+ * skipped exactly the call sites that go on to throw ET950.
163
+ */
101
164
  const determineUsageContext = (node) => {
102
165
  let current = node;
103
166
  while (current) {
104
- if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
105
- const parent = current.parent;
106
- if (ts.isCallExpression(parent) &&
107
- ts.isIdentifier(parent.expression) &&
108
- parent.expression.text === 'queryComputed') {
109
- return 'queryComputed';
167
+ if (isCallbackFunction(current)) {
168
+ if (isContextProvidingCallback(current)) {
169
+ const host = getCallbackHost(current);
170
+ const callee = host && ts.isIdentifier(host.expression) ? host.expression.text : undefined;
171
+ return callee?.startsWith('query') ? 'queryComputed' : 'injection-context';
172
+ }
173
+ if (!isTransparentCallback(current)) {
174
+ return isInsideClass(current) ? 'method' : 'function';
110
175
  }
111
176
  }
177
+ // Reached without crossing a callback: the call really does run while the class is being built, which
178
+ // is an injection context.
112
179
  if (ts.isConstructorDeclaration(current)) {
113
180
  return 'constructor';
114
181
  }
115
- if (ts.isMethodDeclaration(current)) {
182
+ if (ts.isMethodDeclaration(current) ||
183
+ ts.isGetAccessorDeclaration(current) ||
184
+ ts.isSetAccessorDeclaration(current)) {
116
185
  return 'method';
117
186
  }
118
187
  if (ts.isPropertyDeclaration(current)) {
119
188
  return 'class-field';
120
189
  }
121
- if (ts.isFunctionDeclaration(current)) {
122
- return 'function';
123
- }
124
- if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && !isInsideClass(current)) {
125
- return 'function';
126
- }
127
190
  current = current.parent;
128
191
  }
129
192
  return 'unknown';
@@ -177,7 +240,7 @@ const findOrCreateInjectorMembers = (tree, usages) => {
177
240
  const classInjectors = new Map();
178
241
  const usagesByFile = new Map();
179
242
  usages.forEach((usage) => {
180
- if (['queryComputed', 'class-field', 'constructor'].includes(usage.context)) {
243
+ if (CONTEXTS_WITH_INJECTOR.includes(usage.context)) {
181
244
  return;
182
245
  }
183
246
  if (!usagesByFile.has(usage.filePath)) {
@@ -342,7 +405,7 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
342
405
  return;
343
406
  }
344
407
  const context = determineUsageContext(node);
345
- if (['queryComputed', 'constructor', 'class-field'].includes(context)) {
408
+ if (CONTEXTS_WITH_INJECTOR.includes(context)) {
346
409
  ts.forEachChild(node, visit);
347
410
  return;
348
411
  }
@@ -535,7 +598,7 @@ const transformSinglePrepareCall = (callNode, injectorReference, sourceFile, pol
535
598
  }
536
599
  nextArgumentProperties.push(`injector: ${injectorReference}`);
537
600
  const queryVariableName = findQueryVariableNameForPrepareCall(callNode, sourceFile);
538
- const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo);
601
+ const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo, isDiscardedPrepareCall(callNode));
539
602
  if (existingConfig && ts.isObjectLiteralExpression(existingConfig.initializer)) {
540
603
  const configProperties = [];
541
604
  existingConfig.initializer.properties.forEach((property) => {
@@ -763,9 +826,39 @@ const detectPollingInTemplate = (content, filePath, pollingInfo) => {
763
826
  }
764
827
  }
765
828
  };
766
- const shouldAddDestroyOnResponse = (queryVariableName, pollingInfo) => {
829
+ /**
830
+ * Whether the prepared query is thrown away - `legacyGetUsers.prepare().execute()` as a statement, with
831
+ * nothing holding on to it. Chained `.poll()` disqualifies it: that query is meant to keep running.
832
+ */
833
+ const isDiscardedPrepareCall = (callNode) => {
834
+ let current = callNode;
835
+ while (current.parent) {
836
+ const parent = current.parent;
837
+ if (ts.isParenthesizedExpression(parent) || ts.isAwaitExpression(parent) || ts.isNonNullExpression(parent)) {
838
+ current = parent;
839
+ continue;
840
+ }
841
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === current) {
842
+ if (parent.name.text === 'poll') {
843
+ return false;
844
+ }
845
+ current = parent;
846
+ continue;
847
+ }
848
+ // Only a chained call keeps it discarded; being an *argument* means something else receives the query.
849
+ if (ts.isCallExpression(parent) && parent.expression === current) {
850
+ current = parent;
851
+ continue;
852
+ }
853
+ return ts.isExpressionStatement(parent);
854
+ }
855
+ return false;
856
+ };
857
+ const shouldAddDestroyOnResponse = (queryVariableName, pollingInfo, isDiscarded) => {
767
858
  if (!queryVariableName) {
768
- return false;
859
+ // A discarded query is the one case where nothing else can clean it up: no variable for a container
860
+ // to hold, no caller to destroy it. That is exactly what `destroyOnResponse` is for.
861
+ return isDiscarded;
769
862
  }
770
863
  return !pollingInfo.get(queryVariableName)?.hasPolling;
771
864
  };
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
2
2
  import { collectV2BearerAuthConfigs, renderAuthProviderBody } from './auth-provider-scaffold.js';
3
3
  import { createModuleGraph } from './module-graph.js';
4
4
  import { pruneUnusedNamedImports, renameImportedReferences } from './rename-symbols.js';
5
- import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, } from './shared.js';
5
+ import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, legacyQueryDeprecationTag, } from './shared.js';
6
6
  const LEGACY_HTTP_METHOD = {
7
7
  GET: 'get',
8
8
  POST: 'post',
@@ -443,7 +443,7 @@ const getTypeParameter = (creator) => {
443
443
  const generateLegacyWrapper = (creator) => {
444
444
  // `name` is what lets a `prepare()` called outside an injection context name itself in the error
445
445
  // instead of surfacing as a bare NG0203.
446
- return `export const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
446
+ return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\nexport const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
447
447
  };
448
448
  const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, creatorToClient, }) => {
449
449
  const createdProviders = [];
@@ -96,4 +96,47 @@ export const getLineNumber = (node, sourceFile) => {
96
96
  return line + 1;
97
97
  };
98
98
  export const getLineNumberFromPosition = (content, position) => content.slice(0, position).split('\n').length;
99
+ const MIGRATION_GUIDE_URL = 'https://ethlete-sdk-docs.web.app/query/migrating-from-v2';
100
+ /**
101
+ * The tag every `createLegacyQueryCreator(…)` wrapper carries. The wrapper is a migration
102
+ * scaffold, so marking it is what turns "which call sites are still on v2" from a grep into
103
+ * strikethrough in the editor.
104
+ */
105
+ export const legacyQueryDeprecationTag = (creatorName) => `@deprecated Legacy (v2) query wrapper. Migrate the call sites to ${creatorName ? `\`${creatorName}\`` : 'the query creator it wraps'} and delete this wrapper - see ${MIGRATION_GUIDE_URL}.`;
106
+ /**
107
+ * Adds a tag to `node`'s JSDoc, creating the block when there is none. Returns `null` when the
108
+ * tag is already there, so a caller can tell "nothing to do" from "rewritten".
109
+ *
110
+ * Leading `//` comments stay glued to the node - an `eslint-disable-next-line` only works from
111
+ * the line directly above.
112
+ */
113
+ export const addJsDocTag = (content, sourceFile, node, tag) => {
114
+ const jsDocs = node.jsDoc ?? [];
115
+ const existing = jsDocs[jsDocs.length - 1];
116
+ const tagName = /^@\w+/.exec(tag)?.[0];
117
+ if (existing && tagName && new RegExp(`${tagName}\\b`).test(existing.getText())) {
118
+ return null;
119
+ }
120
+ if (existing) {
121
+ const start = existing.getStart(sourceFile);
122
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
123
+ const text = existing.getText();
124
+ const body = text.slice(0, text.lastIndexOf('*/')).replace(/\s+$/, '');
125
+ // A one-line `/** … */` has to become a block before a tag line can be appended to it.
126
+ const opened = body.includes('\n') ? body : `/**\n${indentation} * ${body.slice(3).trim()}`;
127
+ return (content.slice(0, start) +
128
+ `${opened}\n${indentation} *\n${indentation} * ${tag}\n${indentation} */` +
129
+ content.slice(existing.getEnd()));
130
+ }
131
+ let start = node.getStart(sourceFile, false);
132
+ for (const comment of (ts.getLeadingCommentRanges(content, node.getFullStart()) ?? []).reverse()) {
133
+ if (comment.kind !== ts.SyntaxKind.SingleLineCommentTrivia)
134
+ break;
135
+ if (!/^\n[ \t]*$/.test(content.slice(comment.end, start)))
136
+ break;
137
+ start = comment.pos;
138
+ }
139
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
140
+ return (content.slice(0, start) + `/**\n${indentation} * ${tag}\n${indentation} */\n${indentation}` + content.slice(start));
141
+ };
99
142
  //# sourceMappingURL=shared.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/query",
3
- "version": "6.0.0-next.23",
3
+ "version": "6.0.0-next.24",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {