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

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # @ethlete/query
2
2
 
3
+ ## 6.0.0-next.46
4
+
5
+ ### Minor Changes
6
+
7
+ - The Angular peer dependency moves from 22.0.7 to 22.1.6. Angular 22.0.x is affected by
8
+ GHSA-hh8m-fm6v-7cvg, a sanitization bypass through directive host bindings, fixed in 22.1.0.
9
+ - `<et-query-devtools-lazy>` renders nothing without `provideQueryDevtools()` - no floating button, no
10
+ shortcut, no panel download - and the now-public `isQueryDevtoolsEnabled()` is what it gates on.
11
+ - A GraphQL `200` with `errors` and no `data` now fails with `ET601` carrying the server's errors, and the error-message ladder reads `{ errors: [{ message }] }` and `[{ message }]` bodies.
12
+ - GQL over POST sends `variables` as a JSON object, as the spec requires, instead of a JSON string. A server that relied on the string must accept the map. GET is unchanged.
13
+ - `withDefaultRetry()` and `withEthleteApiErrors()` now retry only the client they are on. Before, the first call installed the policy process-wide. Migration: add the feature to every client that should retry - a second client without it no longer retries.
14
+ - `defineQueryForm`: `branch()` now debounces and runs the reset graph like the source form and exposes `liveValue`; the filter overlay applies `liveValue` on submit.
15
+ - HTTP queries gain `createHeadQuery` and `createOptionsQuery` with their secure twins, and `createFakeQueryPersistenceStore()` can fail a removal with `failNextRemoves(count)`.
16
+ - WebSocket client: a room joined after the ping expired, or held across a recovered reconnect, is no longer joined twice. `WebSocketClientSocket` now requires `onAnyOutgoing` and `recovered`; add both to a hand-written fake.
17
+ - Auth: `withTokenExpirationWarning` reads the expiry claim named by its new `expiresInPropertyName` option, so a token that carries the expiry under another name no longer reports no expiry at all.
18
+
19
+ ### Patch Changes
20
+
21
+ - The panel drops a client's Cache, Faults and Events entries once that client's injector is destroyed,
22
+ instead of keeping them alive for as long as one of its queries has a tombstone.
23
+ - Query persistence now opens the IndexedDB database again after the browser closes the connection, for example when the user clears the site data. Before, every later write failed and persistence stopped for the rest of the session.
24
+ - Legacy `V2BearerAuthProvider`: a new token now replaces the pending refresh timer instead of adding a second, parallel refresh chain.
25
+ - Legacy query: variables that differ only by whitespace or braces inside a string, or by nesting, no longer share one cached query instance.
26
+ - Legacy `etInfinityQueryTrigger`: a trigger that stays visible after a page lands now keeps loading the next page instead of stalling.
27
+ - Legacy interop: `abort()` publishes `Cancelled`, a second `execute()` while loading is a no-op again, a poll reports `refreshing`, `triggerImmediately` runs at once, and `state$` replays `Prepared`.
28
+ - `createLegacyQueryCreator`: `prepare({ variables })` now forwards the variables to a wrapped gql creator.
29
+ - Legacy query: a secure query that refreshes after a 401 now stays loading until the retry lands, instead of reporting a failure first.
30
+ - Legacy query containers: teardown destroys an uncacheable query, stops the poll of the last container, tracks dependents per injector, tears down the store's listeners, and leaves a superseded mutation to settle.
31
+ - Legacy `QueryForm`: a value arriving through a navigation (back/forward, a link) now commits at once instead of waiting out the field's debounce, matching `defineQueryForm`.
32
+ - Legacy `QueryForm`: values track control writes without `observe()`, a commit no longer cancels an in-flight route change, and a committed value keeps its type through the form's own URL write.
33
+ - Legacy query: polling no longer resumes on window focus when its `takeUntil` notifier fired while polling was paused.
34
+ - Legacy query: a `false`, `0` or `''` body is now sent with its content type instead of being dropped.
35
+ - Legacy query: a secure query executed while logged out returns to `Prepared` instead of staying `Loading`, so executing it after login sends the request.
36
+ - Legacy query client: calling `setAuthProvider` again with the provider already set no longer cancels its scheduled token refresh.
37
+ - Legacy: `[etInfinityQueryTrigger]` no longer throws NG0200, `v2BuildQueryCacheKey` takes the method, `setAuthProvider` keeps the refresh cookie, the prepare fallback survives several applications, and the barrel's re-exports carry `@deprecated`.
38
+ - `validateWithV2Query`: a secure validate query run while logged out now reports a form-level error instead of leaving the field pending forever.
39
+ - `validateWithV2Query`: a shared GET query cancelled by another consumer is re-run instead of leaving the field pending forever.
40
+ - `fetchNextPage()` on a paged stack returns `null` and keeps its position when its args repeat an already loaded page, like `fetchPreviousPage()` does.
41
+ - Multi-tab auth: a follower waits for the leader's in-flight refresh and spends one token per rotation, a cached login is no longer broadcast, and `withTracking` honors `trackInternalEvents`.
42
+ - Bearer auth: a login that races the cookie restore, an empty `2xx` body and a refresh that yields no tokens now settle the session instead of stranding it, and the cookie clears in every reachable scope.
43
+ - Caching: the key covers the request method and case-folds header names, and an entry's secure, refreshable and retention flags follow the queries still bound to it rather than the one that made it.
44
+ - Cache keys no longer merge request bodies that differ only in whitespace or braces inside strings (GraphQL POST variables like `'new york'` vs `'newyork'`); persisted entries of requests with a body miss once after upgrading.
45
+ - Query: the `ET800` circular-dependency guard counts only executions with identical args and measures its window with its own clock, so a fast-typed search no longer trips it.
46
+ - Destroying a query client's injector now releases its retained unused cache entries and their eviction timers.
47
+ - Query devtools: a tombstone drops the destroyed view, override recorders and host elements are released, the floating pills settle after first paint, and the Forms tab links every reader of a form.
48
+ - Error handling: a malformed `violations` body degrades to a form-level `etServerError` instead of throwing, and the `416`, `408`, `410` and `425` status texts are corrected.
49
+ - Query: `execute()` after destroy warns instead of throwing NG0205, `executeUntilSettled` settles on cancel, `reset()` detaches the request, a batch records a throwing item, and snapshots keep their own signals.
50
+ - A follower tab whose refresh takeover was made moot by a new token pair now re-asks and takes over its next unanswered delegated refresh again.
51
+ - Query forms: an array field with a non-null default now survives a reload holding an empty list or `null`, instead of restoring its default or `['ET_NULL__']`.
52
+ - Query forms: a date-only URL value like `2026-09-01` now reads as local midnight of that day instead of shifting a day west of UTC.
53
+ - A deprecated `QueryForm` field with a function `defaultValue` treats a value equal to its result as the default again, so it is neither written to the URL nor counted in `activeFilterCount$`.
54
+ - Query forms: URL writes no longer break under an app-level `defaultQueryParamsHandling` of `'merge'` or `'preserve'`.
55
+ - Query forms: writing to the URL no longer drops its `#fragment`.
56
+ - `defineQueryForm`: an emptied array commits as its `null` default, `skipResets` and `skipFields` no longer leak into the next change, a value written before `observe()` survives, and `liveValue` reports the controls.
57
+ - Query forms: `skipResets` now applies only to the fields that write changes, so a pending debounced edit to another field still resets its dependents.
58
+ - Query forms: `unobserve()` now commits a pending debounced edit to `value` instead of dropping it.
59
+ - Query forms: restoring an empty or unparseable URL value no longer counts as a change, so `?search=&page=3` keeps page 3 instead of resetting it.
60
+ - Query forms: `searchQueryField` restores `?search=2024` as a string, and a field with an array default restores a single URL value as a one-item array.
61
+ - `defineQueryForm`: two forms writing in one tick keep each other's params, `appendToUrl: false` leaves a foreign param alone, `0` reads back as a number, and a commit no longer cancels a route change.
62
+ - GraphQL: a document is parsed once and minified without breaking on comments or string literals, `rawResponse` can declare its own envelope, and secure queries over POST cache like the rest.
63
+ - GraphQL: the `operationName` sent with a document no longer comes from an operation that only appears in a comment above the real one.
64
+ - Query persistence: a response body IndexedDB cannot store no longer leaves a metadata record without a body on disk.
65
+ - Query persistence: mutations are never stored, one opted-in consumer is enough, GraphQL over POST persists, a failed IndexedDB open is retried, and a purge always beats a hydration or index load in flight.
66
+ - Query persistence: only a `QuotaExceededError` frees the oldest half of the store before the write is retried; any other write failure retries without deleting entries.
67
+ - Persistence: a tab no longer hydrates a body that a build with another `version` wrote over the same key; custom adapters should return `version` from `read`.
68
+ - Query persistence: entries written under another `version` are ignored instead of deleted, so two tabs on either side of a deploy no longer wipe each other's store; they are removed once past `maxAge`.
69
+ - Persistence: a logout now also removes secure responses another tab persisted after this one started.
70
+ - Persistence: secure responses are held back until the session is known and purged when a tab starts without one, so a session that ended without a logout no longer hydrates the previous user's data.
71
+ - Persistence: `whenPersistenceReady` now resolves when a custom adapter's `loadIndex` throws synchronously instead of rejecting.
72
+ - Persistent auth: a session now survives a page reload when `localStorage` refuses writes or is unavailable, instead of silently logging the user out.
73
+ - Query types: `QueryContext` and `setDefaultQueryRetryFn` leave the published types, `createQueryFeature()` takes any `type` string, and `createQuerySubmission`'s `onSuccess` allows the `null` a `204` hands it.
74
+ - `withRefreshQuery`'s `retryConfig.maxAttempts` now retries a failed refresh that many times, instead of one fewer.
75
+ - Query stacks and sequences: `deduplicateArgs` applies without `append`, each response runs its features once, `fetchPreviousPage()` returns the page it made, and every link reports the built `total`.
76
+ - `createQuerySubmission`: a custom `mapViolations` that returns `undefined` or `null` no longer gets the default violation mapping attached to the form.
77
+ - Testing entry point: `setupQueryTest` now installs one console filter instead of nesting a
78
+ new one per call, and exposes `restoreConsole`. `installFakeWebLocks` removes the `abort`
79
+ listeners it adds. The websocket double reports `withCredentials()`.
80
+ - Query: a `transformResponse` that throws lands in `error()` with code `0`, keeps the last good `response()`, and no longer re-runs `withSuccessHandling`.
81
+ - `validateWithQuery`: the in-flight request stops when the field goes idle, and the internal query's cache entry and resource are released with the form that owns them.
82
+ - `validateWithQuery` / `validateWithV2Query`: a network or other non-violation error keeps its form-level error when a custom `mapViolations` is set, instead of validating the field as clean.
83
+ - WebSockets: a room joined before the socket connects sends one `join-room`, and a frame carrying no `room` string is reported as malformed without flooding production logs.
84
+ - The devtools session vault no longer uses `localStorage` outside a development build, so a deployed
85
+ app can mount the panel without leaving tokens and credentials behind.
86
+ - A room left while the web socket is down no longer sends a `leave-room` to a fresh session that never joined it; after a recovered reconnect the leave is still sent.
87
+
3
88
  ## 6.0.0-next.45
4
89
 
5
90
  ### Patch Changes
@@ -182,6 +182,7 @@ const installFakeWebLocks = () => {
182
182
  // of the tab it was taken from settles afterwards.
183
183
  if (holders.get(name) === waiter)
184
184
  holders.delete(name);
185
+ waiter.releaseSignal();
185
186
  settleWaiter();
186
187
  pump(name);
187
188
  };
@@ -203,15 +204,24 @@ const installFakeWebLocks = () => {
203
204
  reject(abortError());
204
205
  return;
205
206
  }
206
- const waiter = { callback, resolve, reject, granted: false };
207
- options.signal?.addEventListener('abort', () => {
208
- if (waiter.granted)
209
- return;
210
- const queue = queues.get(name);
211
- if (queue)
212
- queues.set(name, queue.filter((entry) => entry !== waiter));
213
- reject(abortError());
214
- });
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
+ }
215
225
  if (options.steal) {
216
226
  queueMicrotask(() => {
217
227
  const holder = holders.get(name);
@@ -229,6 +239,7 @@ const installFakeWebLocks = () => {
229
239
  if (options.ifAvailable) {
230
240
  queueMicrotask(() => {
231
241
  if (holders.has(name) || (queues.get(name)?.length ?? 0) > 0) {
242
+ waiter.releaseSignal();
232
243
  Promise.resolve(callback(null)).then(resolve, reject);
233
244
  return;
234
245
  }
@@ -259,6 +270,8 @@ const installFakeWebLocks = () => {
259
270
  heldNames: () => [...holders.keys()],
260
271
  pendingNames: () => [...queues.entries()].filter(([, queue]) => queue.length > 0).map(([name]) => name),
261
272
  restore: () => {
273
+ for (const waiter of [...holders.values(), ...[...queues.values()].flat()])
274
+ waiter.releaseSignal();
262
275
  holders.clear();
263
276
  queues.clear();
264
277
  if (originalDescriptor) {
@@ -282,10 +295,14 @@ const createFakeQueryPersistenceStore = () => {
282
295
  const store = new Map();
283
296
  const calls = { loadIndex: 0, read: 0, write: 0, remove: 0, clear: 0 };
284
297
  const deferredReads = [];
298
+ const deferredLoadIndexes = [];
285
299
  let failingWrites = 0;
286
300
  let writeError = null;
301
+ let failingRemoves = 0;
302
+ let removeError = null;
287
303
  let loadIndexError = null;
288
304
  let isDeferringReads = false;
305
+ let isDeferringLoadIndex = false;
289
306
  // Cloning on the way in and on the way out is not pedantry: a real store hands back a copy, so a
290
307
  // spec (or the engine) mutating a hydrated body must not be able to change what is "on disk".
291
308
  const clone = (value) => structuredClone(value);
@@ -296,7 +313,13 @@ const createFakeQueryPersistenceStore = () => {
296
313
  loadIndexError = null;
297
314
  throw error;
298
315
  }
299
- return Array.from(store.values()).map(({ body: _body, ...meta }) => clone(meta));
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;
300
323
  };
301
324
  const read = async (key) => {
302
325
  calls.read++;
@@ -304,7 +327,7 @@ const createFakeQueryPersistenceStore = () => {
304
327
  await new Promise((resolve) => deferredReads.push(resolve));
305
328
  }
306
329
  const entry = store.get(key);
307
- return entry ? { body: clone(entry.body) } : null;
330
+ return entry ? { body: clone(entry.body), version: entry.version } : null;
308
331
  };
309
332
  const write = async (entries) => {
310
333
  calls.write++;
@@ -318,6 +341,10 @@ const createFakeQueryPersistenceStore = () => {
318
341
  };
319
342
  const remove = async (keys) => {
320
343
  calls.remove++;
344
+ if (failingRemoves > 0) {
345
+ failingRemoves--;
346
+ throw removeError ?? new Error('The removal failed.');
347
+ }
321
348
  for (const key of keys) {
322
349
  store.delete(key);
323
350
  }
@@ -343,6 +370,10 @@ const createFakeQueryPersistenceStore = () => {
343
370
  failingWrites = count;
344
371
  writeError = error ?? null;
345
372
  },
373
+ failNextRemoves: (count, error) => {
374
+ failingRemoves = count;
375
+ removeError = error ?? null;
376
+ },
346
377
  failNextLoadIndex: (error) => {
347
378
  loadIndexError = error ?? quotaError();
348
379
  },
@@ -357,40 +388,65 @@ const createFakeQueryPersistenceStore = () => {
357
388
  }
358
389
  await Promise.resolve();
359
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
+ },
360
402
  };
361
403
  };
362
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
+ };
363
445
  const setupQueryTest = (config) => {
364
446
  const baseUrl = config?.baseUrl ?? 'https://api.test.com';
365
447
  const name = config?.name ?? 'test';
366
448
  const mockErrorHandler = config?.mockErrorHandler !== false;
367
- // Suppress console.warn for auth-related warnings during tests
368
- const originalWarn = console.warn;
369
- console.warn = (...args) => {
370
- const message = args[0];
371
- if (typeof message === 'string' && message.includes('auto-refresh')) {
372
- return; // Suppress auto-refresh warnings
373
- }
374
- originalWarn(...args);
375
- };
376
- // Suppress console.error for expected error scenarios during tests
377
- const originalError = console.error;
378
- console.error = (...args) => {
379
- const message = args[0];
380
- // Suppress HttpErrorResponse logs
381
- if (message && typeof message === 'object' && 'name' in message && message.name === 'HttpErrorResponse') {
382
- return;
383
- }
384
- // Suppress bearer token decryption errors
385
- if (typeof message === 'string' && message.includes('Failed to decrypt bearer token')) {
386
- return;
387
- }
388
- // Suppress token extraction errors
389
- if (typeof message === 'string' && message.includes('Failed to extract tokens from')) {
390
- return;
391
- }
392
- originalError(...args);
393
- };
449
+ installConsoleFilters();
394
450
  const providers = [
395
451
  provideHttpClient(),
396
452
  provideHttpClientTesting(),
@@ -404,7 +460,7 @@ const setupQueryTest = (config) => {
404
460
  }
405
461
  TestBed.configureTestingModule({ providers });
406
462
  const queryClientRef = createQueryClient({ baseUrl, name });
407
- const setup = TestBed.runInInjectionContext(() => {
463
+ return TestBed.runInInjectionContext(() => {
408
464
  const { inject } = queryClientRef;
409
465
  const queryClient = inject();
410
466
  if (!queryClient) {
@@ -422,16 +478,9 @@ const setupQueryTest = (config) => {
422
478
  createPut: createPutQuery(queryClientRef),
423
479
  createPatch: createPatchQuery(queryClientRef),
424
480
  createDelete: createDeleteQuery(queryClientRef),
481
+ restoreConsole,
425
482
  };
426
483
  });
427
- // Restore console.warn and console.error when tests are done
428
- const restoreConsole = () => {
429
- console.warn = originalWarn;
430
- console.error = originalError;
431
- };
432
- // Store the restore function for cleanup
433
- setup._restoreConsole = restoreConsole;
434
- return setup;
435
484
  };
436
485
 
437
486
  /**
@@ -440,33 +489,70 @@ const setupQueryTest = (config) => {
440
489
  *
441
490
  * Nothing here connects on its own: the client's own `connect()` only flips
442
491
  * {@link WebSocketTestDouble.state}, and it is {@link WebSocketTestDouble.serverConnect} that fires
443
- * the `connect` listener. That split is what makes "joined a room while disconnected, and the join
444
- * was re-sent on connect" testable.
492
+ * the `connect` listener. That split is what makes "joined a room while disconnected, and it was
493
+ * re-joined after a reconnect" testable.
445
494
  */
446
495
  const createWebSocketTestDouble = () => {
447
496
  const sent = [];
497
+ const delivered = [];
498
+ const buffered = [];
448
499
  const listeners = new Map();
449
500
  const anyListeners = [];
501
+ const outgoingListeners = [];
450
502
  let connection = null;
503
+ let withCredentials = null;
451
504
  let connectRequested = false;
452
505
  let disconnected = false;
506
+ let connected = false;
507
+ let pingExpired = false;
508
+ let recovered = false;
509
+ const deliver = (message) => {
510
+ for (const listener of outgoingListeners)
511
+ listener(message.event, message.data);
512
+ delivered.push(message);
513
+ };
453
514
  const socket = {
454
515
  connect: () => void (connectRequested = true),
455
516
  disconnect: () => void (disconnected = true),
456
- emit: (event, data) => void sent.push({ event, data }),
517
+ emit: (event, data) => {
518
+ sent.push({ event, data });
519
+ if (connected && !pingExpired)
520
+ deliver({ event, data });
521
+ else
522
+ buffered.push({ event, data });
523
+ },
457
524
  on: (event, listener) => void listeners.set(event, listener),
458
525
  onAny: (listener) => void anyListeners.push(listener),
526
+ onAnyOutgoing: (listener) => void outgoingListeners.push(listener),
527
+ get recovered() {
528
+ return recovered;
529
+ },
459
530
  };
460
531
  return {
461
532
  io: (url, options) => {
462
533
  connection = { url, transports: options.transports };
534
+ withCredentials = options.withCredentials;
463
535
  return socket;
464
536
  },
465
537
  connection: () => connection,
538
+ withCredentials: () => withCredentials,
466
539
  sent: () => [...sent],
540
+ delivered: () => [...delivered],
467
541
  state: () => ({ connectRequested, disconnected }),
468
- serverConnect: () => listeners.get('connect')?.(),
469
- serverDisconnect: () => listeners.get('disconnect')?.(),
542
+ serverConnect: (options) => {
543
+ recovered = options?.recovered ?? false;
544
+ connected = true;
545
+ pingExpired = false;
546
+ for (const message of buffered.splice(0))
547
+ deliver(message);
548
+ listeners.get('connect')?.();
549
+ },
550
+ serverDisconnect: () => {
551
+ connected = false;
552
+ pingExpired = false;
553
+ listeners.get('disconnect')?.();
554
+ },
555
+ serverPingExpire: () => void (pingExpired = true),
470
556
  serverSend: (message) => {
471
557
  const frame = JSON.stringify(message);
472
558
  for (const listener of anyListeners)