@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/fesm2022/ethlete-query-ngrx-toolkit.mjs +393 -0
  3. package/fesm2022/ethlete-query-ngrx-toolkit.mjs.map +1 -0
  4. package/fesm2022/ethlete-query-testing.mjs +67 -25
  5. package/fesm2022/ethlete-query-testing.mjs.map +1 -1
  6. package/fesm2022/ethlete-query.mjs +7895 -7226
  7. package/fesm2022/ethlete-query.mjs.map +1 -1
  8. package/generators/generators.json +5 -0
  9. package/generators/migrate-from-ngrx-toolkit/args-assignability.js +140 -0
  10. package/generators/migrate-from-ngrx-toolkit/consumers.js +521 -0
  11. package/generators/migrate-from-ngrx-toolkit/creators.js +301 -0
  12. package/generators/migrate-from-ngrx-toolkit/facade.js +295 -0
  13. package/generators/migrate-from-ngrx-toolkit/feature.js +513 -0
  14. package/generators/migrate-from-ngrx-toolkit/migration.js +140 -0
  15. package/generators/migrate-from-ngrx-toolkit/report.js +154 -0
  16. package/generators/migrate-from-ngrx-toolkit/schema.json +42 -0
  17. package/generators/migrate-from-ngrx-toolkit/store-removal.js +633 -0
  18. package/generators/migrate-from-ngrx-toolkit/ts-edits.js +128 -0
  19. package/generators/migrate-query-opt-in-features/migration.js +10 -9
  20. package/generators/migrate-to-query-v3/auth-provider-scaffold.js +29 -2
  21. package/generators/migrate-to-query-v3/cleanup-migration.js +163 -53
  22. package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +39 -2
  23. package/generators/migrate-to-query-v3/migration-scope.js +2 -0
  24. package/generators/migrate-to-query-v3/migration.js +5 -4
  25. package/generators/migrate-to-query-v3/module-graph.js +41 -29
  26. package/generators/migrate-to-query-v3/query-client-migration.js +209 -126
  27. package/generators/migrate-to-query-v3/rename-symbols.js +38 -11
  28. package/generators/migrate-to-query-v3/report.js +40 -7
  29. package/generators/migrate-to-query-v3/shared.js +46 -1
  30. package/generators/prep-for-query-v3/migration.js +400 -57
  31. package/migrations/query-field-default-types.md +43 -0
  32. package/migrations/search-query-field-string.md +43 -0
  33. package/migrations.json +16 -0
  34. package/package.json +5 -1
  35. package/types/ethlete-query-ngrx-toolkit.d.ts +143 -0
  36. package/types/ethlete-query-testing.d.ts +28 -11
  37. package/types/ethlete-query.d.ts +789 -584
@@ -1,9 +1,13 @@
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
+ reportWithArgsNullReturns(tree);
9
+ replaceClearQueryArgs(tree);
10
+ reportPrebuiltPackagesImportingRenamedSymbols(tree);
7
11
  if (!schema.skipFormat) {
8
12
  await formatFiles(tree);
9
13
  }
@@ -73,10 +77,19 @@ function renameConflictingSymbols(tree) {
73
77
  }
74
78
  function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
75
79
  const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
80
+ const checker = createBindingChecker(sourceFile);
76
81
  const replacements = [];
77
- // Track which symbols are imported from @ethlete/query
78
- const importedSymbols = new Set();
79
82
  const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
83
+ const renamedImports = new Map();
84
+ sourceFile.statements.forEach((statement) => {
85
+ getQueryNamedImports(statement)?.elements.forEach((element) => {
86
+ const newName = TYPE_RENAMES.get(element.name.text) || FUNCTION_RENAMES.get(element.name.text);
87
+ const symbol = checker.getSymbolAtLocation(element.name);
88
+ if (!element.propertyName && newName && symbol) {
89
+ renamedImports.set(symbol, { oldName: element.name.text, newName });
90
+ }
91
+ });
92
+ });
80
93
  function visit(node) {
81
94
  const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
82
95
  const renamedMember = namespaceMember && (TYPE_RENAMES.get(namespaceMember.text) || FUNCTION_RENAMES.get(namespaceMember.text));
@@ -88,62 +101,29 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
88
101
  oldName: namespaceMember.text,
89
102
  });
90
103
  }
91
- // Track imports from @ethlete/query
92
- if (ts.isImportDeclaration(node)) {
93
- const moduleSpecifier = node.moduleSpecifier;
94
- if (ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === '@ethlete/query') {
95
- const namedBindings = node.importClause?.namedBindings;
96
- if (namedBindings && ts.isNamedImports(namedBindings)) {
97
- namedBindings.elements.forEach((element) => {
98
- const importedName = element.propertyName?.text || element.name.text;
99
- if (TYPE_RENAMES.has(importedName) || FUNCTION_RENAMES.has(importedName)) {
100
- importedSymbols.add(importedName);
101
- }
102
- });
103
- }
104
- }
105
- }
106
- // Rename identifiers that match our rename maps
107
- if (ts.isIdentifier(node)) {
108
- const name = node.text;
109
- const parent = node.parent;
110
- // Only rename if this symbol was imported from @ethlete/query
111
- if (!importedSymbols.has(name)) {
112
- ts.forEachChild(node, visit);
113
- return;
114
- }
115
- const newName = TYPE_RENAMES.get(name) || FUNCTION_RENAMES.get(name);
116
- if (!newName) {
117
- ts.forEachChild(node, visit);
118
- return;
119
- }
120
- // Skip if it's the imported name in an import specifier (we'll handle that separately)
121
- if ((ts.isImportSpecifier(parent) && (parent.propertyName === node || parent.name === node)) ||
122
- (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
123
- (ts.isQualifiedName(parent) && parent.right === node)) {
124
- ts.forEachChild(node, visit);
125
- return;
104
+ if (ts.isIdentifier(node) && !ts.isImportSpecifier(node.parent)) {
105
+ const symbol = resolveReferenceSymbol(checker, node);
106
+ const rename = symbol && renamedImports.get(symbol);
107
+ if (rename) {
108
+ replacements.push({
109
+ start: node.getStart(sourceFile),
110
+ end: node.getEnd(),
111
+ replacement: ts.isShorthandPropertyAssignment(node.parent)
112
+ ? `${rename.oldName}: ${rename.newName}`
113
+ : rename.newName,
114
+ oldName: rename.oldName,
115
+ });
126
116
  }
127
- // Rename the identifier
128
- replacements.push({
129
- start: node.getStart(sourceFile),
130
- end: node.getEnd(),
131
- replacement: newName,
132
- oldName: name,
133
- });
134
117
  }
135
118
  ts.forEachChild(node, visit);
136
119
  }
137
120
  visit(sourceFile);
138
- // Update imports in @ethlete/query import declarations
139
121
  const importReplacements = updateImports(sourceFile);
140
122
  replacements.push(...importReplacements);
141
- // Count usages
142
123
  replacements.forEach((r) => {
143
124
  const count = symbolUsageCounts.get(r.oldName) || 0;
144
125
  symbolUsageCounts.set(r.oldName, count + 1);
145
126
  });
146
- // Apply replacements in reverse order
147
127
  let result = content;
148
128
  replacements.sort((a, b) => b.start - a.start);
149
129
  for (const { start, end, replacement } of replacements) {
@@ -151,6 +131,26 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
151
131
  }
152
132
  return result;
153
133
  }
134
+ function createBindingChecker(sourceFile) {
135
+ const options = { noLib: true, noResolve: true, types: [] };
136
+ const host = ts.createCompilerHost(options);
137
+ host.getSourceFile = (fileName) => (fileName === sourceFile.fileName ? sourceFile : undefined);
138
+ host.fileExists = (fileName) => fileName === sourceFile.fileName;
139
+ host.readFile = (fileName) => (fileName === sourceFile.fileName ? sourceFile.text : undefined);
140
+ return ts.createProgram([sourceFile.fileName], options, host).getTypeChecker();
141
+ }
142
+ function resolveReferenceSymbol(checker, identifier) {
143
+ const parent = identifier.parent;
144
+ if (ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) {
145
+ return checker.getShorthandAssignmentValueSymbol(parent);
146
+ }
147
+ if (ts.isExportSpecifier(parent)) {
148
+ return (parent.propertyName ?? parent.name) === identifier && !parent.parent.parent.moduleSpecifier
149
+ ? checker.getExportSpecifierLocalTargetSymbol(parent)
150
+ : undefined;
151
+ }
152
+ return checker.getSymbolAtLocation(identifier);
153
+ }
154
154
  function collectQueryNamespaceAliases(sourceFile) {
155
155
  const aliases = new Set();
156
156
  sourceFile.statements.forEach((statement) => {
@@ -224,6 +224,7 @@ function updateImports(sourceFile) {
224
224
  //#region ExperimentalQuery namespace replacement
225
225
  function replaceExperimentalQueryNamespace(tree) {
226
226
  const updatedFiles = [];
227
+ const removedHelpers = [];
227
228
  let totalReplacements = 0;
228
229
  visitNotIgnoredFiles(tree, '', (filePath) => {
229
230
  if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts'))
@@ -240,7 +241,13 @@ function replaceExperimentalQueryNamespace(tree) {
240
241
  updatedFiles.push(filePath);
241
242
  totalReplacements += result.replacementCount;
242
243
  }
244
+ findRemovedExperimentalQueryHelpers(createSourceFile(result.content, filePath)).forEach(({ name, line }) => {
245
+ removedHelpers.push(` - ${filePath}:${line} ${name}: ${REMOVED_EXPERIMENTAL_QUERY_HELPERS[name]}`);
246
+ });
243
247
  });
248
+ if (removedHelpers.length > 0) {
249
+ 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')}`);
250
+ }
244
251
  if (updatedFiles.length > 0) {
245
252
  console.log(`\nšŸ“¦ Replaced ExperimentalQuery namespace in ${updatedFiles.length} files (${totalReplacements} usages)`);
246
253
  }
@@ -278,14 +285,13 @@ function replaceNamespaceInFile(content, filePath) {
278
285
  if (!hasExperimentalQueryImport || !experimentalQueryAlias) {
279
286
  return applyReplacements(content, namespaceReplacements, namespaceReplacements.length);
280
287
  }
288
+ const getAliasMember = (node) => getNamespaceMember(node, new Set([experimentalQueryAlias]))?.member;
281
289
  // Second pass: collect used symbols
282
290
  function findUsages(node) {
283
291
  // Find usages: ExperimentalQuery.someSymbol or E.someSymbol (if aliased)
284
- if (ts.isPropertyAccessExpression(node) &&
285
- ts.isIdentifier(node.expression) &&
286
- node.expression.text === experimentalQueryAlias) {
287
- const symbolName = node.name.text;
288
- usedSymbols.add(symbolName);
292
+ const member = getAliasMember(node);
293
+ if (member) {
294
+ usedSymbols.add(member.text);
289
295
  }
290
296
  ts.forEachChild(node, findUsages);
291
297
  }
@@ -343,13 +349,12 @@ function replaceNamespaceInFile(content, filePath) {
343
349
  }
344
350
  }
345
351
  // Replace usages: ExperimentalQuery.someSymbol → someSymbol
346
- if (ts.isPropertyAccessExpression(node) &&
347
- ts.isIdentifier(node.expression) &&
348
- node.expression.text === experimentalQueryAlias) {
352
+ const member = getAliasMember(node);
353
+ if (member) {
349
354
  replacements.push({
350
355
  start: node.getStart(sourceFile),
351
356
  end: node.getEnd(),
352
- replacement: node.name.text,
357
+ replacement: member.text,
353
358
  });
354
359
  }
355
360
  ts.forEachChild(node, createReplacements);
@@ -383,4 +388,342 @@ function applyReplacements(content, replacements, replacementCount) {
383
388
  return { content: result, modified: replacements.length > 0, replacementCount };
384
389
  }
385
390
  //#endregion
391
+ //#region withArgs null returns
392
+ function reportWithArgsNullReturns(tree) {
393
+ const sites = [];
394
+ const uncheckedSites = [];
395
+ visitQueryFiles(tree, ['withArgs'], (filePath, content) => {
396
+ const { nullLines, uncheckedLines } = findWithArgsNullReturns(createSourceFile(content, filePath));
397
+ nullLines.forEach((line) => sites.push(` - ${filePath}:${line}`));
398
+ uncheckedLines.forEach((line) => uncheckedSites.push(` - ${filePath}:${line}`));
399
+ });
400
+ if (sites.length > 0) {
401
+ console.warn(`\nāš ļø These withArgs callbacks return null. Before v3, null kept the previous args; v3 parks the query instead (args, response and executionState become null, and polling pauses). Check each one: a null that should park the query is fine, one that relied on keeping the previous args must now return those args itself:\n${sites.join('\n')}`);
402
+ }
403
+ if (uncheckedSites.length > 0) {
404
+ console.warn(`\nāš ļø TODO: These withArgs callbacks are passed by reference from somewhere this file does not declare, so they could not be checked for a null return. Check each one by hand - v3 parks the query on null, where earlier versions kept the previous args:\n${uncheckedSites.join('\n')}`);
405
+ }
406
+ }
407
+ const isCheckableFunction = (node) => !!node &&
408
+ (ts.isArrowFunction(node) ||
409
+ ts.isFunctionExpression(node) ||
410
+ ts.isFunctionDeclaration(node) ||
411
+ ts.isMethodDeclaration(node)) &&
412
+ !!node.body;
413
+ function resolveCallbackReference(checker, source) {
414
+ const name = ts.isPropertyAccessExpression(source) ? source.name : source;
415
+ if (!ts.isIdentifier(name))
416
+ return undefined;
417
+ const declaration = checker.getSymbolAtLocation(name)?.valueDeclaration;
418
+ if (isCheckableFunction(declaration))
419
+ return declaration;
420
+ if (!declaration || !(ts.isVariableDeclaration(declaration) || ts.isPropertyDeclaration(declaration))) {
421
+ return undefined;
422
+ }
423
+ const initializer = declaration.initializer && skipOuterExpressions(declaration.initializer);
424
+ if (isCheckableFunction(initializer))
425
+ return initializer;
426
+ if (initializer &&
427
+ ts.isCallExpression(initializer) &&
428
+ ts.isIdentifier(initializer.expression) &&
429
+ initializer.expression.text === 'computed') {
430
+ const [computation] = initializer.arguments;
431
+ return isCheckableFunction(computation) ? computation : undefined;
432
+ }
433
+ return undefined;
434
+ }
435
+ function skipOuterExpressions(expression) {
436
+ return ts.isParenthesizedExpression(expression) ||
437
+ ts.isAsExpression(expression) ||
438
+ ts.isSatisfiesExpression(expression)
439
+ ? skipOuterExpressions(expression.expression)
440
+ : expression;
441
+ }
442
+ function findWithArgsNullReturns(sourceFile) {
443
+ const checker = createBindingChecker(sourceFile);
444
+ const withArgsImports = new Set();
445
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
446
+ const nullLines = [];
447
+ const uncheckedLines = [];
448
+ const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
449
+ sourceFile.statements.forEach((statement) => {
450
+ getQueryNamedImports(statement)?.elements.forEach((element) => {
451
+ const symbol = checker.getSymbolAtLocation(element.name);
452
+ if ((element.propertyName ?? element.name).text === 'withArgs' && symbol)
453
+ withArgsImports.add(symbol);
454
+ });
455
+ });
456
+ const isWithArgsCallee = (callee) => {
457
+ if (ts.isIdentifier(callee)) {
458
+ const symbol = checker.getSymbolAtLocation(callee);
459
+ return !!symbol && withArgsImports.has(symbol);
460
+ }
461
+ return getNamespaceMember(callee, namespaceAliases)?.member.text === 'withArgs';
462
+ };
463
+ function visit(node) {
464
+ if (ts.isCallExpression(node) && isWithArgsCallee(node.expression)) {
465
+ const [source] = node.arguments;
466
+ const callback = source && (isCheckableFunction(source) ? source : resolveCallbackReference(checker, source));
467
+ if (callback) {
468
+ collectNullReturns(callback).forEach((nullNode) => nullLines.push(lineOf(nullNode)));
469
+ }
470
+ else if (source) {
471
+ uncheckedLines.push(lineOf(source));
472
+ }
473
+ }
474
+ ts.forEachChild(node, visit);
475
+ }
476
+ visit(sourceFile);
477
+ return { nullLines: [...new Set(nullLines)].sort((a, b) => a - b), uncheckedLines };
478
+ }
479
+ function collectNullReturns(fn) {
480
+ if (!ts.isBlock(fn.body)) {
481
+ return findNullResults(fn.body);
482
+ }
483
+ const found = [];
484
+ function visit(node) {
485
+ if (ts.isFunctionLike(node))
486
+ return;
487
+ if (ts.isReturnStatement(node) && node.expression) {
488
+ found.push(...findNullResults(node.expression));
489
+ }
490
+ ts.forEachChild(node, visit);
491
+ }
492
+ ts.forEachChild(fn.body, visit);
493
+ return found;
494
+ }
495
+ function findNullResults(expression) {
496
+ if (expression.kind === ts.SyntaxKind.NullKeyword)
497
+ return [expression];
498
+ if (ts.isParenthesizedExpression(expression) ||
499
+ ts.isAsExpression(expression) ||
500
+ ts.isSatisfiesExpression(expression) ||
501
+ ts.isTypeAssertionExpression(expression)) {
502
+ return findNullResults(expression.expression);
503
+ }
504
+ if (ts.isConditionalExpression(expression)) {
505
+ return [...findNullResults(expression.whenTrue), ...findNullResults(expression.whenFalse)];
506
+ }
507
+ if (ts.isBinaryExpression(expression) &&
508
+ [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.AmpersandAmpersandToken].includes(expression.operatorToken.kind)) {
509
+ return findNullResults(expression.right);
510
+ }
511
+ return [];
512
+ }
513
+ //#endregion
514
+ //#region CLEAR_QUERY_ARGS replacement
515
+ const CLEAR_QUERY_ARGS_NAMES = new Set(['CLEAR_QUERY_ARGS', 'ClearQueryArgs']);
516
+ function replaceClearQueryArgs(tree) {
517
+ const updatedFiles = [];
518
+ const reExports = [];
519
+ visitQueryFiles(tree, [...CLEAR_QUERY_ARGS_NAMES], (filePath, content) => {
520
+ const result = replaceClearQueryArgsInFile(content, filePath);
521
+ if (result.content !== content) {
522
+ tree.write(filePath, result.content);
523
+ updatedFiles.push(filePath);
524
+ }
525
+ result.reExportLines.forEach((line) => reExports.push(` - ${filePath}:${line}`));
526
+ });
527
+ if (updatedFiles.length > 0) {
528
+ console.log(`\nšŸ…æļø Replaced CLEAR_QUERY_ARGS with null (the v3 park value) in ${updatedFiles.length} files: ${updatedFiles.join(', ')}`);
529
+ }
530
+ if (reExports.length > 0) {
531
+ console.warn(`\nāš ļø These files re-exported CLEAR_QUERY_ARGS or ClearQueryArgs. They now export a local \`null\` under the same name, so their importers keep compiling. Replace each import of it with \`null\`, then delete the export:\n${reExports.join('\n')}`);
532
+ }
533
+ }
534
+ function replaceClearQueryArgsInFile(content, filePath) {
535
+ const sourceFile = createSourceFile(content, filePath);
536
+ const checker = createBindingChecker(sourceFile);
537
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
538
+ const clearImports = new Map();
539
+ const replacements = [];
540
+ const reExportLines = [];
541
+ const statementEnd = (statement) => content[statement.getEnd()] === '\n' ? statement.getEnd() + 1 : statement.getEnd();
542
+ sourceFile.statements.forEach((statement) => {
543
+ const namedBindings = getQueryNamedImports(statement);
544
+ if (!namedBindings)
545
+ return;
546
+ const kept = namedBindings.elements.filter((element) => {
547
+ const importedName = (element.propertyName ?? element.name).text;
548
+ if (!CLEAR_QUERY_ARGS_NAMES.has(importedName))
549
+ return true;
550
+ const symbol = checker.getSymbolAtLocation(element.name);
551
+ if (symbol)
552
+ clearImports.set(symbol, importedName);
553
+ return false;
554
+ });
555
+ if (kept.length === namedBindings.elements.length)
556
+ return;
557
+ const importClause = statement.importClause;
558
+ if (kept.length === 0 && !importClause?.name) {
559
+ replacements.push({ start: statement.getStart(sourceFile), end: statementEnd(statement), replacement: '' });
560
+ return;
561
+ }
562
+ replacements.push({
563
+ start: namedBindings.getStart(sourceFile),
564
+ end: namedBindings.getEnd(),
565
+ replacement: `{ ${kept.map((element) => element.getText(sourceFile)).join(', ')} }`,
566
+ });
567
+ });
568
+ sourceFile.statements.forEach((statement) => {
569
+ if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
570
+ return;
571
+ }
572
+ const fromQuery = isQueryModuleSpecifier(statement.moduleSpecifier);
573
+ if (statement.moduleSpecifier && !fromQuery)
574
+ return;
575
+ const clearedName = (element) => {
576
+ if (fromQuery) {
577
+ const exportedName = (element.propertyName ?? element.name).text;
578
+ return CLEAR_QUERY_ARGS_NAMES.has(exportedName) ? exportedName : undefined;
579
+ }
580
+ const symbol = checker.getExportSpecifierLocalTargetSymbol(element);
581
+ return symbol && clearImports.get(symbol);
582
+ };
583
+ const elements = statement.exportClause.elements;
584
+ const nullExports = elements.flatMap((element) => {
585
+ const name = clearedName(element);
586
+ if (!name)
587
+ return [];
588
+ return [`export ${name === 'ClearQueryArgs' ? 'type' : 'const'} ${element.name.text} = null;`];
589
+ });
590
+ if (nullExports.length === 0)
591
+ return;
592
+ reExportLines.push(sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile)).line + 1);
593
+ const kept = elements.filter((element) => !clearedName(element));
594
+ if (kept.length === 0) {
595
+ replacements.push({
596
+ start: statement.getStart(sourceFile),
597
+ end: statement.getEnd(),
598
+ replacement: nullExports.join('\n'),
599
+ });
600
+ return;
601
+ }
602
+ replacements.push({
603
+ start: statement.exportClause.getStart(sourceFile),
604
+ end: statement.exportClause.getEnd(),
605
+ replacement: `{ ${kept.map((element) => element.getText(sourceFile)).join(', ')} }`,
606
+ });
607
+ replacements.push({
608
+ start: statement.getEnd(),
609
+ end: statement.getEnd(),
610
+ replacement: `\n${nullExports.join('\n')}`,
611
+ });
612
+ });
613
+ function visit(node) {
614
+ const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
615
+ const referencesImport = () => {
616
+ const symbol = ts.isIdentifier(node) ? resolveReferenceSymbol(checker, node) : undefined;
617
+ return !!symbol && clearImports.has(symbol);
618
+ };
619
+ if ((namespaceMember && CLEAR_QUERY_ARGS_NAMES.has(namespaceMember.text)) || referencesImport()) {
620
+ const target = ts.isTypeQueryNode(node.parent) ? node.parent : node;
621
+ const replacement = ts.isShorthandPropertyAssignment(node.parent) ? `${node.getText(sourceFile)}: null` : 'null';
622
+ replacements.push({ start: target.getStart(sourceFile), end: target.getEnd(), replacement });
623
+ return;
624
+ }
625
+ ts.forEachChild(node, visit);
626
+ }
627
+ sourceFile.statements.forEach((statement) => {
628
+ if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement))
629
+ visit(statement);
630
+ });
631
+ return { content: applyReplacements(content, replacements, replacements.length).content, reExportLines };
632
+ }
633
+ function isQueryModuleSpecifier(node) {
634
+ return !!node && ts.isStringLiteral(node) && node.text === '@ethlete/query';
635
+ }
636
+ function visitQueryFiles(tree, markers, visitor) {
637
+ visitNotIgnoredFiles(tree, '', (filePath) => {
638
+ if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts'))
639
+ return;
640
+ const content = tree.read(filePath, 'utf-8');
641
+ if (!content?.includes('@ethlete/query') || !markers.some((marker) => content.includes(marker)))
642
+ return;
643
+ visitor(filePath, content);
644
+ });
645
+ }
646
+ function getQueryNamedImports(statement) {
647
+ if (!ts.isImportDeclaration(statement) ||
648
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
649
+ statement.moduleSpecifier.text !== '@ethlete/query') {
650
+ return undefined;
651
+ }
652
+ const namedBindings = statement.importClause?.namedBindings;
653
+ return namedBindings && ts.isNamedImports(namedBindings) ? namedBindings : undefined;
654
+ }
655
+ const isRenamedSymbol = (name) => TYPE_RENAMES.has(name) || FUNCTION_RENAMES.has(name);
656
+ function reportPrebuiltPackagesImportingRenamedSymbols(tree) {
657
+ if (!tree.exists('package.json'))
658
+ return;
659
+ const packageJson = readJson(tree, 'package.json');
660
+ const packageNames = new Set([packageJson.dependencies, packageJson.devDependencies, packageJson.optionalDependencies].flatMap((deps) => Object.keys(deps ?? {})));
661
+ const hits = [];
662
+ [...packageNames]
663
+ .filter((name) => !name.startsWith('@ethlete/'))
664
+ .sort()
665
+ .forEach((packageName) => {
666
+ const importedNames = new Set();
667
+ collectDeclarationFiles(tree, joinPathFragments('node_modules', packageName)).forEach((filePath) => {
668
+ const content = tree.read(filePath, 'utf-8');
669
+ if (!content?.includes('@ethlete/query'))
670
+ return;
671
+ findRenamedSymbolsInDeclaration(createSourceFile(content, filePath)).forEach((name) => importedNames.add(name));
672
+ });
673
+ if (importedNames.size > 0) {
674
+ hits.push(` - ${packageName}: ${[...importedNames].sort().join(', ')}`);
675
+ }
676
+ });
677
+ if (hits.length > 0) {
678
+ 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')}`);
679
+ }
680
+ }
681
+ function collectDeclarationFiles(tree, dir) {
682
+ if (!tree.exists(dir))
683
+ return [];
684
+ return tree.children(dir).flatMap((child) => {
685
+ if (child === 'node_modules')
686
+ return [];
687
+ const childPath = joinPathFragments(dir, child);
688
+ if (tree.isFile(childPath)) {
689
+ return /\.d\.(c|m)?ts$/.test(child) ? [childPath] : [];
690
+ }
691
+ return collectDeclarationFiles(tree, childPath);
692
+ });
693
+ }
694
+ function findRenamedSymbolsInDeclaration(sourceFile) {
695
+ const names = new Set();
696
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
697
+ const isQueryModule = (node) => !!node && ts.isStringLiteral(node) && node.text === '@ethlete/query';
698
+ function visit(node) {
699
+ if (ts.isImportDeclaration(node) && isQueryModule(node.moduleSpecifier)) {
700
+ const namedBindings = node.importClause?.namedBindings;
701
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
702
+ namedBindings.elements.forEach((element) => names.add((element.propertyName ?? element.name).text));
703
+ }
704
+ }
705
+ if (ts.isExportDeclaration(node) && isQueryModule(node.moduleSpecifier) && node.exportClause) {
706
+ if (ts.isNamedExports(node.exportClause)) {
707
+ node.exportClause.elements.forEach((element) => names.add((element.propertyName ?? element.name).text));
708
+ }
709
+ }
710
+ if (ts.isImportTypeNode(node) &&
711
+ ts.isLiteralTypeNode(node.argument) &&
712
+ isQueryModule(node.argument.literal) &&
713
+ node.qualifier) {
714
+ names.add(getLeftmostIdentifier(node.qualifier).text);
715
+ }
716
+ const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
717
+ if (namespaceMember) {
718
+ names.add(namespaceMember.text);
719
+ }
720
+ ts.forEachChild(node, visit);
721
+ }
722
+ visit(sourceFile);
723
+ return new Set([...names].filter(isRenamedSymbol));
724
+ }
725
+ function getLeftmostIdentifier(name) {
726
+ return ts.isIdentifier(name) ? name : getLeftmostIdentifier(name.left);
727
+ }
728
+ //#endregion
386
729
  //# sourceMappingURL=migration.js.map
@@ -0,0 +1,43 @@
1
+ # `queryField<T>()` is typed by its default and reads the URL into that type
2
+
3
+ `queryField<T>()` used to be typed `T | null` whatever its default, and guessed the type of a URL
4
+ value: `?id=123` became the number `123` even in a `queryField<string>()`. Now:
5
+
6
+ - A field with a non-null `defaultValue` is typed `T`, and a reset or an unreadable URL value falls
7
+ back to the default.
8
+ - A URL value is read into the type of the default (number, boolean, string, string list). Without a
9
+ default it stays a string.
10
+ - Any other `T` needs a `queryParamToValue`: `queryField<number>()` without a default, a `Date` or an
11
+ object default.
12
+ - `searchQueryField()` and `sortQueryField()` no longer count in `activeFilterCount`, whatever their key.
13
+
14
+ The type check finds every call site that has to change.
15
+
16
+ ## Find the call sites
17
+
18
+ ```bash
19
+ grep -rnE '\bqueryField<' apps libs --include='*.ts'
20
+ ```
21
+
22
+ ## What to change
23
+
24
+ 1. `queryField<number>()` or `queryField<boolean>()` without a default: give it the `defaultValue` it
25
+ effectively had, or add `queryParamToValue: transformToNumber` (`transformToBoolean`, …) and keep
26
+ the `null` start. Both transforms are exported from `@ethlete/query`.
27
+ 2. A `Date` or object default without a `queryParamToValue`: add one (`transformToDate` for a date).
28
+ 3. A field that must hold `null` beside a non-null default - an explicit "none" - is declared
29
+ `queryField<T | null>({ defaultValue: … })`.
30
+ 4. Delete the `?? DEFAULT` on reads of a field that now has a non-null type, and any `!` or cast that
31
+ only existed to strip `null`.
32
+ 5. A field bound to a native `<input type="number">` is declared `number | null`: the input writes
33
+ `null` when cleared.
34
+ 6. A field that relied on the old guess - a `queryField<string>()` read as a number somewhere - gets
35
+ the type it was really used as.
36
+ 7. A UI that counted a search or sort field as a filter: pass `skipInFilterCount: false` to keep it.
37
+
38
+ ## When you are done
39
+
40
+ Run the type check, the lint task and the tests of every project you changed, and check in the
41
+ browser that a reload restores each list.
42
+
43
+ The query forms guide is at <https://ethlete-sdk-docs.web.app/query/query-forms>.
@@ -0,0 +1,43 @@
1
+ # `searchQueryField()` is a `string` that starts at `''`
2
+
3
+ `searchQueryField()` used to be typed `string | null` and start at `null`. It is typed `string` now and
4
+ starts at `''`, so `[formField]` binds it to `<et-input>` under `strictTemplates`. An empty search still
5
+ writes no URL param, so URLs do not change. The type check finds most call sites; a comparison with
6
+ `null` compiles and silently stops matching.
7
+
8
+ ## Find the call sites
9
+
10
+ ```bash
11
+ grep -rnE 'searchQueryField\(' apps libs --include='*.ts' -l
12
+ ```
13
+
14
+ In each file found, and in each file that reads the form's value, look for the search field's key
15
+ (`search`, `query`, `q`, …) next to `null`:
16
+
17
+ ```bash
18
+ grep -rnE '(search|query|q)\b[^;]*(null|\?\?)' apps libs --include='*.ts'
19
+ ```
20
+
21
+ ## What to change
22
+
23
+ 1. A write of `null` - `setValue({ search: null, … })`, `patchValue({ search: null })`, a seed object,
24
+ a test fixture - writes `''` instead.
25
+ 2. A check for an empty search - `search === null`, `search !== null`, `search == null`,
26
+ `search ?? ''` - becomes `search === ''`, `search !== ''`, or just `search`. `?? ''` is dead code
27
+ now; delete it.
28
+ 3. A search that goes into a request body or GraphQL variables passes `search || null` where the API
29
+ expects `null` for "no search". Query params need nothing: the client drops an empty string.
30
+ 4. A type that restates the value, such as `search: string | null` in an interface built from the
31
+ form, becomes `string`. Prefer deriving it from `ReturnType<typeof qf.value>`.
32
+ 5. A test that expects `null` for an empty search expects `''`.
33
+
34
+ ## Leave these alone
35
+
36
+ - `queryField<string>()` fields. They still start at `null`.
37
+ - The legacy `SearchQueryField` class. It is unchanged.
38
+
39
+ ## When you are done
40
+
41
+ Run the type check, the lint task and the tests of every project you changed.
42
+
43
+ The query forms guide is at <https://ethlete-sdk-docs.web.app/query/query-forms>.
package/migrations.json CHANGED
@@ -39,6 +39,22 @@
39
39
  "description": "Mark the remaining createLegacyQueryCreator wrappers as deprecated, so their call sites surface as still on v2.",
40
40
  "generator": "@ethlete/query:deprecate-legacy-queries",
41
41
  "docs": "/query/legacy"
42
+ },
43
+ {
44
+ "name": "search-query-field-string",
45
+ "version": "6.0.0-next.52",
46
+ "kind": "assisted",
47
+ "description": "searchQueryField() is typed string and starts at '' instead of null; writes and checks of null move to ''.",
48
+ "instructions": "./migrations/search-query-field-string.md",
49
+ "docs": "/query/query-forms"
50
+ },
51
+ {
52
+ "name": "query-field-default-types",
53
+ "version": "6.0.0-next.52",
54
+ "kind": "assisted",
55
+ "description": "queryField() is typed by its default and reads the URL into that type; fields without a default or with a non-primitive one need a queryParamToValue.",
56
+ "instructions": "./migrations/query-field-default-types.md",
57
+ "docs": "/query/query-forms"
42
58
  }
43
59
  ]
44
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/query",
3
- "version": "6.0.0-next.51",
3
+ "version": "6.0.0-next.53",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {
@@ -44,6 +44,10 @@
44
44
  "types": "./types/ethlete-query.d.ts",
45
45
  "default": "./fesm2022/ethlete-query.mjs"
46
46
  },
47
+ "./ngrx-toolkit": {
48
+ "types": "./types/ethlete-query-ngrx-toolkit.d.ts",
49
+ "default": "./fesm2022/ethlete-query-ngrx-toolkit.mjs"
50
+ },
47
51
  "./testing": {
48
52
  "types": "./types/ethlete-query-testing.d.ts",
49
53
  "default": "./fesm2022/ethlete-query-testing.mjs"