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

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 +772 -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 +15961 -5330
  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
@@ -4,6 +4,18 @@
4
4
  "title": "Migrate to Query v3",
5
5
  "type": "object",
6
6
  "properties": {
7
+ "projects": {
8
+ "type": "array",
9
+ "items": { "type": "string" },
10
+ "description": "Nx projects to migrate. Defaults to every project in the workspace. Use this to adopt v3 one app or library at a time instead of rewriting the whole monorepo in one run - but keep a query client and the creators built from it in the same run, since they are migrated together.",
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
+ },
7
19
  "skipFormat": {
8
20
  "type": "boolean",
9
21
  "description": "Skip formatting files after migration",
@@ -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
  });
@@ -55,6 +59,8 @@ export const ensureNamedImports = ({ content, importsNeeded, moduleSpecifier })
55
59
  };
56
60
  export const ensureImportFromQuery = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/query' });
57
61
  export const ensureAngularCoreImports = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@angular/core' });
62
+ /** `toProvideFn` / `toInjectFn` - the provider-definition extractors every generated ref pair needs. */
63
+ export const ensureImportFromEthleteCore = (content, importsNeeded) => ensureNamedImports({ content, importsNeeded, moduleSpecifier: '@ethlete/core' });
58
64
  export const getVariableStatementEnd = (sourceFile, variableName, initializerName) => {
59
65
  let variableStatementEnd;
60
66
  const visit = (node) => {
@@ -94,4 +100,47 @@ export const getLineNumber = (node, sourceFile) => {
94
100
  return line + 1;
95
101
  };
96
102
  export const getLineNumberFromPosition = (content, position) => content.slice(0, position).split('\n').length;
103
+ const MIGRATION_GUIDE_URL = 'https://ethlete-sdk-docs.web.app/query/migrating-from-v2';
104
+ /**
105
+ * The tag every `createLegacyQueryCreator(…)` wrapper carries. The wrapper is a migration
106
+ * scaffold, so marking it is what turns "which call sites are still on v2" from a grep into
107
+ * strikethrough in the editor.
108
+ */
109
+ 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}.`;
110
+ /**
111
+ * Adds a tag to `node`'s JSDoc, creating the block when there is none. Returns `null` when the
112
+ * tag is already there, so a caller can tell "nothing to do" from "rewritten".
113
+ *
114
+ * Leading `//` comments stay glued to the node - an `eslint-disable-next-line` only works from
115
+ * the line directly above.
116
+ */
117
+ export const addJsDocTag = (content, sourceFile, node, tag) => {
118
+ const jsDocs = node.jsDoc ?? [];
119
+ const existing = jsDocs[jsDocs.length - 1];
120
+ const tagName = /^@\w+/.exec(tag)?.[0];
121
+ if (existing && tagName && new RegExp(`${tagName}\\b`).test(existing.getText())) {
122
+ return null;
123
+ }
124
+ if (existing) {
125
+ const start = existing.getStart(sourceFile);
126
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
127
+ const text = existing.getText();
128
+ const body = text.slice(0, text.lastIndexOf('*/')).replace(/\s+$/, '');
129
+ // A one-line `/** … */` has to become a block before a tag line can be appended to it.
130
+ const opened = body.includes('\n') ? body : `/**\n${indentation} * ${body.slice(3).trim()}`;
131
+ return (content.slice(0, start) +
132
+ `${opened}\n${indentation} *\n${indentation} * ${tag}\n${indentation} */` +
133
+ content.slice(existing.getEnd()));
134
+ }
135
+ let start = node.getStart(sourceFile, false);
136
+ for (const comment of (ts.getLeadingCommentRanges(content, node.getFullStart()) ?? []).reverse()) {
137
+ if (comment.kind !== ts.SyntaxKind.SingleLineCommentTrivia)
138
+ break;
139
+ if (!/^\n[ \t]*$/.test(content.slice(comment.end, start)))
140
+ break;
141
+ start = comment.pos;
142
+ }
143
+ const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
144
+ return (content.slice(0, start) + `/**\n${indentation} * ${tag}\n${indentation} */\n${indentation}` + content.slice(start));
145
+ };
97
146
  //# sourceMappingURL=shared.js.map
@@ -58,7 +58,7 @@ function renameConflictingSymbols(tree) {
58
58
  // Log statistics
59
59
  if (updatedFiles.length > 0) {
60
60
  console.log(`\n📊 Renamed symbols in ${updatedFiles.length} files:`);
61
- const hasRenames = Array.from(symbolUsageCounts.entries()).filter(([_, count]) => count > 0);
61
+ const hasRenames = Array.from(symbolUsageCounts.entries()).filter(([, count]) => count > 0);
62
62
  if (hasRenames.length > 0) {
63
63
  console.log('\n Symbol usage counts:');
64
64
  hasRenames.forEach(([symbol, count]) => {
@@ -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
  }
@@ -123,7 +136,7 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
123
136
  }
124
137
  visit(sourceFile);
125
138
  // Update imports in @ethlete/query import declarations
126
- const importReplacements = updateImports(sourceFile, content);
139
+ const importReplacements = updateImports(sourceFile);
127
140
  replacements.push(...importReplacements);
128
141
  // Count usages
129
142
  replacements.forEach((r) => {
@@ -138,7 +151,34 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
138
151
  }
139
152
  return result;
140
153
  }
141
- function updateImports(sourceFile, content) {
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
+ }
181
+ function updateImports(sourceFile) {
142
182
  const replacements = [];
143
183
  function visit(node) {
144
184
  if (ts.isImportDeclaration(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
@@ -0,0 +1,44 @@
1
+ {
2
+ "migrations": [
3
+ {
4
+ "name": "to-query-v3",
5
+ "version": "5.42.0",
6
+ "kind": "auto",
7
+ "description": "Move the legacy query system onto the new one through the interop layer.",
8
+ "generator": "@ethlete/query:migrate-to-query-v3",
9
+ "docs": "/query/migrating-from-v2"
10
+ },
11
+ {
12
+ "name": "prep-for-query-v3",
13
+ "version": "6.0.0-beta.8",
14
+ "kind": "auto",
15
+ "description": "Prepare the call sites the v3 query system changes, before the move itself.",
16
+ "generator": "@ethlete/query:prep-for-query-v3",
17
+ "docs": "/query/migrating-from-v2"
18
+ },
19
+ {
20
+ "name": "query-client-features",
21
+ "version": "6.0.0-next.23",
22
+ "kind": "auto",
23
+ "description": "Move the createQueryClient multiTabSync and persistence options onto opt-in features.",
24
+ "generator": "@ethlete/query:migrate-query-client-features",
25
+ "docs": "/query/features"
26
+ },
27
+ {
28
+ "name": "query-opt-in-features",
29
+ "version": "6.0.0-next.23",
30
+ "kind": "auto",
31
+ "description": "Move query clients onto the opt-in error pipeline, and bearer auth onto the opt-in multi-tab sync feature.",
32
+ "generator": "@ethlete/query:migrate-query-opt-in-features",
33
+ "docs": "/query/features"
34
+ },
35
+ {
36
+ "name": "deprecate-legacy-queries",
37
+ "version": "6.0.0-next.24",
38
+ "kind": "auto",
39
+ "description": "Mark the remaining createLegacyQueryCreator wrappers as deprecated, so their call sites surface as still on v2.",
40
+ "generator": "@ethlete/query:deprecate-legacy-queries",
41
+ "docs": "/query/legacy"
42
+ }
43
+ ]
44
+ }
package/package.json CHANGED
@@ -1,22 +1,37 @@
1
1
  {
2
2
  "name": "@ethlete/query",
3
- "version": "6.0.0-next.5",
3
+ "version": "6.0.0-next.51",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {
7
- "@analogjs/vite-plugin-angular": "2.3.1",
8
- "@angular/common": "21.2.1",
9
- "@angular/core": "21.2.1",
10
- "@angular/forms": "21.2.1",
11
- "@angular/router": "21.2.1",
12
- "@ethlete/core": "^5.0.0-next.0",
13
- "@ethlete/types": "^2.0.0-next.0",
14
- "@nx/devkit": "22.5.4",
15
- "@nx/vite": "22.5.4",
7
+ "@analogjs/vite-plugin-angular": "2.6.3",
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
+ "@ethlete/core": "^5.0.0-beta.11",
13
+ "@ethlete/types": "^2.0.0-beta.4",
14
+ "@nx/devkit": "23.1.0",
16
15
  "rxjs": "7.8.2",
17
- "socket.io-client": "4.8.3",
18
- "typescript": "5.9.3",
19
- "vite": "^7.0.0"
16
+ "typescript": "6.0.3",
17
+ "vite": "8.1.5"
18
+ },
19
+ "peerDependenciesMeta": {
20
+ "@analogjs/vite-plugin-angular": {
21
+ "optional": true
22
+ },
23
+ "@nx/devkit": {
24
+ "optional": true
25
+ },
26
+ "typescript": {
27
+ "optional": true
28
+ },
29
+ "vite": {
30
+ "optional": true
31
+ }
32
+ },
33
+ "ethlete": {
34
+ "migrations": "./migrations.json"
20
35
  },
21
36
  "generators": "./generators/generators.json",
22
37
  "module": "fesm2022/ethlete-query.mjs",
@@ -34,6 +49,7 @@
34
49
  "default": "./fesm2022/ethlete-query-testing.mjs"
35
50
  }
36
51
  },
52
+ "type": "module",
37
53
  "dependencies": {
38
54
  "tslib": "^2.3.0"
39
55
  }
@@ -1,10 +1,10 @@
1
- import { AnyCreateQueryClientResult, QueryClientRef, createGetQuery, createPostQuery, createPutQuery, createPatchQuery, createDeleteQuery, BearerAuthProviderFeatureContext, AnyQueryBuilder, BearerAuthProvider, AuthQueryBuilder, TokenRefreshQueryBuilder, TokenRefreshQueryConfig } from '@ethlete/query';
1
+ import { AnyCreateQueryClientResult, QueryClientRef, createGetQuery, createPostQuery, createPutQuery, createPatchQuery, createDeleteQuery, BearerAuthProviderFeatureContext, AnyQueryBuilder, BearerAuthProvider, AuthQueryBuilder, TokenRefreshQueryBuilder, TokenRefreshQueryConfig, QueryPersistenceAdapter, PersistedQueryEntry, WebSocketClientIo, SocketMessageView } from '@ethlete/query';
2
2
  import { HttpClient } from '@angular/common/http';
3
3
  import { HttpTestingController } from '@angular/common/http/testing';
4
4
  import { Injector } from '@angular/core';
5
5
 
6
6
  type QueryTestSetup = {
7
- queryClient: NonNullable<ReturnType<AnyCreateQueryClientResult[1]>>;
7
+ queryClient: NonNullable<ReturnType<AnyCreateQueryClientResult['inject']>>;
8
8
  queryClientRef: QueryClientRef;
9
9
  httpClient: HttpClient;
10
10
  httpTesting: HttpTestingController;
@@ -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;
@@ -70,18 +75,12 @@ type AuthTestSetupConfig<TLoginArgs extends {
70
75
  accessToken: string;
71
76
  refreshToken: string;
72
77
  };
78
+ /** Builds the refresh request from the refresh token. See TokenRefreshQueryConfig for details */
79
+ buildRefreshArgs?: TokenRefreshQueryConfig<TRefreshArgs>['buildArgs'];
73
80
  /** Feature builders for additional auth functionality */
74
81
  features?: [...TFeatures];
75
82
  /** Custom bearer decrypt function for testing */
76
83
  bearerDecryptFn?: (token: string) => TBearerData;
77
- /** Whether to disable multi-tab sync. Default: false */
78
- multiTabSync?: false | {
79
- enabled?: boolean;
80
- channelName?: string;
81
- syncTokens?: boolean;
82
- syncLogout?: boolean;
83
- leaderElection?: boolean;
84
- };
85
84
  /** Refresh strategy configuration. See TokenRefreshQueryConfig for details */
86
85
  refreshStrategy?: TokenRefreshQueryConfig<TRefreshArgs>['refreshStrategy'];
87
86
  /** Minimum refresh interval in milliseconds */
@@ -90,6 +89,8 @@ type AuthTestSetupConfig<TLoginArgs extends {
90
89
  refreshIfExpired?: boolean;
91
90
  /** Property name for expiration in JWT */
92
91
  expiresInPropertyName?: string;
92
+ /** What to do when the refresh query fails for good. Defaults to the provider's own policy */
93
+ onRefreshFailure?: TokenRefreshQueryConfig<TRefreshArgs>['onRefreshFailure'];
93
94
  };
94
95
  type AuthTestSetup<TLoginArgs extends {
95
96
  body: Record<string, unknown>;
@@ -154,6 +155,114 @@ declare const setupAuthTest: <TLoginArgs extends {
154
155
  };
155
156
  }, TFeatures extends readonly AnyFeatureBuilder[] = readonly AnyFeatureBuilder[], TBearerData = unknown>(config: AuthTestSetupConfig<TLoginArgs, TRefreshArgs, TFeatures, TBearerData>) => AuthTestSetup<TLoginArgs, TRefreshArgs, TFeatures, TBearerData>;
156
157
 
158
+ /**
159
+ * Test doubles for the two browser APIs the query client's multi-tab sync is built on:
160
+ * `BroadcastChannel` and the Web Locks API (`navigator.locks`).
161
+ *
162
+ * Both fakes are process-wide and shared between every consumer created while they are installed,
163
+ * which is exactly what makes "two tabs" testable: create two query clients in one test and they
164
+ * talk to each other over the fake bus and contend for the same fake locks, going through the real
165
+ * production code path on both sides.
166
+ *
167
+ * Neither jsdom nor happy-dom can stand in here - jsdom ships a `BroadcastChannel` that never
168
+ * delivers to other instances, and no `navigator.locks` at all.
169
+ */
170
+ /** Something posted on the fake bus, as seen by a receiver. */
171
+ type FakeBroadcastMessage = {
172
+ /** The channel name it was posted on. */
173
+ channel: string;
174
+ /** The structured-cloned payload. */
175
+ data: unknown;
176
+ };
177
+ type FakeBroadcastChannelHandle = {
178
+ /** Every message posted on the bus, in order, across all channel names. */
179
+ posted: FakeBroadcastMessage[];
180
+ /** Uninstalls the fake and restores whatever `BroadcastChannel` was there before. */
181
+ restore: () => void;
182
+ };
183
+ /**
184
+ * Replaces `globalThis.BroadcastChannel` with an in-memory bus.
185
+ *
186
+ * Messages are structured-cloned at post time (so a non-cloneable payload throws exactly like the
187
+ * real API does), delivered on a microtask (never synchronously), and never delivered back to the
188
+ * channel that posted them - all three matching browser behavior. `await Promise.resolve()` in a
189
+ * spec is enough to flush delivery.
190
+ */
191
+ declare const installFakeBroadcastChannel: () => FakeBroadcastChannelHandle;
192
+ type FakeWebLocksHandle = {
193
+ /** The names currently held, in no particular order. */
194
+ heldNames: () => string[];
195
+ /** The names with at least one request queued behind the holder. */
196
+ pendingNames: () => string[];
197
+ /** Uninstalls the fake and restores whatever `navigator.locks` was there before. */
198
+ restore: () => void;
199
+ };
200
+ /**
201
+ * Installs an in-memory `navigator.locks` implementing exclusive locks: one holder per name, the
202
+ * rest queued FIFO, granted on a microtask (never synchronously), released when the callback's
203
+ * promise settles. `signal` is honored for queued requests and - as in the real API - ignored once a
204
+ * request has been granted.
205
+ *
206
+ * `ifAvailable` is a try-lock: a request that cannot be granted straight away runs its callback with
207
+ * `null` instead of queueing, exactly as the real API does. `steal` releases the current holder -
208
+ * rejecting its request with an `AbortError` - and is granted ahead of everything queued.
209
+ *
210
+ * `mode: 'shared'` is not implemented; nothing in the library uses it.
211
+ */
212
+ declare const installFakeWebLocks: () => FakeWebLocksHandle;
213
+ /**
214
+ * Flushes the microtask queue, which is where both fakes schedule their work - message delivery and
215
+ * lock grants. Awaiting it once is enough for a single hop; a grant that has to wait for another
216
+ * tab's release needs one await per hop.
217
+ */
218
+ declare const flushMultiTabSync: () => Promise<void>;
219
+
220
+ /**
221
+ * An in-memory stand-in for the query client's persisted response store.
222
+ *
223
+ * jsdom has no IndexedDB at all, so this is how persistence is tested - and because the storage
224
+ * adapter is a documented seam rather than a mock of one, a spec running against this exercises the
225
+ * real engine, repository and request code on both the write and the read side.
226
+ *
227
+ * Everything resolves on a microtask, never synchronously, matching the real thing: `await
228
+ * Promise.resolve()` (or `flushMicrotasks`) is what lets a hydration land in a spec.
229
+ */
230
+ type FakeQueryPersistenceStoreHandle = {
231
+ /** Pass this as `persistence: { adapter }`. Two clients sharing one handle share one store. */
232
+ adapter: QueryPersistenceAdapter;
233
+ /** Pre-populates the store, as if a previous session had written these entries. */
234
+ seed: (entries: PersistedQueryEntry[]) => void;
235
+ /** Every entry currently held, in insertion order. */
236
+ entries: () => PersistedQueryEntry[];
237
+ /** The entry held under `key`, or `undefined`. */
238
+ entry: (key: string) => PersistedQueryEntry | undefined;
239
+ /** How often each adapter method has been called. */
240
+ calls: () => Record<'loadIndex' | 'read' | 'write' | 'remove' | 'clear', number>;
241
+ /** Makes the next `count` writes reject - a full disk, by default. */
242
+ failNextWrites: (count: number, error?: unknown) => void;
243
+ /** Makes the next `count` removals reject. */
244
+ failNextRemoves: (count: number, error?: unknown) => void;
245
+ /** Makes the next `loadIndex` reject. */
246
+ failNextLoadIndex: (error?: unknown) => void;
247
+ /**
248
+ * Holds every subsequent `read` pending until {@link flushReads} is called, for testing the window
249
+ * between a cold mount and its hydration (a slow disk, or a mount that happened before startup
250
+ * finished).
251
+ */
252
+ deferReads: () => void;
253
+ /** Settles the reads held by {@link deferReads} and stops deferring. */
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>;
263
+ };
264
+ declare const createFakeQueryPersistenceStore: () => FakeQueryPersistenceStoreHandle;
265
+
157
266
  declare const expectAndFlush: <T = unknown>(httpTesting: HttpTestingController, url: string, response: T, statusOpts?: {
158
267
  status: number;
159
268
  statusText: string;
@@ -163,5 +272,63 @@ declare const expectFlushAndWait: <T = unknown>(httpTesting: HttpTestingControll
163
272
  statusText: string;
164
273
  }) => void;
165
274
 
166
- export { expectAndFlush, expectFlushAndWait, setupAuthTest, setupQueryTest };
167
- export type { AnyFeatureBuilder, AuthTestSetup, AuthTestSetupConfig, QueryTestSetup, QueryTestSetupConfig };
275
+ /** A scripted stand-in for `socket.io-client`'s `io`, driven from a spec. */
276
+ type WebSocketTestDouble = {
277
+ /** Pass this as `createWebSocketClient({ io })`. */
278
+ io: WebSocketClientIo;
279
+ /** The url and options the client called the factory with, or `null` until it did. */
280
+ connection: () => {
281
+ url: string;
282
+ transports: string[] | undefined;
283
+ } | null;
284
+ /** The `withCredentials` the client asked the factory for, or `null` until it called it. */
285
+ withCredentials: () => boolean | null;
286
+ /** The auth payload of every handshake, oldest first - `null` for one made without `auth`. */
287
+ handshakes: () => (object | null)[];
288
+ /** Every message the client emitted, newest last - room joins and leaves included, buffered ones too. */
289
+ sent: () => {
290
+ event: string;
291
+ data: unknown;
292
+ }[];
293
+ /** Every message that reached the server, newest last. An emit buffered while offline lands here on the next connect. */
294
+ delivered: () => {
295
+ event: string;
296
+ data: unknown;
297
+ }[];
298
+ /** Whether the client asked the socket to connect, and whether it has since disconnected it. */
299
+ state: () => {
300
+ connectRequested: boolean;
301
+ disconnected: boolean;
302
+ };
303
+ /**
304
+ * Complete the handshake: flush every buffered emit, then turn the client's `isConnected` true. Pass
305
+ * `recovered: true` for a connection-state-recovered reconnect, in which the server kept the rooms.
306
+ */
307
+ serverConnect: (options?: {
308
+ recovered?: boolean;
309
+ }) => void;
310
+ /** Drop the connection, so `isConnected` turns false. */
311
+ serverDisconnect: () => void;
312
+ /**
313
+ * Let the ping expire: the socket still reports itself connected, but buffers every emit like socket.io
314
+ * does until it notices and closes. Follow it with {@link serverDisconnect}.
315
+ */
316
+ serverPingExpire: () => void;
317
+ /** Deliver a message the way the server would. */
318
+ serverSend: (message: SocketMessageView) => void;
319
+ /** Deliver a raw frame, for the malformed-payload path. */
320
+ serverSendRaw: (frame: string) => void;
321
+ };
322
+ /**
323
+ * A socket io test double, for specs around `createWebSocketClient` - which takes its `io` factory as
324
+ * an option precisely so a spec (or an app that never opens a socket) needs no `socket.io-client`.
325
+ *
326
+ * Nothing here connects on its own: the client's own `connect()` only flips
327
+ * {@link WebSocketTestDouble.state}, and it is {@link WebSocketTestDouble.serverConnect} that fires
328
+ * the `connect` listener. That split is what makes "joined a room while disconnected, and it was
329
+ * re-joined after a reconnect" testable.
330
+ */
331
+ declare const createWebSocketTestDouble: () => WebSocketTestDouble;
332
+
333
+ export { createFakeQueryPersistenceStore, createWebSocketTestDouble, expectAndFlush, expectFlushAndWait, flushMultiTabSync, installFakeBroadcastChannel, installFakeWebLocks, setupAuthTest, setupQueryTest };
334
+ export type { AnyFeatureBuilder, AuthTestSetup, AuthTestSetupConfig, FakeBroadcastChannelHandle, FakeBroadcastMessage, FakeQueryPersistenceStoreHandle, FakeWebLocksHandle, QueryTestSetup, QueryTestSetupConfig, WebSocketTestDouble };