@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
@@ -27,12 +27,12 @@ const setupAuthTest = (config) => {
27
27
  }), extractRefreshTokens = (response) => ({
28
28
  accessToken: response.accessToken,
29
29
  refreshToken: response.refreshToken,
30
- }), features, bearerDecryptFn, multiTabSync = false, refreshStrategy, minRefreshInterval, refreshIfExpired, expiresInPropertyName, } = config;
30
+ }), buildRefreshArgs, features, bearerDecryptFn, refreshStrategy, minRefreshInterval, refreshIfExpired, expiresInPropertyName, onRefreshFailure, } = config;
31
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
32
  const login = querySetup.createPost(loginPath, {});
33
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
34
  const refresh = querySetup.createPost(refreshPath, {});
35
- const [, injectAuth] = createBearerAuthProvider({
35
+ const { inject: injectAuth } = createBearerAuthProvider({
36
36
  name: 'test-auth',
37
37
  queryClientRef: querySetup.queryClientRef,
38
38
  queries: [
@@ -43,16 +43,17 @@ const setupAuthTest = (config) => {
43
43
  withRefreshQuery('refresh', {
44
44
  queryCreator: refresh,
45
45
  extractTokens: extractRefreshTokens,
46
+ buildArgs: buildRefreshArgs,
46
47
  autoRetryOn401,
47
48
  refreshStrategy,
48
49
  minRefreshInterval,
49
50
  refreshIfExpired,
50
51
  expiresInPropertyName,
52
+ onRefreshFailure,
51
53
  }),
52
54
  ],
53
55
  features: (features ?? []),
54
56
  bearerDecryptFn,
55
- multiTabSync,
56
57
  });
57
58
  const auth = TestBed.runInInjectionContext(() => {
58
59
  const provider = injectAuth();
@@ -96,37 +97,356 @@ const setupAuthTest = (config) => {
96
97
  };
97
98
  };
98
99
 
99
- const setupQueryTest = (config) => {
100
- const baseUrl = config?.baseUrl ?? 'https://api.test.com';
101
- const name = config?.name ?? 'test';
102
- const mockErrorHandler = config?.mockErrorHandler !== false;
103
- // Suppress console.warn for auth-related warnings during tests
104
- const originalWarn = console.warn;
105
- console.warn = (...args) => {
106
- const message = args[0];
107
- if (typeof message === 'string' && message.includes('auto-refresh')) {
108
- return; // Suppress auto-refresh warnings
100
+ /* eslint-disable @typescript-eslint/no-explicit-any */
101
+ /**
102
+ * Replaces `globalThis.BroadcastChannel` with an in-memory bus.
103
+ *
104
+ * Messages are structured-cloned at post time (so a non-cloneable payload throws exactly like the
105
+ * real API does), delivered on a microtask (never synchronously), and never delivered back to the
106
+ * channel that posted them - all three matching browser behavior. `await Promise.resolve()` in a
107
+ * spec is enough to flush delivery.
108
+ */
109
+ const installFakeBroadcastChannel = () => {
110
+ const original = globalThis.BroadcastChannel;
111
+ const instances = new Set();
112
+ const posted = [];
113
+ class FakeBroadcastChannel {
114
+ constructor(name) {
115
+ this.name = name;
116
+ this.closed = false;
117
+ this.onmessage = null;
118
+ instances.add(this);
109
119
  }
110
- originalWarn(...args);
111
- };
112
- // Suppress console.error for expected error scenarios during tests
113
- const originalError = console.error;
114
- console.error = (...args) => {
115
- const message = args[0];
116
- // Suppress HttpErrorResponse logs
117
- if (message && typeof message === 'object' && 'name' in message && message.name === 'HttpErrorResponse') {
118
- return;
120
+ postMessage(data) {
121
+ if (this.closed)
122
+ throw new DOMException('Channel is closed', 'InvalidStateError');
123
+ const clone = structuredClone(data);
124
+ posted.push({ channel: this.name, data: clone });
125
+ for (const instance of instances) {
126
+ if (instance === this || instance.closed || instance.name !== this.name)
127
+ continue;
128
+ queueMicrotask(() => {
129
+ if (instance.closed)
130
+ return;
131
+ instance.onmessage?.({ data: structuredClone(clone) });
132
+ });
133
+ }
119
134
  }
120
- // Suppress bearer token decryption errors
121
- if (typeof message === 'string' && message.includes('Failed to decrypt bearer token')) {
122
- return;
135
+ close() {
136
+ this.closed = true;
137
+ instances.delete(this);
123
138
  }
124
- // Suppress token extraction errors
125
- if (typeof message === 'string' && message.includes('Failed to extract tokens from')) {
139
+ addEventListener() {
140
+ throw new Error('The fake BroadcastChannel only supports `onmessage`.');
141
+ }
142
+ }
143
+ globalThis.BroadcastChannel = FakeBroadcastChannel;
144
+ return {
145
+ posted,
146
+ restore: () => {
147
+ for (const instance of [...instances]) {
148
+ instance.closed = true;
149
+ }
150
+ instances.clear();
151
+ globalThis.BroadcastChannel = original;
152
+ },
153
+ };
154
+ };
155
+ /**
156
+ * Installs an in-memory `navigator.locks` implementing exclusive locks: one holder per name, the
157
+ * rest queued FIFO, granted on a microtask (never synchronously), released when the callback's
158
+ * promise settles. `signal` is honored for queued requests and - as in the real API - ignored once a
159
+ * request has been granted.
160
+ *
161
+ * `ifAvailable` is a try-lock: a request that cannot be granted straight away runs its callback with
162
+ * `null` instead of queueing, exactly as the real API does. `steal` releases the current holder -
163
+ * rejecting its request with an `AbortError` - and is granted ahead of everything queued.
164
+ *
165
+ * `mode: 'shared'` is not implemented; nothing in the library uses it.
166
+ */
167
+ const installFakeWebLocks = () => {
168
+ const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'locks');
169
+ const holders = new Map();
170
+ const queues = new Map();
171
+ const abortError = () => new DOMException('The lock request was aborted', 'AbortError');
172
+ const pump = (name) => {
173
+ if (holders.has(name))
174
+ return;
175
+ const waiter = queues.get(name)?.shift();
176
+ if (!waiter)
126
177
  return;
178
+ waiter.granted = true;
179
+ holders.set(name, waiter);
180
+ const settle = (settleWaiter) => {
181
+ // Only if this waiter is still the holder: a stolen lock already has a new one, and the callback
182
+ // of the tab it was taken from settles afterwards.
183
+ if (holders.get(name) === waiter)
184
+ holders.delete(name);
185
+ waiter.releaseSignal();
186
+ settleWaiter();
187
+ pump(name);
188
+ };
189
+ // `pump` itself only ever runs from a microtask, so calling the callback right here keeps the
190
+ // grant exactly one microtask away from the `request()` call - which is what makes a single
191
+ // `flushMultiTabSync()` per hop enough in specs.
192
+ try {
193
+ Promise.resolve(waiter.callback({ name, mode: 'exclusive' })).then((value) => settle(() => waiter.resolve(value)), (error) => settle(() => waiter.reject(error)));
194
+ }
195
+ catch (error) {
196
+ settle(() => waiter.reject(error));
197
+ }
198
+ };
199
+ const request = (name, optionsOrCallback, maybeCallback) => {
200
+ const options = (typeof optionsOrCallback === 'function' ? {} : (optionsOrCallback ?? {}));
201
+ const callback = (typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback);
202
+ return new Promise((resolve, reject) => {
203
+ if (options.signal?.aborted) {
204
+ reject(abortError());
205
+ return;
206
+ }
207
+ const waiter = { callback, resolve, reject, granted: false, releaseSignal: () => undefined };
208
+ const signal = options.signal;
209
+ if (signal) {
210
+ const onAbort = () => {
211
+ waiter.releaseSignal();
212
+ if (waiter.granted)
213
+ return;
214
+ const queue = queues.get(name);
215
+ if (queue)
216
+ queues.set(name, queue.filter((entry) => entry !== waiter));
217
+ reject(abortError());
218
+ };
219
+ signal.addEventListener('abort', onAbort);
220
+ waiter.releaseSignal = () => {
221
+ waiter.releaseSignal = () => undefined;
222
+ signal.removeEventListener('abort', onAbort);
223
+ };
224
+ }
225
+ if (options.steal) {
226
+ queueMicrotask(() => {
227
+ const holder = holders.get(name);
228
+ if (holder) {
229
+ holders.delete(name);
230
+ holder.reject(abortError());
231
+ }
232
+ queues.set(name, [waiter, ...(queues.get(name) ?? [])]);
233
+ pump(name);
234
+ });
235
+ return;
236
+ }
237
+ // Availability is decided a microtask out, not here: that is when the real API decides it too, so
238
+ // two try-locks requested in the same tick still resolve to one winner and one `null`.
239
+ if (options.ifAvailable) {
240
+ queueMicrotask(() => {
241
+ if (holders.has(name) || (queues.get(name)?.length ?? 0) > 0) {
242
+ waiter.releaseSignal();
243
+ Promise.resolve(callback(null)).then(resolve, reject);
244
+ return;
245
+ }
246
+ queues.set(name, [waiter]);
247
+ pump(name);
248
+ });
249
+ return;
250
+ }
251
+ queues.set(name, [...(queues.get(name) ?? []), waiter]);
252
+ queueMicrotask(() => pump(name));
253
+ });
254
+ };
255
+ // Both lists are reported, because a queued request is how a tab that is *not* the holder still
256
+ // shows up - which is what the auth leader election counts its tabs by.
257
+ const query = () => Promise.resolve({
258
+ held: [...holders.keys()].map((name) => ({ name, mode: 'exclusive' })),
259
+ pending: [...queues.entries()].flatMap(([name, waiters]) => waiters.map(() => ({ name, mode: 'exclusive' }))),
260
+ });
261
+ Object.defineProperty(navigator, 'locks', {
262
+ value: {
263
+ request,
264
+ query,
265
+ },
266
+ configurable: true,
267
+ writable: true,
268
+ });
269
+ return {
270
+ heldNames: () => [...holders.keys()],
271
+ pendingNames: () => [...queues.entries()].filter(([, queue]) => queue.length > 0).map(([name]) => name),
272
+ restore: () => {
273
+ for (const waiter of [...holders.values(), ...[...queues.values()].flat()])
274
+ waiter.releaseSignal();
275
+ holders.clear();
276
+ queues.clear();
277
+ if (originalDescriptor) {
278
+ Object.defineProperty(navigator, 'locks', originalDescriptor);
279
+ }
280
+ else {
281
+ delete navigator.locks;
282
+ }
283
+ },
284
+ };
285
+ };
286
+ /**
287
+ * Flushes the microtask queue, which is where both fakes schedule their work - message delivery and
288
+ * lock grants. Awaiting it once is enough for a single hop; a grant that has to wait for another
289
+ * tab's release needs one await per hop.
290
+ */
291
+ const flushMultiTabSync = () => Promise.resolve();
292
+
293
+ const quotaError = () => new DOMException('The quota has been exceeded.', 'QuotaExceededError');
294
+ const createFakeQueryPersistenceStore = () => {
295
+ const store = new Map();
296
+ const calls = { loadIndex: 0, read: 0, write: 0, remove: 0, clear: 0 };
297
+ const deferredReads = [];
298
+ const deferredLoadIndexes = [];
299
+ let failingWrites = 0;
300
+ let writeError = null;
301
+ let failingRemoves = 0;
302
+ let removeError = null;
303
+ let loadIndexError = null;
304
+ let isDeferringReads = false;
305
+ let isDeferringLoadIndex = false;
306
+ // Cloning on the way in and on the way out is not pedantry: a real store hands back a copy, so a
307
+ // spec (or the engine) mutating a hydrated body must not be able to change what is "on disk".
308
+ const clone = (value) => structuredClone(value);
309
+ const loadIndex = async () => {
310
+ calls.loadIndex++;
311
+ if (loadIndexError !== null) {
312
+ const error = loadIndexError;
313
+ loadIndexError = null;
314
+ throw error;
315
+ }
316
+ // Taken before the gate, not after: a real store answers with what it held when the read started,
317
+ // so anything that empties it while a deferred load is held must not change what that load returns.
318
+ const snapshot = Array.from(store.values()).map(({ body: _body, ...meta }) => clone(meta));
319
+ if (isDeferringLoadIndex) {
320
+ await new Promise((resolve) => deferredLoadIndexes.push(resolve));
321
+ }
322
+ return snapshot;
323
+ };
324
+ const read = async (key) => {
325
+ calls.read++;
326
+ if (isDeferringReads) {
327
+ await new Promise((resolve) => deferredReads.push(resolve));
127
328
  }
128
- originalError(...args);
329
+ const entry = store.get(key);
330
+ return entry ? { body: clone(entry.body), version: entry.version } : null;
129
331
  };
332
+ const write = async (entries) => {
333
+ calls.write++;
334
+ if (failingWrites > 0) {
335
+ failingWrites--;
336
+ throw writeError ?? quotaError();
337
+ }
338
+ for (const entry of entries) {
339
+ store.set(entry.key, clone(entry));
340
+ }
341
+ };
342
+ const remove = async (keys) => {
343
+ calls.remove++;
344
+ if (failingRemoves > 0) {
345
+ failingRemoves--;
346
+ throw removeError ?? new Error('The removal failed.');
347
+ }
348
+ for (const key of keys) {
349
+ store.delete(key);
350
+ }
351
+ };
352
+ const clear = async () => {
353
+ calls.clear++;
354
+ store.clear();
355
+ };
356
+ return {
357
+ adapter: { loadIndex, read, write, remove, clear, isSupported: true },
358
+ seed: (entries) => {
359
+ for (const entry of entries) {
360
+ store.set(entry.key, clone(entry));
361
+ }
362
+ },
363
+ entries: () => Array.from(store.values()).map(clone),
364
+ entry: (key) => {
365
+ const entry = store.get(key);
366
+ return entry ? clone(entry) : undefined;
367
+ },
368
+ calls: () => ({ ...calls }),
369
+ failNextWrites: (count, error) => {
370
+ failingWrites = count;
371
+ writeError = error ?? null;
372
+ },
373
+ failNextRemoves: (count, error) => {
374
+ failingRemoves = count;
375
+ removeError = error ?? null;
376
+ },
377
+ failNextLoadIndex: (error) => {
378
+ loadIndexError = error ?? quotaError();
379
+ },
380
+ deferReads: () => {
381
+ isDeferringReads = true;
382
+ },
383
+ flushReads: async () => {
384
+ isDeferringReads = false;
385
+ const pending = deferredReads.splice(0, deferredReads.length);
386
+ for (const resolve of pending) {
387
+ resolve();
388
+ }
389
+ await Promise.resolve();
390
+ },
391
+ deferLoadIndex: () => {
392
+ isDeferringLoadIndex = true;
393
+ },
394
+ flushLoadIndex: async () => {
395
+ isDeferringLoadIndex = false;
396
+ const pending = deferredLoadIndexes.splice(0, deferredLoadIndexes.length);
397
+ for (const resolve of pending) {
398
+ resolve();
399
+ }
400
+ await Promise.resolve();
401
+ },
402
+ };
403
+ };
404
+
405
+ let originalWarn = null;
406
+ let originalError = null;
407
+ const filteredWarn = (...args) => {
408
+ const message = args[0];
409
+ if (typeof message === 'string' && message.includes('auto-refresh')) {
410
+ return;
411
+ }
412
+ originalWarn?.(...args);
413
+ };
414
+ const filteredError = (...args) => {
415
+ const message = args[0];
416
+ if (message && typeof message === 'object' && 'name' in message && message.name === 'HttpErrorResponse') {
417
+ return;
418
+ }
419
+ if (typeof message === 'string' && message.includes('Failed to decrypt bearer token')) {
420
+ return;
421
+ }
422
+ if (typeof message === 'string' && message.includes('Failed to extract tokens from')) {
423
+ return;
424
+ }
425
+ originalError?.(...args);
426
+ };
427
+ // Capture only what is not already the wrapper, so repeated calls reinstall the one filter instead
428
+ // of nesting a new closure over the previous one and stranding the pristine handlers.
429
+ const installConsoleFilters = () => {
430
+ if (console.warn !== filteredWarn)
431
+ originalWarn = console.warn;
432
+ if (console.error !== filteredError)
433
+ originalError = console.error;
434
+ console.warn = filteredWarn;
435
+ console.error = filteredError;
436
+ };
437
+ const restoreConsole = () => {
438
+ if (originalWarn && console.warn === filteredWarn)
439
+ console.warn = originalWarn;
440
+ if (originalError && console.error === filteredError)
441
+ console.error = originalError;
442
+ originalWarn = null;
443
+ originalError = null;
444
+ };
445
+ const setupQueryTest = (config) => {
446
+ const baseUrl = config?.baseUrl ?? 'https://api.test.com';
447
+ const name = config?.name ?? 'test';
448
+ const mockErrorHandler = config?.mockErrorHandler !== false;
449
+ installConsoleFilters();
130
450
  const providers = [
131
451
  provideHttpClient(),
132
452
  provideHttpClientTesting(),
@@ -140,8 +460,8 @@ const setupQueryTest = (config) => {
140
460
  }
141
461
  TestBed.configureTestingModule({ providers });
142
462
  const queryClientRef = createQueryClient({ baseUrl, name });
143
- const setup = TestBed.runInInjectionContext(() => {
144
- const [, inject] = queryClientRef;
463
+ return TestBed.runInInjectionContext(() => {
464
+ const { inject } = queryClientRef;
145
465
  const queryClient = inject();
146
466
  if (!queryClient) {
147
467
  throw new Error('Failed to create query client in test setup');
@@ -158,21 +478,104 @@ const setupQueryTest = (config) => {
158
478
  createPut: createPutQuery(queryClientRef),
159
479
  createPatch: createPatchQuery(queryClientRef),
160
480
  createDelete: createDeleteQuery(queryClientRef),
481
+ restoreConsole,
161
482
  };
162
483
  });
163
- // Restore console.warn and console.error when tests are done
164
- const restoreConsole = () => {
165
- console.warn = originalWarn;
166
- console.error = originalError;
484
+ };
485
+
486
+ /**
487
+ * A socket io test double, for specs around `createWebSocketClient` - which takes its `io` factory as
488
+ * an option precisely so a spec (or an app that never opens a socket) needs no `socket.io-client`.
489
+ *
490
+ * Nothing here connects on its own: the client's own `connect()` only flips
491
+ * {@link WebSocketTestDouble.state}, and it is {@link WebSocketTestDouble.serverConnect} that fires
492
+ * the `connect` listener. That split is what makes "joined a room while disconnected, and it was
493
+ * re-joined after a reconnect" testable.
494
+ */
495
+ const createWebSocketTestDouble = () => {
496
+ const sent = [];
497
+ const delivered = [];
498
+ const buffered = [];
499
+ const listeners = new Map();
500
+ const anyListeners = [];
501
+ const outgoingListeners = [];
502
+ let connection = null;
503
+ let withCredentials = null;
504
+ let auth;
505
+ const handshakes = [];
506
+ let connectRequested = false;
507
+ let disconnected = false;
508
+ let connected = false;
509
+ let pingExpired = false;
510
+ let recovered = false;
511
+ const deliver = (message) => {
512
+ for (const listener of outgoingListeners)
513
+ listener(message.event, message.data);
514
+ delivered.push(message);
515
+ };
516
+ const socket = {
517
+ connect: () => void (connectRequested = true),
518
+ disconnect: () => void (disconnected = true),
519
+ emit: (event, data) => {
520
+ sent.push({ event, data });
521
+ if (connected && !pingExpired)
522
+ deliver({ event, data });
523
+ else
524
+ buffered.push({ event, data });
525
+ },
526
+ on: (event, listener) => void listeners.set(event, listener),
527
+ onAny: (listener) => void anyListeners.push(listener),
528
+ onAnyOutgoing: (listener) => void outgoingListeners.push(listener),
529
+ get recovered() {
530
+ return recovered;
531
+ },
532
+ };
533
+ return {
534
+ io: (url, options) => {
535
+ connection = { url, transports: options.transports };
536
+ withCredentials = options.withCredentials;
537
+ auth = options.auth;
538
+ return socket;
539
+ },
540
+ connection: () => connection,
541
+ withCredentials: () => withCredentials,
542
+ handshakes: () => [...handshakes],
543
+ sent: () => [...sent],
544
+ delivered: () => [...delivered],
545
+ state: () => ({ connectRequested, disconnected }),
546
+ serverConnect: (options) => {
547
+ if (auth)
548
+ auth((data) => handshakes.push(data));
549
+ else
550
+ handshakes.push(null);
551
+ recovered = options?.recovered ?? false;
552
+ connected = true;
553
+ pingExpired = false;
554
+ for (const message of buffered.splice(0))
555
+ deliver(message);
556
+ listeners.get('connect')?.();
557
+ },
558
+ serverDisconnect: () => {
559
+ connected = false;
560
+ pingExpired = false;
561
+ listeners.get('disconnect')?.();
562
+ },
563
+ serverPingExpire: () => void (pingExpired = true),
564
+ serverSend: (message) => {
565
+ const frame = JSON.stringify(message);
566
+ for (const listener of anyListeners)
567
+ listener('message', frame);
568
+ },
569
+ serverSendRaw: (frame) => {
570
+ for (const listener of anyListeners)
571
+ listener('message', frame);
572
+ },
167
573
  };
168
- // Store the restore function for cleanup
169
- setup._restoreConsole = restoreConsole;
170
- return setup;
171
574
  };
172
575
 
173
576
  /**
174
577
  * Generated bundle index. Do not edit.
175
578
  */
176
579
 
177
- export { expectAndFlush, expectFlushAndWait, setupAuthTest, setupQueryTest };
580
+ export { createFakeQueryPersistenceStore, createWebSocketTestDouble, expectAndFlush, expectFlushAndWait, flushMultiTabSync, installFakeBroadcastChannel, installFakeWebLocks, setupAuthTest, setupQueryTest };
178
581
  //# sourceMappingURL=ethlete-query-testing.mjs.map