@ethlete/query 6.0.0-next.45 → 6.0.0-next.48

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.
@@ -115,17 +115,25 @@ const migrateQueryClientToConfig = ({ content, filePath, renamesOut, report }) =
115
115
  const sourceFile = createSourceFile(content, filePath);
116
116
  const replacements = [];
117
117
  const variableRenames = new Map();
118
+ let hasUnmigratedClient = false;
118
119
  const visit = (node) => {
119
- if (ts.isNewExpression(node) &&
120
- ts.isIdentifier(node.expression) &&
121
- node.expression.text === 'V2QueryClient' &&
122
- node.arguments &&
123
- node.arguments.length > 0) {
124
- const configArgument = node.arguments[0];
125
- if (ts.isObjectLiteralExpression(configArgument)) {
120
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'V2QueryClient') {
121
+ const configArgument = node.arguments?.[0];
122
+ if (!configArgument || !ts.isObjectLiteralExpression(configArgument)) {
123
+ hasUnmigratedClient = true;
124
+ report.addManualReview({
125
+ title: 'Migrate a V2QueryClient whose config is not an object literal',
126
+ summary: `\`${node.getText(sourceFile)}\` passes its config indirectly, so the migration cannot read it and left the client on v2.`,
127
+ action: 'Inline the config object and re-run the migration, or rewrite the client by hand as `createQueryClient({ baseUrl, name })` with provider aliases and creators.',
128
+ locations: [{ filePath, line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 }],
129
+ source: 'query-client-migration',
130
+ dedupeKey: `unmigrated-client:${filePath}:${node.getStart(sourceFile)}`,
131
+ });
132
+ }
133
+ else {
126
134
  const oldVariableName = getVariableNameForQueryClient(node);
127
- const newVariableName = ensureConfigSuffix(oldVariableName);
128
- if (oldVariableName !== newVariableName) {
135
+ const newVariableName = oldVariableName && ensureConfigSuffix(oldVariableName);
136
+ if (oldVariableName && newVariableName && oldVariableName !== newVariableName) {
129
137
  const collisionRegex = new RegExp(`\\b(?:const|let|var)\\s+${newVariableName}\\b`);
130
138
  if (collisionRegex.test(content)) {
131
139
  report.addWarning({
@@ -158,16 +166,20 @@ const migrateQueryClientToConfig = ({ content, filePath, renamesOut, report }) =
158
166
  replacements.forEach(({ start, end, replacement }) => {
159
167
  result = result.slice(0, start) + replacement + result.slice(end);
160
168
  });
161
- result = removeQueryClientImport(result);
169
+ result = removeQueryClientImport(result, hasUnmigratedClient);
162
170
  return renameVariables(result, variableRenames);
163
171
  };
164
- const removeQueryClientImport = (content) => {
172
+ const removeQueryClientImport = (content, keepLegacyClient) => {
165
173
  const sourceFile = createSourceFile(content);
166
174
  let queryImportNode;
167
175
  ts.forEachChild(sourceFile, (node) => {
168
- if (ts.isImportDeclaration(node) &&
176
+ if (!queryImportNode &&
177
+ ts.isImportDeclaration(node) &&
169
178
  ts.isStringLiteral(node.moduleSpecifier) &&
170
- node.moduleSpecifier.text === '@ethlete/query') {
179
+ node.moduleSpecifier.text === '@ethlete/query' &&
180
+ node.importClause?.namedBindings &&
181
+ ts.isNamedImports(node.importClause.namedBindings) &&
182
+ node.importClause.namedBindings.elements.some((element) => element.name.text === 'V2QueryClient')) {
171
183
  queryImportNode = node;
172
184
  }
173
185
  });
@@ -183,10 +195,10 @@ const removeQueryClientImport = (content) => {
183
195
  return ensureImportFromQuery(content, requiredImports);
184
196
  }
185
197
  const existingElements = queryImportNode.importClause.namedBindings.elements.filter((element) => {
186
- return element.name.text !== 'V2QueryClient';
198
+ return keepLegacyClient || element.name.text !== 'V2QueryClient';
187
199
  });
188
200
  const nextImports = new Set(requiredImports);
189
- existingElements.forEach((element) => nextImports.add(element.name.text));
201
+ existingElements.forEach((element) => nextImports.add(element.getText(sourceFile)));
190
202
  const nextImportStatement = `import { ${Array.from(nextImports).sort().join(', ')} } from '@ethlete/query';`;
191
203
  return (content.slice(0, queryImportNode.getStart(sourceFile)) +
192
204
  nextImportStatement +
@@ -267,14 +279,37 @@ const reportDroppedClientOptions = ({ configArgument, sourceFile, filePath, repo
267
279
  dedupeKey: `dropped-client-option:${filePath}:${optionPath}`,
268
280
  });
269
281
  };
282
+ const raiseSpread = (spread) => {
283
+ const text = spread.getText(sourceFile);
284
+ report.addWarning({
285
+ title: `Reconfigure spread query client options "${text}"`,
286
+ summary: `The v2 client config spreads \`${text}\`. The migration cannot see which options it holds, so none of them were carried over to \`createQueryClient\`.`,
287
+ action: 'Inline the spread options that are still needed (`baseRoute` becomes `baseUrl`) and re-check the result.',
288
+ locations: [{ filePath, line: sourceFile.getLineAndCharacterOfPosition(spread.getStart(sourceFile)).line + 1 }],
289
+ source: 'query-client-migration',
290
+ dedupeKey: `dropped-client-spread:${filePath}:${spread.getStart(sourceFile)}`,
291
+ });
292
+ };
270
293
  configArgument.properties.forEach((property) => {
271
- if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
294
+ if (ts.isSpreadAssignment(property)) {
295
+ raiseSpread(property);
296
+ return;
297
+ }
298
+ if ((!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) ||
299
+ !ts.isIdentifier(property.name)) {
272
300
  return;
273
301
  }
274
302
  const name = property.name.text;
275
- if ((name === 'request' || name === 'logging') && ts.isObjectLiteralExpression(property.initializer)) {
303
+ if ((name === 'request' || name === 'logging') &&
304
+ ts.isPropertyAssignment(property) &&
305
+ ts.isObjectLiteralExpression(property.initializer)) {
276
306
  property.initializer.properties.forEach((nested) => {
277
- if (!ts.isPropertyAssignment(nested) || !ts.isIdentifier(nested.name)) {
307
+ if (ts.isSpreadAssignment(nested)) {
308
+ raiseSpread(nested);
309
+ return;
310
+ }
311
+ if ((!ts.isPropertyAssignment(nested) && !ts.isShorthandPropertyAssignment(nested)) ||
312
+ !ts.isIdentifier(nested.name)) {
278
313
  return;
279
314
  }
280
315
  if (name === 'request' && ['queryParams', 'cacheAdapter', 'retryFn'].includes(nested.name.text)) {
@@ -289,40 +324,42 @@ const reportDroppedClientOptions = ({ configArgument, sourceFile, filePath, repo
289
324
  }
290
325
  });
291
326
  };
327
+ const getPropertyValueText = (property, sourceFile) => {
328
+ if (ts.isShorthandPropertyAssignment(property)) {
329
+ return property.name.text;
330
+ }
331
+ return ts.isPropertyAssignment(property) ? property.initializer.getText(sourceFile) : undefined;
332
+ };
333
+ const findProperty = (objectLiteral, name) => objectLiteral.properties.find((property) => {
334
+ return ((ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) &&
335
+ ts.isIdentifier(property.name) &&
336
+ property.name.text === name);
337
+ });
292
338
  const migrateConfigObject = (configArgument, node, sourceFile) => {
293
- const variableName = getVariableNameForQueryClient(node);
339
+ const variableName = getVariableNameForQueryClient(node) ?? 'client';
294
340
  const nextConfig = [];
295
- const baseRouteProperty = configArgument.properties.find((property) => {
296
- return ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'baseRoute';
297
- });
298
- if (baseRouteProperty && ts.isPropertyAssignment(baseRouteProperty)) {
299
- const baseRouteValue = baseRouteProperty.initializer
300
- .getText(sourceFile)
301
- .replace('as `https://${string}`', '')
302
- .trim();
303
- nextConfig.push(`baseUrl: ${baseRouteValue}`);
341
+ const baseRouteProperty = findProperty(configArgument, 'baseRoute');
342
+ const baseRouteText = baseRouteProperty && getPropertyValueText(baseRouteProperty, sourceFile);
343
+ if (baseRouteText) {
344
+ nextConfig.push(`baseUrl: ${baseRouteText.replace('as `https://${string}`', '').trim()}`);
304
345
  }
305
346
  nextConfig.push(`name: '${variableName}'`);
306
- const requestProperty = configArgument.properties.find((property) => {
307
- return ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'request';
308
- });
347
+ const requestProperty = findProperty(configArgument, 'request');
309
348
  if (requestProperty &&
310
349
  ts.isPropertyAssignment(requestProperty) &&
311
350
  ts.isObjectLiteralExpression(requestProperty.initializer)) {
312
- requestProperty.initializer.properties.forEach((property) => {
313
- if (!ts.isPropertyAssignment(property) || !ts.isIdentifier(property.name)) {
314
- return;
315
- }
316
- if (property.name.text === 'queryParams') {
317
- nextConfig.push(`queryString: ${property.initializer.getText(sourceFile)}`);
318
- }
319
- if (property.name.text === 'cacheAdapter') {
320
- nextConfig.push(`cacheAdapter: ${property.initializer.getText(sourceFile)}`);
351
+ const requestConfig = requestProperty.initializer;
352
+ for (const [from, to] of [
353
+ ['queryParams', 'queryString'],
354
+ ['cacheAdapter', 'cacheAdapter'],
355
+ ['retryFn', 'retryFn'],
356
+ ]) {
357
+ const property = findProperty(requestConfig, from);
358
+ const valueText = property && getPropertyValueText(property, sourceFile);
359
+ if (valueText) {
360
+ nextConfig.push(`${to}: ${valueText}`);
321
361
  }
322
- if (property.name.text === 'retryFn') {
323
- nextConfig.push(`retryFn: ${property.initializer.getText(sourceFile)}`);
324
- }
325
- });
362
+ }
326
363
  }
327
364
  return `createQueryClient({\n ${nextConfig.join(',\n ')}\n})`;
328
365
  };
@@ -334,7 +371,7 @@ const getVariableNameForQueryClient = (node) => {
334
371
  }
335
372
  parent = parent.parent;
336
373
  }
337
- return 'client';
374
+ return undefined;
338
375
  };
339
376
  const extractClientConfigNames = (content) => {
340
377
  const sourceFile = createSourceFile(content);
@@ -433,6 +470,19 @@ const generateCreatorsForConfig = (configName) => {
433
470
  `export const ${creatorBaseName}Delete = createDeleteQuery(${configVariableName});`,
434
471
  ].join('\n');
435
472
  };
473
+ const declaresName = (declarations, name) => declarations.some((declaration) => declaration.name && ts.isIdentifier(declaration.name) && declaration.name.text === name);
474
+ const isShadowedByLocalDeclaration = (identifier) => {
475
+ for (let scope = identifier.parent; scope && !ts.isSourceFile(scope); scope = scope.parent) {
476
+ if (ts.isFunctionLike(scope) && declaresName(scope.parameters, identifier.text)) {
477
+ return true;
478
+ }
479
+ if (ts.isBlock(scope) &&
480
+ scope.statements.some((statement) => ts.isVariableStatement(statement) && declaresName(statement.declarationList.declarations, identifier.text))) {
481
+ return true;
482
+ }
483
+ }
484
+ return false;
485
+ };
436
486
  const updateImportsInFile = (content, renames) => {
437
487
  const sourceFile = createSourceFile(content);
438
488
  const replacements = [];
@@ -443,7 +493,7 @@ const updateImportsInFile = (content, renames) => {
443
493
  const nextElements = [];
444
494
  let hasChanges = false;
445
495
  node.importClause.namedBindings.elements.forEach((element) => {
446
- const nextName = renames.get(element.name.text);
496
+ const nextName = renames.get(element.propertyName?.text ?? element.name.text);
447
497
  if (!nextName) {
448
498
  nextElements.push(element.getText(sourceFile));
449
499
  return;
@@ -494,7 +544,7 @@ const updateImportsInFile = (content, renames) => {
494
544
  ts.forEachChild(node, visit);
495
545
  return;
496
546
  }
497
- if (ts.isPropertyAssignment(parent) && parent.name === node) {
547
+ if ((ts.isPropertyAssignment(parent) && parent.name === node) || isShadowedByLocalDeclaration(node)) {
498
548
  ts.forEachChild(node, visit);
499
549
  return;
500
550
  }
@@ -17,8 +17,6 @@ const isNamePosition = (node) => {
17
17
  // Object literal / type member keys, enum members, JSX-ish attribute names.
18
18
  if (ts.isPropertyAssignment(parent) && parent.name === node)
19
19
  return true;
20
- if (ts.isShorthandPropertyAssignment(parent) && parent.name === node)
21
- return true;
22
20
  if (ts.isPropertySignature(parent) && parent.name === node)
23
21
  return true;
24
22
  if (ts.isMethodSignature(parent) && parent.name === node)
@@ -57,12 +55,24 @@ const isNamePosition = (node) => {
57
55
  if (ts.isLabeledStatement(parent) && parent.label === node)
58
56
  return true;
59
57
  // Import / export clauses are rewritten by the caller, never by the reference pass.
60
- if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent))
58
+ if (ts.isImportSpecifier(parent))
59
+ return true;
60
+ if (ts.isExportSpecifier(parent) &&
61
+ (parent.parent.parent.moduleSpecifier || (parent.propertyName && parent.name === node))) {
61
62
  return true;
63
+ }
62
64
  if (ts.isImportClause(parent) || ts.isNamespaceImport(parent))
63
65
  return true;
64
66
  return false;
65
67
  };
68
+ const renamedReference = (node, nextName) => {
69
+ const parent = node.parent;
70
+ if (ts.isShorthandPropertyAssignment(parent))
71
+ return `${node.text}: ${nextName}`;
72
+ if (ts.isExportSpecifier(parent) && !parent.propertyName)
73
+ return `${nextName} as ${node.text}`;
74
+ return nextName;
75
+ };
66
76
  /** Every name the file declares itself - a local by the same name means the import is shadowed. */
67
77
  const collectLocalDeclarations = (sourceFile) => {
68
78
  const declared = new Set();
@@ -115,7 +125,11 @@ export const renameImportedReferences = (content, renames) => {
115
125
  if (ts.isIdentifier(node)) {
116
126
  const nextName = applicable.get(node.text);
117
127
  if (nextName && !isNamePosition(node)) {
118
- replacements.push({ start: node.getStart(sourceFile), end: node.getEnd(), replacement: nextName });
128
+ replacements.push({
129
+ start: node.getStart(sourceFile),
130
+ end: node.getEnd(),
131
+ replacement: renamedReference(node, nextName),
132
+ });
119
133
  }
120
134
  }
121
135
  ts.forEachChild(node, visit);
@@ -136,7 +150,7 @@ export const renameImportedReferences = (content, renames) => {
136
150
  * the same generator invocation so they land that way. Identifiers inside template strings count as
137
151
  * usage too - a component referenced only from an inline template must not be pruned.
138
152
  */
139
- export const pruneUnusedNamedImports = (content, moduleSpecifiers) => {
153
+ export const pruneUnusedNamedImports = (content) => {
140
154
  const sourceFile = createSourceFile(content);
141
155
  const used = new Set();
142
156
  const addWordsFrom = (text) => {
@@ -162,7 +176,6 @@ export const pruneUnusedNamedImports = (content, moduleSpecifiers) => {
162
176
  for (const node of sourceFile.statements) {
163
177
  if (!ts.isImportDeclaration(node) ||
164
178
  !ts.isStringLiteral(node.moduleSpecifier) ||
165
- (moduleSpecifiers !== 'all' && !moduleSpecifiers.includes(node.moduleSpecifier.text)) ||
166
179
  !node.importClause?.namedBindings ||
167
180
  !ts.isNamedImports(node.importClause.namedBindings)) {
168
181
  continue;
@@ -17,9 +17,13 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
17
17
  const sourceFile = createSourceFile(content);
18
18
  let importNode;
19
19
  ts.forEachChild(sourceFile, (node) => {
20
- if (ts.isImportDeclaration(node) &&
20
+ if (!importNode &&
21
+ ts.isImportDeclaration(node) &&
21
22
  ts.isStringLiteral(node.moduleSpecifier) &&
22
- node.moduleSpecifier.text === moduleSpecifier) {
23
+ node.moduleSpecifier.text === moduleSpecifier &&
24
+ !node.importClause?.isTypeOnly &&
25
+ node.importClause?.namedBindings &&
26
+ ts.isNamedImports(node.importClause.namedBindings)) {
23
27
  importNode = node;
24
28
  }
25
29
  });
@@ -76,7 +76,18 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
76
76
  const replacements = [];
77
77
  // Track which symbols are imported from @ethlete/query
78
78
  const importedSymbols = new Set();
79
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
79
80
  function visit(node) {
81
+ const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
82
+ const renamedMember = namespaceMember && (TYPE_RENAMES.get(namespaceMember.text) || FUNCTION_RENAMES.get(namespaceMember.text));
83
+ if (namespaceMember && renamedMember) {
84
+ replacements.push({
85
+ start: namespaceMember.getStart(sourceFile),
86
+ end: namespaceMember.getEnd(),
87
+ replacement: renamedMember,
88
+ oldName: namespaceMember.text,
89
+ });
90
+ }
80
91
  // Track imports from @ethlete/query
81
92
  if (ts.isImportDeclaration(node)) {
82
93
  const moduleSpecifier = node.moduleSpecifier;
@@ -107,7 +118,9 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
107
118
  return;
108
119
  }
109
120
  // Skip if it's the imported name in an import specifier (we'll handle that separately)
110
- if (ts.isImportSpecifier(parent) && (parent.propertyName === node || parent.name === node)) {
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)) {
111
124
  ts.forEachChild(node, visit);
112
125
  return;
113
126
  }
@@ -138,6 +151,33 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
138
151
  }
139
152
  return result;
140
153
  }
154
+ function collectQueryNamespaceAliases(sourceFile) {
155
+ const aliases = new Set();
156
+ sourceFile.statements.forEach((statement) => {
157
+ if (!ts.isImportDeclaration(statement)) {
158
+ return;
159
+ }
160
+ const namedBindings = statement.importClause?.namedBindings;
161
+ if (namedBindings &&
162
+ ts.isNamespaceImport(namedBindings) &&
163
+ ts.isStringLiteral(statement.moduleSpecifier) &&
164
+ statement.moduleSpecifier.text === '@ethlete/query') {
165
+ aliases.add(namedBindings.name.text);
166
+ }
167
+ });
168
+ return aliases;
169
+ }
170
+ function getNamespaceMember(node, namespaceAliases) {
171
+ const [namespace, member] = ts.isPropertyAccessExpression(node)
172
+ ? [node.expression, node.name]
173
+ : ts.isQualifiedName(node)
174
+ ? [node.left, node.right]
175
+ : [];
176
+ if (!namespace || !member || !ts.isIdentifier(namespace) || !ts.isIdentifier(member)) {
177
+ return undefined;
178
+ }
179
+ return namespaceAliases.has(namespace.text) ? { namespace, member } : undefined;
180
+ }
141
181
  function updateImports(sourceFile) {
142
182
  const replacements = [];
143
183
  function visit(node) {
@@ -232,9 +272,11 @@ function replaceNamespaceInFile(content, filePath) {
232
272
  ts.forEachChild(node, collectImport);
233
273
  }
234
274
  collectImport(sourceFile);
275
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
276
+ const namespaceReplacements = collectNamespacedExperimentalQueryReplacements(sourceFile, namespaceAliases);
235
277
  // If no ExperimentalQuery import found, return unchanged
236
278
  if (!hasExperimentalQueryImport || !experimentalQueryAlias) {
237
- return { content, modified: false, replacementCount: 0 };
279
+ return applyReplacements(content, namespaceReplacements, namespaceReplacements.length);
238
280
  }
239
281
  // Second pass: collect used symbols
240
282
  function findUsages(node) {
@@ -313,17 +355,32 @@ function replaceNamespaceInFile(content, filePath) {
313
355
  ts.forEachChild(node, createReplacements);
314
356
  }
315
357
  createReplacements(sourceFile);
316
- // Apply replacements in reverse order
358
+ // Subtract 1 for the import replacement
359
+ return applyReplacements(content, [...replacements, ...namespaceReplacements], replacements.length - 1 + namespaceReplacements.length);
360
+ }
361
+ function collectNamespacedExperimentalQueryReplacements(sourceFile, namespaceAliases) {
362
+ const replacements = [];
363
+ if (namespaceAliases.size === 0) {
364
+ return replacements;
365
+ }
366
+ function visit(node) {
367
+ const access = getNamespaceMember(node, namespaceAliases);
368
+ if (access?.member.text === 'ExperimentalQuery') {
369
+ replacements.push({ start: node.getStart(sourceFile), end: node.getEnd(), replacement: access.namespace.text });
370
+ }
371
+ ts.forEachChild(node, visit);
372
+ }
373
+ visit(sourceFile);
374
+ return replacements;
375
+ }
376
+ function applyReplacements(content, replacements, replacementCount) {
317
377
  let result = content;
318
- replacements.sort((a, b) => b.start - a.start);
319
- for (const { start, end, replacement } of replacements) {
378
+ [...replacements]
379
+ .sort((a, b) => b.start - a.start)
380
+ .forEach(({ start, end, replacement }) => {
320
381
  result = result.slice(0, start) + replacement + result.slice(end);
321
- }
322
- return {
323
- content: result,
324
- modified: replacements.length > 0,
325
- replacementCount: replacements.length - 1, // Subtract 1 for the import replacement
326
- };
382
+ });
383
+ return { content: result, modified: replacements.length > 0, replacementCount };
327
384
  }
328
385
  //#endregion
329
386
  //# sourceMappingURL=migration.js.map
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@ethlete/query",
3
- "version": "6.0.0-next.45",
3
+ "version": "6.0.0-next.48",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {
7
7
  "@analogjs/vite-plugin-angular": "2.6.3",
8
- "@angular/common": "22.0.7",
9
- "@angular/core": "22.0.7",
10
- "@angular/forms": "22.0.7",
11
- "@angular/router": "22.0.7",
8
+ "@angular/common": "22.1.6",
9
+ "@angular/core": "22.1.6",
10
+ "@angular/forms": "22.1.6",
11
+ "@angular/router": "22.1.6",
12
12
  "@ethlete/core": "^5.0.0-beta.11",
13
13
  "@ethlete/types": "^2.0.0-beta.4",
14
14
  "@nx/devkit": "23.1.0",
@@ -15,6 +15,11 @@ type QueryTestSetup = {
15
15
  createPut: ReturnType<typeof createPutQuery>;
16
16
  createPatch: ReturnType<typeof createPatchQuery>;
17
17
  createDelete: ReturnType<typeof createDeleteQuery>;
18
+ /**
19
+ * Puts back the `console.warn` / `console.error` that were installed before the first
20
+ * {@link setupQueryTest} call. Idempotent, and safe to call from any of the setups a file created.
21
+ */
22
+ restoreConsole: () => void;
18
23
  };
19
24
  type QueryTestSetupConfig = {
20
25
  baseUrl?: string;
@@ -235,6 +240,8 @@ type FakeQueryPersistenceStoreHandle = {
235
240
  calls: () => Record<'loadIndex' | 'read' | 'write' | 'remove' | 'clear', number>;
236
241
  /** Makes the next `count` writes reject - a full disk, by default. */
237
242
  failNextWrites: (count: number, error?: unknown) => void;
243
+ /** Makes the next `count` removals reject. */
244
+ failNextRemoves: (count: number, error?: unknown) => void;
238
245
  /** Makes the next `loadIndex` reject. */
239
246
  failNextLoadIndex: (error?: unknown) => void;
240
247
  /**
@@ -245,6 +252,14 @@ type FakeQueryPersistenceStoreHandle = {
245
252
  deferReads: () => void;
246
253
  /** Settles the reads held by {@link deferReads} and stops deferring. */
247
254
  flushReads: () => Promise<void>;
255
+ /**
256
+ * Holds every subsequent `loadIndex` pending until {@link flushLoadIndex} is called, for testing
257
+ * what happens to the store while a client's startup is still reading its index (a real disk takes
258
+ * milliseconds). The snapshot each held load returns is the one it would have read when it started.
259
+ */
260
+ deferLoadIndex: () => void;
261
+ /** Settles the index loads held by {@link deferLoadIndex} and stops deferring. */
262
+ flushLoadIndex: () => Promise<void>;
248
263
  };
249
264
  declare const createFakeQueryPersistenceStore: () => FakeQueryPersistenceStoreHandle;
250
265
 
@@ -266,20 +281,37 @@ type WebSocketTestDouble = {
266
281
  url: string;
267
282
  transports: string[] | undefined;
268
283
  } | null;
269
- /** Every message the client sent, newest last - room joins and leaves included. */
284
+ /** The `withCredentials` the client asked the factory for, or `null` until it called it. */
285
+ withCredentials: () => boolean | null;
286
+ /** Every message the client emitted, newest last - room joins and leaves included, buffered ones too. */
270
287
  sent: () => {
271
288
  event: string;
272
289
  data: unknown;
273
290
  }[];
291
+ /** Every message that reached the server, newest last. An emit buffered while offline lands here on the next connect. */
292
+ delivered: () => {
293
+ event: string;
294
+ data: unknown;
295
+ }[];
274
296
  /** Whether the client asked the socket to connect, and whether it has since disconnected it. */
275
297
  state: () => {
276
298
  connectRequested: boolean;
277
299
  disconnected: boolean;
278
300
  };
279
- /** Complete the handshake, so the client's `isConnected` turns true and queued rooms re-join. */
280
- serverConnect: () => void;
301
+ /**
302
+ * Complete the handshake: flush every buffered emit, then turn the client's `isConnected` true. Pass
303
+ * `recovered: true` for a connection-state-recovered reconnect, in which the server kept the rooms.
304
+ */
305
+ serverConnect: (options?: {
306
+ recovered?: boolean;
307
+ }) => void;
281
308
  /** Drop the connection, so `isConnected` turns false. */
282
309
  serverDisconnect: () => void;
310
+ /**
311
+ * Let the ping expire: the socket still reports itself connected, but buffers every emit like socket.io
312
+ * does until it notices and closes. Follow it with {@link serverDisconnect}.
313
+ */
314
+ serverPingExpire: () => void;
283
315
  /** Deliver a message the way the server would. */
284
316
  serverSend: (message: SocketMessageView) => void;
285
317
  /** Deliver a raw frame, for the malformed-payload path. */
@@ -291,8 +323,8 @@ type WebSocketTestDouble = {
291
323
  *
292
324
  * Nothing here connects on its own: the client's own `connect()` only flips
293
325
  * {@link WebSocketTestDouble.state}, and it is {@link WebSocketTestDouble.serverConnect} that fires
294
- * the `connect` listener. That split is what makes "joined a room while disconnected, and the join
295
- * was re-sent on connect" testable.
326
+ * the `connect` listener. That split is what makes "joined a room while disconnected, and it was
327
+ * re-joined after a reconnect" testable.
296
328
  */
297
329
  declare const createWebSocketTestDouble: () => WebSocketTestDouble;
298
330