@ethlete/query 6.0.0-next.27 → 6.0.0-next.29

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.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { DestroyRef, InjectionToken, inject, Injector, EnvironmentInjector, ElementRef, ErrorHandler, createEnvironmentInjector, effect, untracked, signal, linkedSignal, computed, runInInjectionContext, isDevMode, PLATFORM_ID, resource, DOCUMENT, makeEnvironmentProviders, assertInInjectionContext, provideEnvironmentInitializer, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, input, Pipe, ViewEncapsulation, Component, NgZone } from '@angular/core';
3
- import { defineStaticProvider, RuntimeError, clamp, randomId, defineRootProvider, isObject, injectUnsavedChangesCoordinator, injectRoute, getCookie, getDomain, setCookie, deleteCookie, createDestroy, computedTillTruthy, defineProvider, toProvideFn, toInjectFn, nextFrame, injectQueryParams, injectQueryParamChanges, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
3
+ import { defineStaticProvider, RuntimeError, clamp, randomId, defineRootProvider, isObject, injectUnsavedChangesCoordinator, injectRoute, getCookie, getDomain, deleteCookie, setCookie, createDestroy, computedTillTruthy, defineProvider, toProvideFn, toInjectFn, nextFrame, injectQueryParams, injectQueryParamChanges, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
4
4
  import { filter, Subject, Subscription, defer, throwError, timer, switchMap, tap, retry, catchError, take, of, merge, firstValueFrom, fromEvent, throttleTime, interval, combineLatest, map, Observable, takeUntil, startWith, pairwise, BehaviorSubject, ReplaySubject, shareReplay, skip, takeWhile, EMPTY, withLatestFrom, debounceTime } from 'rxjs';
5
5
  import { HttpClient, HttpEventType, HttpErrorResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http';
6
6
  import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
@@ -50,7 +50,7 @@ const queryDevtoolsFnDetail = (fn, key) => {
50
50
  return name ? [{ label: key, value: name }] : [];
51
51
  };
52
52
 
53
- const noop$2 = () => undefined;
53
+ const noop$3 = () => undefined;
54
54
  let registrar = null;
55
55
  /**
56
56
  * Installs the real registry. Called by `provideQueryDevtools()`; nothing else may call it.
@@ -70,7 +70,7 @@ const isQueryDevtoolsEnabled = () => registrar !== null;
70
70
  * a no-op callback) unless {@link provideQueryDevtools} has been called.
71
71
  * @internal
72
72
  */
73
- const registerQueryDevtoolsEntry = (entry) => registrar?.(entry) ?? noop$2;
73
+ const registerQueryDevtoolsEntry = (entry) => registrar?.(entry) ?? noop$3;
74
74
  let statsFactory = null;
75
75
  /**
76
76
  * Installs the stats recorder factory. Called by `provideQueryDevtools()`; nothing else may call it.
@@ -1023,7 +1023,7 @@ const createBaseQuery = (options) => {
1023
1023
  if (devtoolsStats) {
1024
1024
  const statsSubscription = state.events$.subscribe((event) => {
1025
1025
  if (event.type === 'error') {
1026
- devtoolsStats.recordError({ faulted: event.faulted });
1026
+ devtoolsStats.recordError({ faulted: event.faulted, error: event.error });
1027
1027
  }
1028
1028
  else if (event.type === HttpEventType.Response) {
1029
1029
  devtoolsStats.recordResponse({ headers: event.headers, body: event.body });
@@ -1085,7 +1085,7 @@ const setupQueryExecuteState = () => {
1085
1085
  };
1086
1086
  };
1087
1087
  const queryExecute = (options) => {
1088
- const { executeOptions, args, executeState, options: runQueryOptions, isSecure } = options;
1088
+ const { executeOptions, args, executeState, options: runQueryOptions, isSecure, isRefreshable } = options;
1089
1089
  const { deps, state, creator, creatorInternals, queryConfig } = executeOptions;
1090
1090
  const { key, request, executed } = deps.client.repository.request({
1091
1091
  route: creatorInternals.route,
@@ -1098,6 +1098,7 @@ const queryExecute = (options) => {
1098
1098
  previousKey: executeState.previousKey(),
1099
1099
  runQueryOptions,
1100
1100
  isSecure,
1101
+ isRefreshable,
1101
1102
  silenceUncacheableAllowCacheError: queryConfig.silenceUncacheableAllowCacheError,
1102
1103
  });
1103
1104
  executeState.previousKey.set(key);
@@ -1850,8 +1851,13 @@ const createQueryStack = (options) => {
1850
1851
  removeStrategy === 'newest' ? finalQueries.slice(0, maxQueries) : finalQueries.slice(-maxQueries);
1851
1852
  }
1852
1853
  queries.set(finalQueries);
1853
- lastQuery.set(lastAppendedQuery);
1854
- return lastAppendedQuery;
1854
+ // After the eviction, not before: `removeStrategy: 'newest'` destroys the query `appendFn`
1855
+ // just returned, and `lastQuery` must never point at a query that is no longer in the stack.
1856
+ const last = lastAppendedQuery && finalQueries.includes(lastAppendedQuery)
1857
+ ? lastAppendedQuery
1858
+ : (finalQueries[finalQueries.length - 1] ?? null);
1859
+ lastQuery.set(last);
1860
+ return last;
1855
1861
  }
1856
1862
  else {
1857
1863
  const keyFn = argsKeyFn ?? ((a) => JSON.stringify(a));
@@ -2084,7 +2090,7 @@ const createPagedQueryStack = (options) => {
2084
2090
  ...(ngDevMode ? [{ debugName: "minPagination" }] : /* istanbul ignore next */ []));
2085
2091
  const isFirstPageLoaded = computed(() => {
2086
2092
  const min = minPagination();
2087
- return min ? loadedMinPage() === min.currentPage : false;
2093
+ return min ? loadedMinPage() === 1 : false;
2088
2094
  }, /* @ts-ignore */
2089
2095
  ...(ngDevMode ? [{ debugName: "isFirstPageLoaded" }] : /* istanbul ignore next */ []));
2090
2096
  const isLastPageLoaded = computed(() => {
@@ -2164,14 +2170,16 @@ const createPagedQueryStack = (options) => {
2164
2170
  };
2165
2171
  const canFetchPreviousPage = computed(() => {
2166
2172
  const currentMinPagination = minPagination();
2167
- if (!currentMinPagination)
2173
+ if (!currentMinPagination || stack.anyLoading())
2168
2174
  return false;
2169
2175
  return loadedMinPage() > 1;
2170
2176
  }, /* @ts-ignore */
2171
2177
  ...(ngDevMode ? [{ debugName: "canFetchPreviousPage" }] : /* istanbul ignore next */ []));
2172
2178
  const canFetchNextPage = computed(() => {
2173
2179
  const currentMaxPagination = maxPagination();
2174
- return currentMaxPagination ? loadedMaxPage() < currentMaxPagination.totalPages : false;
2180
+ if (!currentMaxPagination || stack.anyLoading())
2181
+ return false;
2182
+ return loadedMaxPage() < currentMaxPagination.totalPages;
2175
2183
  }, /* @ts-ignore */
2176
2184
  ...(ngDevMode ? [{ debugName: "canFetchNextPage" }] : /* istanbul ignore next */ []));
2177
2185
  const items = computed(() => {
@@ -2891,9 +2899,9 @@ const shouldRetryRequest = (options) => {
2891
2899
  return { retry: false };
2892
2900
  };
2893
2901
 
2894
- const noop$1 = () => undefined;
2902
+ const noop$2 = () => undefined;
2895
2903
  /** Shared because it never changes: without Web Locks, every hold is granted and never lost. */
2896
- const alwaysHolder = { isHolder: /* @__PURE__ */ signal(true).asReadonly(), release: noop$1 };
2904
+ const alwaysHolder = { isHolder: /* @__PURE__ */ signal(true).asReadonly(), release: noop$2 };
2897
2905
  const noKeyStates = /* @__PURE__ */ signal({}).asReadonly();
2898
2906
  const createQueryKeyLockManager = (namespace) => {
2899
2907
  const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
@@ -3081,10 +3089,10 @@ const unwrapQuerySyncMessage = (data) => {
3081
3089
  return null;
3082
3090
  };
3083
3091
 
3084
- const noop = () => undefined;
3092
+ const noop$1 = () => undefined;
3085
3093
  const createQuerySyncTransport = (channelName) => {
3086
3094
  if (typeof BroadcastChannel === 'undefined') {
3087
- return { post: noop, listen: () => noop, destroy: noop, isSupported: false };
3095
+ return { post: noop$1, listen: () => noop$1, destroy: noop$1, isSupported: false };
3088
3096
  }
3089
3097
  const channel = new BroadcastChannel(channelName);
3090
3098
  const listeners = new Set();
@@ -3361,6 +3369,7 @@ const createQueryRepository = (config) => {
3361
3369
  const shouldCache = creatorOptions?.subtle?.useQueryRepositoryCache === false
3362
3370
  ? false
3363
3371
  : shouldCacheQuery(options.method) || creatorOptions?.subtle?.useQueryRepositoryCache === true;
3372
+ const isRefreshable = shouldCache && (options.isRefreshable ?? shouldCacheQuery(options.method));
3364
3373
  if (!shouldCache && options.key)
3365
3374
  throw uncacheableRequestHasCacheKeyParam(options.key);
3366
3375
  // `allowCache` is never read on the uncacheable path below - there is no cache entry to reuse - so this throw
@@ -3407,6 +3416,7 @@ const createQueryRepository = (config) => {
3407
3416
  request: cacheEntry.request,
3408
3417
  isSecure,
3409
3418
  isCached: true,
3419
+ isRefreshable,
3410
3420
  isMultiTabSyncEnabled,
3411
3421
  isPersistEnabled,
3412
3422
  keepUnusedFor,
@@ -3435,6 +3445,7 @@ const createQueryRepository = (config) => {
3435
3445
  request,
3436
3446
  isSecure,
3437
3447
  isCached: shouldCache,
3448
+ isRefreshable,
3438
3449
  isMultiTabSyncEnabled,
3439
3450
  isPersistEnabled,
3440
3451
  keepUnusedFor,
@@ -3446,11 +3457,13 @@ const createQueryRepository = (config) => {
3446
3457
  return { key: trackingKey, request, executed };
3447
3458
  };
3448
3459
  /** Tears an entry down for good, whether it currently has consumers or not. */
3449
- const destroyEntry = (key, cacheEntry) => {
3460
+ const destroyEntry = (key, cacheEntry, cause) => {
3461
+ const { method, url } = cacheEntry.request;
3450
3462
  clearTimeout(cacheEntry.evictTimer);
3451
3463
  cacheEntry.request.destroy();
3452
3464
  cacheEntry.eventSubscription?.unsubscribe();
3453
3465
  cache.delete(key);
3466
+ eventsSubject.next({ type: 'entry-destroyed', key, isSecure: cacheEntry.isSecure, method, url, cause });
3454
3467
  };
3455
3468
  const cancelEviction = (cacheEntry) => {
3456
3469
  if (cacheEntry.evictTimer === undefined)
@@ -3466,12 +3479,12 @@ const createQueryRepository = (config) => {
3466
3479
  return;
3467
3480
  unused.sort(([, a], [, b]) => (a.unusedSince ?? 0) - (b.unusedSince ?? 0));
3468
3481
  for (const [key, entry] of unused.slice(0, unused.length - MAX_UNUSED_ENTRIES)) {
3469
- destroyEntry(key, entry);
3482
+ destroyEntry(key, entry, 'unused-cap');
3470
3483
  }
3471
3484
  };
3472
3485
  const retain = (key, cacheEntry) => {
3473
3486
  cacheEntry.unusedSince = Date.now();
3474
- cacheEntry.evictTimer = setTimeout(() => evict(key), cacheEntry.keepUnusedFor);
3487
+ cacheEntry.evictTimer = setTimeout(() => evict(key, 'expired'), cacheEntry.keepUnusedFor);
3475
3488
  enforceUnusedEntryLimit();
3476
3489
  };
3477
3490
  const unbind = (key, consumerDestroyRef) => {
@@ -3488,7 +3501,7 @@ const createQueryRepository = (config) => {
3488
3501
  retain(key, cacheEntry);
3489
3502
  }
3490
3503
  else {
3491
- destroyEntry(key, cacheEntry);
3504
+ destroyEntry(key, cacheEntry, 'unbind');
3492
3505
  }
3493
3506
  }
3494
3507
  bumpCacheVersion();
@@ -3501,7 +3514,7 @@ const createQueryRepository = (config) => {
3501
3514
  // Force the teardown instead of routing through `unbind`: a logged out session must not leave a
3502
3515
  // retained response body behind waiting out its `keepUnusedFor` window.
3503
3516
  cacheEntry.consumers.clear();
3504
- destroyEntry(key, cacheEntry);
3517
+ destroyEntry(key, cacheEntry, 'logout');
3505
3518
  }
3506
3519
  bumpCacheVersion();
3507
3520
  eventsSubject.next({ type: 'unbind-all-secure' });
@@ -3511,10 +3524,11 @@ const createQueryRepository = (config) => {
3511
3524
  for (const cacheEntry of cache.values()) {
3512
3525
  if (cacheEntry.consumers.size === 0)
3513
3526
  continue;
3514
- // Re-firing a mutation would be a side effect nobody asked for, so only reads are refreshed -
3515
- // "read" meaning cacheable, which also covers a GQL query transported via POST that opted into
3516
- // the cache explicitly.
3517
- if (!cacheEntry.isCached)
3527
+ // Re-firing a mutation would be a side effect nobody asked for, so only reads are refreshed:
3528
+ // a cacheable method, or a GQL query transported via POST. Opting into the cache alone does not
3529
+ // qualify - the auth queries do that for a stable cache key, and replaying a login POST would
3530
+ // end the session.
3531
+ if (!cacheEntry.isRefreshable)
3518
3532
  continue;
3519
3533
  if (filter && !filter(cacheEntry.request))
3520
3534
  continue;
@@ -3548,7 +3562,7 @@ const createQueryRepository = (config) => {
3548
3562
  return true;
3549
3563
  };
3550
3564
  const bind = (options) => {
3551
- const { key, consumerDestroyRef, request, isSecure, isCached, isMultiTabSyncEnabled, isPersistEnabled, keepUnusedFor, } = options;
3565
+ const { key, consumerDestroyRef, request, isSecure, isCached, isRefreshable, isMultiTabSyncEnabled, isPersistEnabled, keepUnusedFor, } = options;
3552
3566
  const destroyListener = consumerDestroyRef.onDestroy(() => unbind(key, consumerDestroyRef));
3553
3567
  const cacheEntry = cache.get(key);
3554
3568
  if (cacheEntry && isCached) {
@@ -3585,6 +3599,7 @@ const createQueryRepository = (config) => {
3585
3599
  eventSubscription,
3586
3600
  keepUnusedFor,
3587
3601
  isCached,
3602
+ isRefreshable,
3588
3603
  isMultiTabSyncEnabled,
3589
3604
  isPersistEnabled,
3590
3605
  });
@@ -3598,11 +3613,11 @@ const createQueryRepository = (config) => {
3598
3613
  isUnused: entry.consumers.size === 0,
3599
3614
  request: entry.request,
3600
3615
  }));
3601
- const evict = (key) => {
3616
+ const evict = (key, cause = 'manual') => {
3602
3617
  const entry = cache.get(key);
3603
3618
  if (!entry)
3604
3619
  return;
3605
- destroyEntry(key, entry);
3620
+ destroyEntry(key, entry, cause);
3606
3621
  bumpCacheVersion();
3607
3622
  };
3608
3623
  const repository = {
@@ -3690,6 +3705,9 @@ const createSecureExecuteFactory = (options) => {
3690
3705
  let authQuerySubscription = Subscription.EMPTY;
3691
3706
  let tokenRefreshSubscription = Subscription.EMPTY;
3692
3707
  let tokenWaitSubscription = Subscription.EMPTY;
3708
+ let sessionRestartSubscription = Subscription.EMPTY;
3709
+ let hasExecuted = false;
3710
+ let lastExecuteArgs;
3693
3711
  const reset = () => {
3694
3712
  authQuerySubscription.unsubscribe();
3695
3713
  authQuerySubscription = Subscription.EMPTY;
@@ -3697,6 +3715,8 @@ const createSecureExecuteFactory = (options) => {
3697
3715
  tokenRefreshSubscription = Subscription.EMPTY;
3698
3716
  tokenWaitSubscription.unsubscribe();
3699
3717
  tokenWaitSubscription = Subscription.EMPTY;
3718
+ sessionRestartSubscription.unsubscribe();
3719
+ sessionRestartSubscription = Subscription.EMPTY;
3700
3720
  resetExecuteState({
3701
3721
  executeState,
3702
3722
  executeOptions: { deps: options.deps, state: options.state },
@@ -3705,9 +3725,26 @@ const createSecureExecuteFactory = (options) => {
3705
3725
  // A logout tears down the secure entries in the repository, but the query object holding one keeps
3706
3726
  // its own `response`/`error` signals - so without this a component still mounted after logout goes
3707
3727
  // on rendering the previous user's data until something calls `reset()` by hand.
3728
+ //
3729
+ // Resetting alone leaves it idle for good though: a self-executing query runs once, at creation,
3730
+ // and nothing runs it again - so anything still mounted across a logout has to be re-armed for
3731
+ // the next session. That re-arm waits on the token instead of re-running here: `logout()` clears
3732
+ // `latestExecutedQuery` and `executionState` only *after* emitting this event, so everything read
3733
+ // synchronously still describes the session that just ended.
3708
3734
  options.deps.client.repository.events$
3709
3735
  .pipe(filter((event) => event.type === 'unbind-all-secure'), takeUntilDestroyed(options.deps.destroyRef))
3710
- .subscribe(() => reset());
3736
+ .subscribe(() => {
3737
+ const shouldRestart = hasExecuted && options.autoExecutes;
3738
+ const restartArgs = lastExecuteArgs;
3739
+ reset();
3740
+ if (!shouldRestart)
3741
+ return;
3742
+ sessionRestartSubscription = toObservable(options.authProvider.accessToken, {
3743
+ injector: options.deps.injector,
3744
+ })
3745
+ .pipe(filter(Boolean), take(1), tap(() => exec(restartArgs)))
3746
+ .subscribe();
3747
+ });
3711
3748
  const error = (query) => {
3712
3749
  const state = options.state;
3713
3750
  state.error.set(query.error());
@@ -3768,12 +3805,16 @@ const createSecureExecuteFactory = (options) => {
3768
3805
  args: executeArgs?.args ?? options.state.args(),
3769
3806
  options: executeArgs?.options,
3770
3807
  };
3808
+ hasExecuted = true;
3809
+ lastExecuteArgs = execArgsWithDefaults;
3771
3810
  authQuerySubscription.unsubscribe();
3772
3811
  authQuerySubscription = Subscription.EMPTY;
3773
3812
  tokenRefreshSubscription.unsubscribe();
3774
3813
  tokenRefreshSubscription = Subscription.EMPTY;
3775
3814
  tokenWaitSubscription.unsubscribe();
3776
3815
  tokenWaitSubscription = Subscription.EMPTY;
3816
+ sessionRestartSubscription.unsubscribe();
3817
+ sessionRestartSubscription = Subscription.EMPTY;
3777
3818
  tokenRefreshSubscription = options.authProvider.afterTokenRefresh$
3778
3819
  .pipe(filter(() => {
3779
3820
  const currentError = options.state.error();
@@ -3840,6 +3881,7 @@ const createSecureExecuteFn = (executeOptions) => {
3840
3881
  authProvider,
3841
3882
  deps: executeOptions.deps,
3842
3883
  state: executeOptions.state,
3884
+ autoExecutes: shouldAutoExecuteQuery(executeOptions.creatorInternals.method) && !executeOptions.queryConfig.onlyManualExecution,
3843
3885
  transformAuthAndExec: (executeArgs, _tokens, headers, executeState) => {
3844
3886
  const { options: runOptions } = executeArgs ?? {};
3845
3887
  const updatedArgs = { ...(executeArgs?.args ?? {}), headers };
@@ -4268,61 +4310,103 @@ const deriveExecutionStateType = (builder, triggeredBy) => {
4268
4310
  return 'revocation';
4269
4311
  return builder.key;
4270
4312
  };
4271
- const setupBearerQueryRegistry = (builders, injector, latestExecutedQuery, latestNonInternalQuery, applyTokens, executionState) => {
4313
+ const setupBearerQueryRegistry = (builders, context) => {
4314
+ const { injector, latestExecutedQuery, latestNonInternalQuery, applyTokens, executionState, sessionStatus } = context;
4272
4315
  const queries = {};
4273
4316
  const querySnapshots = new Map();
4317
+ // Supersession is provider-wide, not per key. Within one key the reused query already handles it,
4318
+ // but a token refresh landing while a login is in flight would otherwise apply its tokens over the
4319
+ // login's and report its own outcome as the session's - two writers, two different executions.
4320
+ let latestExecutionId = 0;
4321
+ const currentExecutions = [];
4274
4322
  for (const builder of builders) {
4275
4323
  const querySnapshot = signal(null, /* @ts-ignore */
4276
4324
  ...(ngDevMode ? [{ debugName: "querySnapshot" }] : /* istanbul ignore next */ []));
4277
4325
  querySnapshots.set(builder.key, querySnapshot);
4278
4326
  const extractTokens = builder.config.extractTokens ?? defaultExtractTokens;
4327
+ // A 2xx whose body carries no usable tokens is a failed execution, not a successful one - the
4328
+ // state effect below reads this so it cannot report `success` for an attempt that left the tab
4329
+ // unauthenticated.
4330
+ const extractionError = signal(null, /* @ts-ignore */
4331
+ ...(ngDevMode ? [{ debugName: "extractionError" }] : /* istanbul ignore next */ []));
4332
+ const currentExecution = signal(null, /* @ts-ignore */
4333
+ ...(ngDevMode ? [{ debugName: "currentExecution" }] : /* istanbul ignore next */ []));
4334
+ currentExecutions.push(currentExecution);
4335
+ const isSuperseded = (execution) => execution.id !== null && execution.id !== latestExecutionId;
4336
+ // Runs before the state effect below, which reads the extraction error this one writes. Angular
4337
+ // runs effects in creation order, so the two must stay in this order.
4279
4338
  effect(() => {
4280
- const snapshot = querySnapshot();
4281
- if (!snapshot)
4339
+ const execution = currentExecution();
4340
+ if (!execution)
4282
4341
  return;
4342
+ const { snapshot } = execution;
4283
4343
  const response = snapshot.response();
4284
4344
  const loading = snapshot.loading();
4285
4345
  const error = snapshot.error();
4286
- if (response && !loading && !error) {
4346
+ if (response && !loading && !error && !isSuperseded(execution)) {
4287
4347
  try {
4288
4348
  const tokens = extractTokens(response);
4349
+ extractionError.set(null);
4289
4350
  applyTokens(tokens.accessToken, tokens.refreshToken);
4290
4351
  }
4291
4352
  catch (extractError) {
4353
+ extractionError.set(createQueryErrorResponse(extractError));
4292
4354
  if (isDevMode()) {
4293
4355
  console.error(`Failed to extract tokens from ${builder.key} response:`, extractError);
4294
4356
  }
4295
4357
  }
4296
4358
  }
4297
4359
  });
4360
+ effect(() => {
4361
+ const execution = currentExecution();
4362
+ if (!execution)
4363
+ return;
4364
+ const { type, snapshot } = execution;
4365
+ const response = snapshot.response();
4366
+ const loading = snapshot.loading();
4367
+ const error = snapshot.error();
4368
+ if (loading || isSuperseded(execution))
4369
+ return;
4370
+ if (error) {
4371
+ executionState.set({ type, state: 'error', error });
4372
+ }
4373
+ else if (response) {
4374
+ const failedExtraction = extractionError();
4375
+ if (failedExtraction) {
4376
+ executionState.set({ type, state: 'error', error: failedExtraction });
4377
+ }
4378
+ else {
4379
+ executionState.set({ type, state: 'success', response });
4380
+ }
4381
+ }
4382
+ // Keyed on `isAlive` rather than on the branches above: a cancelled restore produces neither a
4383
+ // response nor an error, and would otherwise leave `sessionStatus` at `restoring` forever.
4384
+ if (type === 'autoLogin' && !snapshot.isAlive() && !untracked(context.isAuthenticated)) {
4385
+ sessionStatus.set('anonymous');
4386
+ }
4387
+ });
4388
+ // One query per builder, reused by every execution. A query owns a child injector, a devtools
4389
+ // entry and a repository binding holding the request and response bodies - creating one per login
4390
+ // attempt or token refresh grows all of that for as long as the tab lives.
4391
+ let query = null;
4298
4392
  const execute = (args, options) => {
4299
- const query = builder.config.queryCreator({
4300
- onlyManualExecution: true,
4301
- injector,
4302
- });
4393
+ query ??= builder.config.queryCreator({ onlyManualExecution: true, injector });
4394
+ extractionError.set(null);
4303
4395
  query.execute({ args, options });
4304
4396
  const snapshot = query.createSnapshot();
4305
4397
  const stateType = deriveExecutionStateType(builder, options?.triggeredBy);
4306
- if (stateType !== 'revocation') {
4398
+ const isRevocation = stateType === 'revocation';
4399
+ if (!isRevocation) {
4307
4400
  latestExecutedQuery.set({ key: builder.key, snapshot });
4308
4401
  if (!snapshot.triggeredBy()) {
4309
4402
  latestNonInternalQuery.set({ key: builder.key, snapshot });
4310
4403
  }
4311
4404
  }
4312
4405
  executionState.set({ type: stateType, state: 'loading' });
4313
- effect(() => {
4314
- const response = snapshot.response();
4315
- const loading = snapshot.loading();
4316
- const error = snapshot.error();
4317
- if (loading)
4318
- return;
4319
- if (error) {
4320
- executionState.set({ type: stateType, state: 'error', error });
4321
- }
4322
- else if (response) {
4323
- executionState.set({ type: stateType, state: 'success', response });
4324
- }
4325
- }, { injector });
4406
+ if (stateType === 'autoLogin') {
4407
+ sessionStatus.set('restoring');
4408
+ }
4409
+ currentExecution.set({ id: isRevocation ? null : ++latestExecutionId, type: stateType, snapshot });
4326
4410
  querySnapshot.set(snapshot);
4327
4411
  return snapshot;
4328
4412
  };
@@ -4331,7 +4415,12 @@ const setupBearerQueryRegistry = (builders, injector, latestExecutedQuery, lates
4331
4415
  snapshot: querySnapshot.asReadonly(),
4332
4416
  };
4333
4417
  }
4334
- return { queries, querySnapshots };
4418
+ const hasTokenIssuingExecutionInFlight = computed(() => currentExecutions.some((execution) => {
4419
+ const current = execution();
4420
+ return !!current && current.id !== null && current.snapshot.isAlive();
4421
+ }), /* @ts-ignore */
4422
+ ...(ngDevMode ? [{ debugName: "hasTokenIssuingExecutionInFlight" }] : /* istanbul ignore next */ []));
4423
+ return { queries, querySnapshots, hasTokenIssuingExecutionInFlight };
4335
4424
  };
4336
4425
  const setupFeatures = (featureBuilders, context) => {
4337
4426
  const features = {};
@@ -4376,6 +4465,7 @@ const alwaysLeader = () => true;
4376
4465
  const runEarlyFeatureSetup = (featureBuilders, context) => {
4377
4466
  let isLeaderFn = alwaysLeader;
4378
4467
  let leaderElectionContext;
4468
+ let refreshCoordination;
4379
4469
  for (const featureBuilder of featureBuilders ?? []) {
4380
4470
  const earlySetup = featureBuilder.earlySetup;
4381
4471
  if (!earlySetup)
@@ -4383,8 +4473,9 @@ const runEarlyFeatureSetup = (featureBuilders, context) => {
4383
4473
  const result = earlySetup(context);
4384
4474
  isLeaderFn = result.isLeader ?? isLeaderFn;
4385
4475
  leaderElectionContext = result.leaderElection ?? leaderElectionContext;
4476
+ refreshCoordination = result.refreshCoordination ?? refreshCoordination;
4386
4477
  }
4387
- return { isLeaderFn, leaderElectionContext };
4478
+ return { isLeaderFn, leaderElectionContext, refreshCoordination };
4388
4479
  };
4389
4480
  const createBearerAuthProviderImpl = (config) => {
4390
4481
  const injector = inject(Injector);
@@ -4419,9 +4510,15 @@ const createBearerAuthProviderImpl = (config) => {
4419
4510
  ...(ngDevMode ? [{ debugName: "latestNonInternalQuery" }] : /* istanbul ignore next */ []));
4420
4511
  const executionState = signal(null, /* @ts-ignore */
4421
4512
  ...(ngDevMode ? [{ debugName: "executionState" }] : /* istanbul ignore next */ []));
4513
+ const sessionStatus = signal('unknown', /* @ts-ignore */
4514
+ ...(ngDevMode ? [{ debugName: "sessionStatus" }] : /* istanbul ignore next */ []));
4515
+ const sessionEndCause = signal(null, /* @ts-ignore */
4516
+ ...(ngDevMode ? [{ debugName: "sessionEndCause" }] : /* istanbul ignore next */ []));
4422
4517
  const applyTokens = (access, refresh) => {
4423
4518
  accessToken.set(access);
4424
4519
  refreshToken.set(refresh);
4520
+ sessionStatus.set('authenticated');
4521
+ sessionEndCause.set(null);
4425
4522
  afterTokenRefresh$.next();
4426
4523
  };
4427
4524
  // Public entry point only - a query-driven login already reports its own, more specific
@@ -4431,20 +4528,37 @@ const createBearerAuthProviderImpl = (config) => {
4431
4528
  applyTokens(access, refresh);
4432
4529
  executionState.set({ type: 'tokenSeed', state: 'success' });
4433
4530
  };
4434
- const { queries } = setupBearerQueryRegistry(config.queries, injector, latestExecutedQuery, latestNonInternalQuery, applyTokens, executionState);
4435
- const logout = () => {
4531
+ const { queries, hasTokenIssuingExecutionInFlight } = setupBearerQueryRegistry(config.queries, {
4532
+ injector,
4533
+ latestExecutedQuery,
4534
+ latestNonInternalQuery,
4535
+ applyTokens,
4536
+ executionState,
4537
+ sessionStatus,
4538
+ isAuthenticated,
4539
+ });
4540
+ const logout = (cause = 'user') => {
4436
4541
  accessToken.set(null);
4437
4542
  refreshToken.set(null);
4438
4543
  queryClient.repository.unbindAllSecure();
4439
4544
  latestExecutedQuery.set(null);
4440
4545
  latestNonInternalQuery.set(null);
4441
4546
  executionState.set({ type: 'logout', state: 'success' });
4547
+ sessionStatus.set('anonymous');
4548
+ sessionEndCause.set(cause);
4442
4549
  // Unsaved edits can no longer be saved once the session is gone. Guarding them past this point
4443
4550
  // only strands a "discard your changes?" dialog over the login page the app redirects to, and
4444
4551
  // leaves the tab locked against closing. See `injectUnsavedChangesCoordinator`.
4445
4552
  unsavedChanges.abandonAll('logout');
4446
4553
  };
4447
- const isLeader = runEarlyFeatureSetup(config.features, { accessToken, refreshToken, queryClient });
4554
+ const isLeader = runEarlyFeatureSetup(config.features, {
4555
+ accessToken,
4556
+ refreshToken,
4557
+ queryClient,
4558
+ name: config.name,
4559
+ applyTokens,
4560
+ logout,
4561
+ });
4448
4562
  const querySetupContext = {
4449
4563
  accessToken,
4450
4564
  refreshToken,
@@ -4452,8 +4566,11 @@ const createBearerAuthProviderImpl = (config) => {
4452
4566
  queryClient,
4453
4567
  repository: queryClient.repository,
4454
4568
  isLeader: isLeader.isLeaderFn,
4569
+ refreshCoordination: isLeader.refreshCoordination,
4570
+ logout,
4455
4571
  afterTokenRefresh$,
4456
4572
  queries: queries,
4573
+ hasTokenIssuingExecutionInFlight,
4457
4574
  };
4458
4575
  for (const builder of config.queries) {
4459
4576
  builder.setup?.(querySetupContext);
@@ -4471,8 +4588,15 @@ const createBearerAuthProviderImpl = (config) => {
4471
4588
  afterTokenRefresh$,
4472
4589
  queries: queries,
4473
4590
  executionState,
4591
+ sessionStatus: sessionStatus.asReadonly(),
4592
+ sessionEndCause: sessionEndCause.asReadonly(),
4474
4593
  };
4475
4594
  const { features, applied: appliedFeatures } = setupFeatures(config.features, featureSetupContext);
4595
+ // Runs after the features, because `withPersistentAuth` starts its auto-login during setup. Still
4596
+ // `unknown` here means nothing is trying to restore a session, so there is nothing to wait for.
4597
+ if (sessionStatus() === 'unknown') {
4598
+ sessionStatus.set('anonymous');
4599
+ }
4476
4600
  const provider = {
4477
4601
  queries,
4478
4602
  features: features,
@@ -4483,6 +4607,8 @@ const createBearerAuthProviderImpl = (config) => {
4483
4607
  latestExecutedQuery: latestExecutedQuery.asReadonly(),
4484
4608
  latestNonInternalQuery: latestNonInternalQuery.asReadonly(),
4485
4609
  executionState: executionState.asReadonly(),
4610
+ sessionStatus: sessionStatus.asReadonly(),
4611
+ sessionEndCause: sessionEndCause.asReadonly(),
4486
4612
  setTokens,
4487
4613
  logout,
4488
4614
  afterTokenRefresh$,
@@ -4512,7 +4638,9 @@ const withAuthenticationQuery = (key, config, setup) => ({
4512
4638
  key,
4513
4639
  config: {
4514
4640
  ...config,
4515
- queryCreator: config.queryCreator.clone({ subtle: { useQueryRepositoryCache: true } }),
4641
+ // A spent credential entry must not sit out a retention window: the request body holds the
4642
+ // username and password, the response the tokens it was exchanged for.
4643
+ queryCreator: config.queryCreator.clone({ keepUnusedFor: 0, subtle: { useQueryRepositoryCache: true } }),
4516
4644
  },
4517
4645
  setup,
4518
4646
  });
@@ -4547,20 +4675,30 @@ const withRefreshQuery = (key, config) => {
4547
4675
  const minRefreshInterval = config.minRefreshInterval ?? 30000; // 30 seconds default
4548
4676
  const refreshIfExpired = config.refreshIfExpired ?? true;
4549
4677
  let lastRefreshTime = 0;
4550
- const executeRefresh = (triggeredInternally = true) => {
4678
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4679
+ const refreshQuery = () => context.queries[key];
4680
+ const executeRefresh = (reason) => {
4551
4681
  const currentRefreshToken = context.refreshToken();
4552
- if (!currentRefreshToken || !context.isLeader())
4682
+ if (!currentRefreshToken)
4553
4683
  return;
4554
- const now = Date.now();
4555
- const timeSinceLastRefresh = now - lastRefreshTime;
4556
- // Prevent too-frequent refreshes
4557
- if (timeSinceLastRefresh < minRefreshInterval)
4684
+ if (!context.isLeader()) {
4685
+ // Only the leader may spend a single-use refresh token, but the event is still real - hand it
4686
+ // over instead of dropping it, or this tab waits out the leader's timer with a dead token.
4687
+ context.refreshCoordination?.request();
4558
4688
  return;
4559
- lastRefreshTime = now;
4560
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4561
- context.queries[key].execute(buildArgs(currentRefreshToken), {
4562
- triggeredBy: triggeredInternally ? 'token-refresh' : undefined,
4563
- });
4689
+ }
4690
+ // Any token-issuing execution, not just another refresh: a login already in flight is about to
4691
+ // issue a token pair of its own, and the refresh token this would spend belongs to the session
4692
+ // that login is replacing.
4693
+ if (context.hasTokenIssuingExecutionInFlight())
4694
+ return;
4695
+ if (reason === 'scheduled') {
4696
+ const now = Date.now();
4697
+ if (now - lastRefreshTime < minRefreshInterval)
4698
+ return;
4699
+ lastRefreshTime = now;
4700
+ }
4701
+ refreshQuery().execute(buildArgs(currentRefreshToken), { triggeredBy: 'token-refresh' });
4564
4702
  };
4565
4703
  const calculateRefreshBuffer = (tokenLifetimeMs) => {
4566
4704
  if (typeof config.refreshStrategy === 'number') {
@@ -4611,9 +4749,24 @@ const withRefreshQuery = (key, config) => {
4611
4749
  }), takeUntilDestroyed())
4612
4750
  .subscribe((shouldRefresh) => {
4613
4751
  if (shouldRefresh) {
4614
- executeRefresh(true);
4752
+ executeRefresh('scheduled');
4615
4753
  }
4616
4754
  });
4755
+ context.refreshCoordination?.requests$.pipe(takeUntilDestroyed()).subscribe(() => executeRefresh('unauthorized'));
4756
+ const onRefreshFailure = config.onRefreshFailure ??
4757
+ (({ error, logout }) => {
4758
+ if (!retryableStatusCodes.includes(error.code))
4759
+ logout();
4760
+ });
4761
+ effect(() => {
4762
+ const snapshot = refreshQuery().snapshot();
4763
+ if (!snapshot || snapshot.loading())
4764
+ return;
4765
+ const error = snapshot.error();
4766
+ if (!error)
4767
+ return;
4768
+ onRefreshFailure({ error, logout: () => context.logout('expired') });
4769
+ });
4617
4770
  // Auto-retry on 401: Listen to repository events and trigger refresh on 401 errors
4618
4771
  const autoRetryOn401 = config.autoRetryOn401 ?? true;
4619
4772
  if (autoRetryOn401) {
@@ -4629,7 +4782,7 @@ const withRefreshQuery = (key, config) => {
4629
4782
  return true;
4630
4783
  }), takeUntilDestroyed())
4631
4784
  .subscribe(() => {
4632
- executeRefresh(true);
4785
+ executeRefresh('unauthorized');
4633
4786
  });
4634
4787
  }
4635
4788
  };
@@ -4638,7 +4791,11 @@ const withRefreshQuery = (key, config) => {
4638
4791
  key,
4639
4792
  config: {
4640
4793
  ...config,
4641
- queryCreator: config.queryCreator.clone({ retryFn: refreshRetryFn, subtle: { useQueryRepositoryCache: true } }),
4794
+ queryCreator: config.queryCreator.clone({
4795
+ retryFn: refreshRetryFn,
4796
+ keepUnusedFor: 0,
4797
+ subtle: { useQueryRepositoryCache: true },
4798
+ }),
4642
4799
  },
4643
4800
  setup,
4644
4801
  buildArgs,
@@ -4670,7 +4827,7 @@ const withInactivityLogout = (config = {}) => {
4670
4827
  });
4671
4828
  const inactivityLogout$ = activity$.pipe(switchMap(() => timer(inactivityTimeout)), filter(() => enabled() && !!context.accessToken()));
4672
4829
  const logoutSubscription = inactivityLogout$.subscribe(() => {
4673
- context.logout();
4830
+ context.logout('inactivity');
4674
4831
  });
4675
4832
  effect(() => {
4676
4833
  const token = context.accessToken();
@@ -4720,18 +4877,20 @@ const withInactivityLogout = (config = {}) => {
4720
4877
  /**
4721
4878
  * Namespace and key of the one lock the whole election is: whoever holds it is the leader, and every
4722
4879
  * other tab of the app is queued behind it. The lock name is what the instance count is derived from,
4723
- * so the two can never disagree about who is taking part.
4880
+ * so the two can never disagree about who is taking part. Both carry the provider's name, so two
4881
+ * providers reachable from one origin elect a leader each instead of one between them.
4724
4882
  */
4725
4883
  const LEADER_LOCK_NAMESPACE = 'ethlete-auth';
4726
- const LEADER_LOCK_KEY = 'leader';
4727
- const LEADER_LOCK_NAME = 'ethlete-auth:leader';
4884
+ const leaderLockKey = (name) => `leader:${name}`;
4885
+ const leaderLockName = (name) => `${LEADER_LOCK_NAMESPACE}:${leaderLockKey(name)}`;
4728
4886
  /**
4729
4887
  * Web Locks has no "someone joined" event, so tabs announce themselves on this channel. Two messages
4730
- * per tab lifetime, in place of the heartbeat this used to run once a second.
4888
+ * per tab lifetime, in place of the heartbeat this used to run once a second. It also carries a
4889
+ * follower's request for a refresh, which is the other thing a tab can only say to the leader.
4731
4890
  */
4732
- const PRESENCE_CHANNEL_NAME = 'ethlete-auth-leader';
4733
- const countLeaderRequests = (snapshot) => {
4734
- const isLeaderLock = (info) => info.name === LEADER_LOCK_NAME;
4891
+ const presenceChannelName = (name) => `ethlete-auth-leader:${name}`;
4892
+ const countLeaderRequests = (snapshot, lockName) => {
4893
+ const isLeaderLock = (info) => info.name === lockName;
4735
4894
  // Held plus pending is exactly the tab count: every tab requests this one lock, one gets it and the
4736
4895
  // rest queue. The platform drops a crashed tab from both lists on its own.
4737
4896
  return (snapshot.held?.filter(isLeaderLock).length ?? 0) + (snapshot.pending?.filter(isLeaderLock).length ?? 0);
@@ -4748,20 +4907,29 @@ const countLeaderRequests = (snapshot) => {
4748
4907
  * `localStorage` implementation fell back to, and the only safe default: a session that refreshes its
4749
4908
  * token in every tab is wasteful, one that refreshes it in none logs the user out.
4750
4909
  */
4751
- const setupLeaderElection = () => {
4910
+ const setupLeaderElection = (options) => {
4752
4911
  const destroyRef = inject(DestroyRef);
4912
+ const lockName = leaderLockName(options.name);
4753
4913
  const lockManager = createQueryKeyLockManager(LEADER_LOCK_NAMESPACE);
4754
- const hold = lockManager.hold(LEADER_LOCK_KEY);
4914
+ const hold = lockManager.hold(leaderLockKey(options.name));
4755
4915
  const instanceCount = signal(1, /* @ts-ignore */
4756
4916
  ...(ngDevMode ? [{ debugName: "instanceCount" }] : /* istanbul ignore next */ []));
4757
- const noopCleanup = () => {
4917
+ const refreshRequests = new Subject();
4918
+ const noop = () => {
4758
4919
  /* nothing was set up */
4759
4920
  };
4760
- // `hold.isHolder` is already `true` in this case, so the tab reads as the leader it effectively is.
4921
+ // `hold.isHolder` is already `true` in this case, so the tab reads as the leader it effectively is,
4922
+ // and asks nobody else for a refresh.
4761
4923
  if (!lockManager.isSupported) {
4762
- return { isLeader: hold.isHolder, instanceCount: instanceCount.asReadonly(), cleanup: noopCleanup };
4924
+ return {
4925
+ isLeader: hold.isHolder,
4926
+ instanceCount: instanceCount.asReadonly(),
4927
+ requestRefresh: noop,
4928
+ refreshRequests$: refreshRequests.asObservable(),
4929
+ cleanup: noop,
4930
+ };
4763
4931
  }
4764
- const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(PRESENCE_CHANNEL_NAME);
4932
+ const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(presenceChannelName(options.name));
4765
4933
  let isDestroyed = false;
4766
4934
  const recount = async () => {
4767
4935
  const snapshot = await navigator.locks.query();
@@ -4769,14 +4937,21 @@ const setupLeaderElection = () => {
4769
4937
  return;
4770
4938
  // Never below one: the snapshot can be taken before this tab's own request is registered, and a
4771
4939
  // count of zero would say the app is not running in the tab reading it.
4772
- instanceCount.set(Math.max(countLeaderRequests(snapshot), 1));
4940
+ instanceCount.set(Math.max(countLeaderRequests(snapshot, lockName), 1));
4773
4941
  };
4774
4942
  const announce = () => channel?.postMessage({ type: 'presence' });
4943
+ const requestRefresh = () => channel?.postMessage({ type: 'refresh-requested' });
4775
4944
  if (channel) {
4776
4945
  channel.onmessage = (event) => {
4777
- if (event.data?.type !== 'presence')
4946
+ const message = event.data;
4947
+ if (message?.type === 'presence') {
4948
+ void recount();
4778
4949
  return;
4779
- void recount();
4950
+ }
4951
+ // Every tab hears the request; only the one that may spend the refresh token acts on it.
4952
+ if (message?.type === 'refresh-requested' && hold.isHolder()) {
4953
+ refreshRequests.next();
4954
+ }
4780
4955
  };
4781
4956
  }
4782
4957
  effect(() => {
@@ -4797,9 +4972,16 @@ const setupLeaderElection = () => {
4797
4972
  hold.release();
4798
4973
  announce();
4799
4974
  channel?.close();
4975
+ refreshRequests.complete();
4800
4976
  };
4801
4977
  destroyRef.onDestroy(cleanup);
4802
- return { isLeader: hold.isHolder, instanceCount: instanceCount.asReadonly(), cleanup };
4978
+ return {
4979
+ isLeader: hold.isHolder,
4980
+ instanceCount: instanceCount.asReadonly(),
4981
+ requestRefresh,
4982
+ refreshRequests$: refreshRequests.asObservable(),
4983
+ cleanup,
4984
+ };
4803
4985
  };
4804
4986
 
4805
4987
  const ENCRYPTION_KEY_STORAGE = '__eth_ek';
@@ -4882,13 +5064,19 @@ const resetEncryptionKey = () => {
4882
5064
  }
4883
5065
  };
4884
5066
 
4885
- const setupMultiTabSync = (config, accessToken, refreshToken, queryClient) => {
5067
+ /**
5068
+ * The channel a provider syncs on unless the consumer names one. Carries the provider's name, so two
5069
+ * providers reachable from the same origin do not push each other's tokens around.
5070
+ */
5071
+ const defaultSyncChannelName = (name) => `ethlete-auth-sync:${name}`;
5072
+ /** How a logout is recorded in {@link lastSyncedState}, where a token pair records its two tokens. */
5073
+ const LOGGED_OUT = 'logout';
5074
+ const tokenState = (access, refresh) => `${access}\0${refresh}`;
5075
+ const setupMultiTabSync = (config, context) => {
4886
5076
  const destroyRef = inject(DestroyRef);
4887
- const channelName = config.channelName ?? 'ethlete-auth-sync';
5077
+ const channelName = config.channelName ?? defaultSyncChannelName(context.name);
4888
5078
  const syncTokens = config.syncTokens ?? true;
4889
5079
  const syncLogout = config.syncLogout ?? true;
4890
- let channel = null;
4891
- let isProcessingExternalUpdate = false;
4892
5080
  if (typeof BroadcastChannel === 'undefined') {
4893
5081
  if (isDevMode()) {
4894
5082
  console.warn('BroadcastChannel is not supported in this environment. Multi-tab sync will be disabled.');
@@ -4899,60 +5087,67 @@ const setupMultiTabSync = (config, accessToken, refreshToken, queryClient) => {
4899
5087
  },
4900
5088
  };
4901
5089
  }
4902
- channel = new BroadcastChannel(channelName);
5090
+ const channel = new BroadcastChannel(channelName);
5091
+ // The session state this tab last sent or received, which is what stops an incoming message from being
5092
+ // echoed straight back out. A flag cleared once the message handler returns cannot do that job: the
5093
+ // broadcasting effects below run after it, so they would always see it cleared.
5094
+ let lastSyncedState = null;
5095
+ let hadTokens = false;
4903
5096
  channel.onmessage = (event) => {
4904
5097
  const message = event.data;
4905
- // Prevent infinite loops
4906
- if (isProcessingExternalUpdate)
5098
+ if (message.type === 'logout' && syncLogout) {
5099
+ lastSyncedState = LOGGED_OUT;
5100
+ hadTokens = false;
5101
+ context.logout('otherTab');
4907
5102
  return;
4908
- isProcessingExternalUpdate = true;
4909
- try {
4910
- if (message.type === 'logout' && syncLogout) {
4911
- accessToken.set(null);
4912
- refreshToken.set(null);
4913
- queryClient.repository.unbindAllSecure();
4914
- }
4915
- else if (message.type === 'tokens-updated' && syncTokens) {
4916
- accessToken.set(decryptToken(message.accessToken));
4917
- refreshToken.set(decryptToken(message.refreshToken));
4918
- }
4919
- }
4920
- finally {
4921
- isProcessingExternalUpdate = false;
4922
5103
  }
5104
+ if (message.type !== 'tokens-updated' || !syncTokens)
5105
+ return;
5106
+ const access = decryptToken(message.accessToken);
5107
+ const refresh = decryptToken(message.refreshToken);
5108
+ if (!access || !refresh)
5109
+ return;
5110
+ lastSyncedState = tokenState(access, refresh);
5111
+ hadTokens = true;
5112
+ context.applyTokens(access, refresh);
4923
5113
  };
4924
5114
  const cleanup = () => {
4925
- channel?.close();
5115
+ channel.close();
4926
5116
  };
4927
5117
  destroyRef.onDestroy(cleanup);
4928
5118
  if (syncTokens) {
4929
5119
  effect(() => {
4930
- const access = accessToken();
4931
- const refresh = refreshToken();
4932
- if (access && refresh && !isProcessingExternalUpdate && channel) {
4933
- const message = {
4934
- type: 'tokens-updated',
4935
- accessToken: encryptToken(access),
4936
- refreshToken: encryptToken(refresh),
4937
- };
4938
- channel.postMessage(message);
4939
- }
5120
+ const access = context.accessToken();
5121
+ const refresh = context.refreshToken();
5122
+ if (!access || !refresh)
5123
+ return;
5124
+ const state = tokenState(access, refresh);
5125
+ if (state === lastSyncedState)
5126
+ return;
5127
+ lastSyncedState = state;
5128
+ const message = {
5129
+ type: 'tokens-updated',
5130
+ accessToken: encryptToken(access),
5131
+ refreshToken: encryptToken(refresh),
5132
+ };
5133
+ channel.postMessage(message);
4940
5134
  });
4941
5135
  }
4942
5136
  if (syncLogout) {
4943
- let hadTokens = false;
4944
5137
  effect(() => {
4945
- const access = accessToken();
5138
+ const access = context.accessToken();
4946
5139
  if (access) {
4947
5140
  hadTokens = true;
5141
+ return;
4948
5142
  }
4949
- else if (hadTokens && !isProcessingExternalUpdate && channel) {
4950
- const message = {
4951
- type: 'logout',
4952
- };
4953
- channel.postMessage(message);
4954
- hadTokens = false;
4955
- }
5143
+ if (!hadTokens)
5144
+ return;
5145
+ hadTokens = false;
5146
+ if (lastSyncedState === LOGGED_OUT)
5147
+ return;
5148
+ lastSyncedState = LOGGED_OUT;
5149
+ const message = { type: 'logout' };
5150
+ channel.postMessage(message);
4956
5151
  });
4957
5152
  }
4958
5153
  return { cleanup };
@@ -4986,11 +5181,12 @@ const SINGLE_TAB = /* @__PURE__ */ (() => {
4986
5181
  */
4987
5182
  const withBearerAuthMultiTabSync = (config = {}) => {
4988
5183
  let instance = SINGLE_TAB;
5184
+ let channelName = config.channelName ?? '';
4989
5185
  const setup = (_context) => ({
4990
5186
  type: BearerAuthFeatureType.MULTI_TAB_SYNC,
4991
5187
  instance,
4992
5188
  devtools: () => [
4993
- { label: 'channel', value: config.channelName ?? 'ethlete-auth-sync' },
5189
+ { label: 'channel', value: channelName },
4994
5190
  { label: 'tokens', value: config.syncTokens === false ? 'tab local' : 'synced' },
4995
5191
  { label: 'logout', value: config.syncLogout === false ? 'tab local' : 'synced' },
4996
5192
  {
@@ -5002,12 +5198,23 @@ const withBearerAuthMultiTabSync = (config = {}) => {
5002
5198
  // The elected leader gates the auth queries' automatic refresh, which is wired up before the
5003
5199
  // regular feature setup runs - so this half has to happen earlier than `setup`.
5004
5200
  const earlySetup = (context) => {
5005
- setupMultiTabSync({ channelName: config.channelName, syncTokens: config.syncTokens, syncLogout: config.syncLogout }, context.accessToken, context.refreshToken, context.queryClient);
5201
+ channelName = config.channelName ?? defaultSyncChannelName(context.name);
5202
+ setupMultiTabSync({ channelName, syncTokens: config.syncTokens, syncLogout: config.syncLogout }, {
5203
+ accessToken: context.accessToken,
5204
+ refreshToken: context.refreshToken,
5205
+ name: context.name,
5206
+ applyTokens: context.applyTokens,
5207
+ logout: context.logout,
5208
+ });
5006
5209
  if (config.leaderElection === false)
5007
5210
  return {};
5008
- const election = setupLeaderElection();
5211
+ const election = setupLeaderElection({ name: context.name });
5009
5212
  instance = { isLeader: election.isLeader, instanceCount: election.instanceCount };
5010
- return { isLeader: () => election.isLeader(), leaderElection: instance };
5213
+ return {
5214
+ isLeader: () => election.isLeader(),
5215
+ leaderElection: instance,
5216
+ refreshCoordination: { request: election.requestRefresh, requests$: election.refreshRequests$ },
5217
+ };
5011
5218
  };
5012
5219
  return Object.assign(setup, { earlySetup });
5013
5220
  };
@@ -5032,7 +5239,7 @@ const withPersistentAuth = (config) => {
5032
5239
  };
5033
5240
  };
5034
5241
  const createPersistentAuthFeature = (config, context) => {
5035
- const { refreshToken } = context;
5242
+ const { refreshToken, executionState } = context;
5036
5243
  const cookieName = config.cookie?.name ?? 'etAuth';
5037
5244
  const rememberMeStorageKey = `${cookieName}-rememberMe`;
5038
5245
  const route = injectRoute();
@@ -5049,25 +5256,36 @@ const createPersistentAuthFeature = (config, context) => {
5049
5256
  };
5050
5257
  const rememberMeSignal = signal(initializeRememberMe(), /* @ts-ignore */
5051
5258
  ...(ngDevMode ? [{ debugName: "rememberMeSignal" }] : /* istanbul ignore next */ []));
5259
+ const cookieDomain = () => config.cookie?.domain ?? getDomain() ?? 'localhost';
5260
+ const cookiePath = () => config.cookie?.path ?? '/';
5261
+ const removeCookie = () => deleteCookie(cookieName, cookiePath(), cookieDomain());
5052
5262
  effect(() => {
5053
5263
  const token = refreshToken();
5054
5264
  const rememberMe = rememberMeSignal();
5055
- const domain = config.cookie?.domain ?? getDomain() ?? 'localhost';
5056
- const path = config.cookie?.path ?? '/';
5057
- const sameSite = config.cookie?.sameSite ?? 'lax';
5058
- if (token) {
5059
- const encryptedToken = encryptToken(token);
5060
- if (rememberMe) {
5061
- const expiresInDays = config.cookie?.expiresInDays ?? 30;
5062
- setCookie(cookieName, encryptedToken, expiresInDays, domain, path, sameSite);
5063
- }
5064
- else {
5065
- setCookie(cookieName, encryptedToken, null, domain, path, sameSite);
5066
- }
5067
- }
5068
- else {
5069
- deleteCookie(cookieName, path, domain);
5265
+ // A missing token is not a reason to drop the cookie. It is missing on every startup - `tryLogin`
5266
+ // reads the cookie synchronously and the auto-login only resolves a tick later - so deleting here
5267
+ // would throw away a 30-day refresh token before it was ever used. Deletion is driven by the events
5268
+ // that actually end a session instead, below.
5269
+ if (!token)
5270
+ return;
5271
+ const expiresInDays = rememberMe ? (config.cookie?.expiresInDays ?? 30) : null;
5272
+ setCookie(cookieName, encryptToken(token), expiresInDays, cookieDomain(), cookiePath(), config.cookie?.sameSite ?? 'lax');
5273
+ });
5274
+ effect(() => {
5275
+ const state = executionState();
5276
+ if (!state)
5277
+ return;
5278
+ if (state.type === 'logout') {
5279
+ removeCookie();
5280
+ return;
5070
5281
  }
5282
+ // A token the server itself rejected is worth nothing on the next visit; anything else (offline, a
5283
+ // 500, an aborted startup) leaves it in place so a reload can try again.
5284
+ const wasRejected = state.state === 'error' &&
5285
+ (state.type === 'tokenRefresh' || state.type === 'autoLogin') &&
5286
+ (state.error.code === 401 || state.error.code === 403);
5287
+ if (wasRejected)
5288
+ removeCookie();
5071
5289
  });
5072
5290
  const tryLogin = () => {
5073
5291
  const currentRoute = route();
@@ -5313,7 +5531,7 @@ const createTrackingFeature = (context, config) => {
5313
5531
  const refreshToken = context.refreshToken();
5314
5532
  const hasTokens = !!(accessToken || refreshToken);
5315
5533
  if (hadTokens && !hasTokens) {
5316
- emit('logout', undefined);
5534
+ emit('logout', { cause: untracked(context.sessionEndCause) });
5317
5535
  }
5318
5536
  hadTokens = hasTokens;
5319
5537
  });
@@ -5460,7 +5678,12 @@ const createQueryDevtoolsFormLinks = () => {
5460
5678
  };
5461
5679
  };
5462
5680
 
5463
- const pathsEqual = (a, b) => a.length === b.length && a.every((step, index) => step === b[index]);
5681
+ const isPathAtOrBelow = (path, root) => path.length >= root.length && root.every((step, index) => step === path[index]);
5682
+ /**
5683
+ * Whether any of `entries` is armed at `path` or anywhere below it - what the panel's menu asks before
5684
+ * offering "Reset", so the destructive action is hidden where it would be a silent no-op.
5685
+ */
5686
+ const hasQueryDevtoolsOverridesAtPath = (entries, path) => entries.some((entry) => isPathAtOrBelow(entry.op.path, path));
5464
5687
  /**
5465
5688
  * Walks `path` into `root`, stopping as soon as a step's container doesn't exist. An empty path
5466
5689
  * resolves to `root` itself, which is how an op targets the response's top-level value.
@@ -5668,15 +5891,84 @@ const resizePagination = (value, shape, mode, amount) => {
5668
5891
  const STRING_PRESETS = {
5669
5892
  short: 'Ab',
5670
5893
  long: /* @__PURE__ */ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt. '.repeat(4),
5894
+ longWord: 'Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz',
5671
5895
  unicode: 'مرحبا بالعالم 👋 日本語テスト 🎉 Ñoño',
5672
5896
  };
5673
- const stringPresetValue = (preset, custom) => preset === 'custom' ? (custom ?? '') : STRING_PRESETS[preset];
5897
+ const stringPresetValue = (preset, custom) => custom ?? (preset === 'custom' ? '' : STRING_PRESETS[preset]);
5674
5898
  const NUMBER_PRESETS = {
5675
5899
  zero: 0,
5676
5900
  negative: -1,
5677
5901
  huge: Number.MAX_SAFE_INTEGER,
5678
5902
  };
5679
- const numberPresetValue = (preset, custom) => preset === 'custom' ? (custom ?? 0) : NUMBER_PRESETS[preset];
5903
+ const numberPresetValue = (preset, custom) => custom ?? (preset === 'custom' ? 0 : NUMBER_PRESETS[preset]);
5904
+ const pickRandom = (values) => values[Math.floor(Math.random() * values.length)];
5905
+ const randomIntBetween = (min, max) => min + Math.floor(Math.random() * (max - min + 1));
5906
+ const randomHex = (length) => {
5907
+ let out = '';
5908
+ while (out.length < length)
5909
+ out += Math.random().toString(16).slice(2);
5910
+ return out.slice(0, length);
5911
+ };
5912
+ const SHORT_WORDS = ['Ok', 'Ada', 'Fig', 'Kiwi', 'Nova', 'Echo', 'Amber', 'Delta', 'Mango', 'Quartz', 'Zephyr'];
5913
+ const LOREM_SENTENCE = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ';
5914
+ const LONG_COMPOUNDS = [
5915
+ 'Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz',
5916
+ 'Donaudampfschifffahrtsgesellschaftskapitänskajütenschlüssel',
5917
+ 'Kraftfahrzeughaftpflichtversicherungsbescheinigung',
5918
+ ];
5919
+ const UNICODE_SAMPLES = [
5920
+ 'مرحبا بالعالم 👋 اختبار النص',
5921
+ '日本語テスト 🎉 カタカナとひらがな',
5922
+ '한국어 테스트 💡 조합형 한글',
5923
+ 'עברית מימין לשמאל 🚀 בדיקה',
5924
+ 'Ñoño über façade — Ω≈ç√∫ 🧪',
5925
+ ];
5926
+ /**
5927
+ * Generates a fresh sample value for a string preset, so filling twenty fields yields twenty different
5928
+ * values instead of twenty identical ones. Call it at arm time and store the result in the op's
5929
+ * `custom` field - generating inside apply would reshuffle the response on every refetch. `longWord`
5930
+ * is a single unbreakable token (a compound word, a URL, a hex blob) - it tests overflow, where
5931
+ * `long` tests wrapping.
5932
+ */
5933
+ const generateQueryDevtoolsStringPreset = (preset) => {
5934
+ switch (preset) {
5935
+ case 'short':
5936
+ return pickRandom(SHORT_WORDS);
5937
+ case 'long': {
5938
+ const length = randomIntBetween(80, 480);
5939
+ return LOREM_SENTENCE.repeat(Math.ceil(length / LOREM_SENTENCE.length))
5940
+ .slice(0, length)
5941
+ .trimEnd();
5942
+ }
5943
+ case 'longWord':
5944
+ return pickRandom([
5945
+ () => pickRandom(LONG_COMPOUNDS),
5946
+ () => `https://api.example.com/v2/resources/${randomHex(24)}/attachments?signature=${randomHex(randomIntBetween(24, 96))}`,
5947
+ () => randomHex(randomIntBetween(64, 160)),
5948
+ ])();
5949
+ case 'unicode':
5950
+ return pickRandom(UNICODE_SAMPLES);
5951
+ }
5952
+ };
5953
+ /**
5954
+ * Generates a fresh sample value for a number preset - same arm-time contract as
5955
+ * {@link generateQueryDevtoolsStringPreset}. `negative` and `huge` vary in magnitude so a batch fill
5956
+ * produces varied digit counts; `zero` is always `0`.
5957
+ */
5958
+ const generateQueryDevtoolsNumberPreset = (preset) => {
5959
+ switch (preset) {
5960
+ case 'zero':
5961
+ return 0;
5962
+ case 'negative': {
5963
+ const magnitude = 10 ** randomIntBetween(0, 8);
5964
+ return -randomIntBetween(magnitude, magnitude * 10 - 1);
5965
+ }
5966
+ case 'huge': {
5967
+ const magnitude = 10 ** randomIntBetween(9, 15);
5968
+ return Math.min(randomIntBetween(magnitude, magnitude * 10 - 1), Number.MAX_SAFE_INTEGER);
5969
+ }
5970
+ }
5971
+ };
5680
5972
  const DAY_MS = 24 * 60 * 60 * 1000;
5681
5973
  /** `invalid` is deliberately unparseable - "the API sent a date my code can't parse" is a real bug class. */
5682
5974
  const datePresetValue = (preset) => {
@@ -5768,7 +6060,7 @@ const createQueryDevtoolsOverrides = () => {
5768
6060
  let counter = 0;
5769
6061
  const arm = (op) => {
5770
6062
  if (op.type === 'reset') {
5771
- list.update((current) => current.filter((entry) => !pathsEqual(entry.op.path, op.path)));
6063
+ list.update((current) => current.filter((entry) => !isPathAtOrBelow(entry.op.path, op.path)));
5772
6064
  return;
5773
6065
  }
5774
6066
  list.update((current) => [...current, { id: `override-${++counter}`, op }]);
@@ -5820,10 +6112,27 @@ const estimateBodySize = (body) => {
5820
6112
  /** How many runs a query keeps. Enough to see a stampede or a chain without growing unbounded. */
5821
6113
  const RUN_HISTORY = 25;
5822
6114
  /**
5823
- * How many of those runs keep their response body. Bodies dominate what the buffer retains, and a diff
5824
- * only ever looks a couple of runs back.
6115
+ * How many of those runs keep a body - a response, or the body an error came with. Bodies dominate what
6116
+ * the buffer retains, which is why this is far below {@link RUN_HISTORY} and why raising it is a memory
6117
+ * decision the app has to make for itself.
5825
6118
  */
5826
- const RESPONSE_HISTORY = 5;
6119
+ const DEFAULT_RESPONSE_HISTORY = 5;
6120
+ let responseHistory = DEFAULT_RESPONSE_HISTORY;
6121
+ /**
6122
+ * How many bodies each query retains - {@link QueryDevtoolsOptions.responseHistory}, or `5`. Only a run
6123
+ * that still holds one can be an end of a response diff, so the panel reads this to say why an older run
6124
+ * can no longer be picked.
6125
+ *
6126
+ * Part of the devtools contract. **Not part of the general public contract.**
6127
+ */
6128
+ const queryDevtoolsResponseHistory = () => responseHistory;
6129
+ /**
6130
+ * Sets how many bodies each query retains. Called by `provideQueryDevtools()`; nothing else may call it.
6131
+ * @internal
6132
+ */
6133
+ const setQueryDevtoolsResponseHistory = (count) => {
6134
+ responseHistory = count === undefined ? DEFAULT_RESPONSE_HISTORY : Math.max(1, Math.floor(count));
6135
+ };
5827
6136
  /** The index of the newest run still in flight, or -1 when none is. */
5828
6137
  const lastPendingRunIndex = (runs) => {
5829
6138
  for (let index = runs.length - 1; index >= 0; index--) {
@@ -5833,17 +6142,25 @@ const lastPendingRunIndex = (runs) => {
5833
6142
  return -1;
5834
6143
  };
5835
6144
  /**
5836
- * Drops the response body of every run past the newest {@link RESPONSE_HISTORY} that hold one. Mutates
5837
- * the array it is given, which is always one this module just built.
6145
+ * Drops the body of every run past the newest {@link queryDevtoolsResponseHistory} that hold one - a
6146
+ * response body and an error body count against the same budget, since a run only ever has one of the
6147
+ * two. Mutates the array it is given, which is always one this module just built.
5838
6148
  */
5839
- const trimRetainedResponses = (runs) => {
6149
+ const trimRetainedBodies = (runs) => {
6150
+ const budget = responseHistory;
5840
6151
  let kept = 0;
5841
6152
  for (let index = runs.length - 1; index >= 0; index--) {
5842
6153
  const run = runs[index];
5843
- if (!run?.hasResponse)
6154
+ if (!run || (!run.hasResponse && !run.error?.hasBody))
5844
6155
  continue;
5845
- if (++kept > RESPONSE_HISTORY)
5846
- runs[index] = { ...run, response: null, hasResponse: false };
6156
+ if (++kept <= budget)
6157
+ continue;
6158
+ runs[index] = {
6159
+ ...run,
6160
+ response: null,
6161
+ hasResponse: false,
6162
+ error: run.error?.hasBody ? { ...run.error, body: null, hasBody: false } : run.error,
6163
+ };
5847
6164
  }
5848
6165
  return runs;
5849
6166
  };
@@ -5880,7 +6197,7 @@ const createQueryDevtoolsStats = () => {
5880
6197
  let runCounter = 0;
5881
6198
  const appendRun = (run, previous) => {
5882
6199
  const appended = { ...run, index: ++runCounter };
5883
- runs.set(trimRetainedResponses([...previous, appended].slice(-RUN_HISTORY)));
6200
+ runs.set(trimRetainedBodies([...previous, appended].slice(-RUN_HISTORY)));
5884
6201
  };
5885
6202
  /**
5886
6203
  * Ends the newest run still in flight. A response with no run of the query's own waiting for it came
@@ -5893,7 +6210,7 @@ const createQueryDevtoolsStats = () => {
5893
6210
  appendRun({ ...end, startedAt: end.endedAt, didRequest: false, url: lastUrl, sentBytes: 0, attempts: 0 }, current);
5894
6211
  return;
5895
6212
  }
5896
- runs.set(trimRetainedResponses(current.map((run, index) => (index === pending ? { ...run, ...end } : run))));
6213
+ runs.set(trimRetainedBodies(current.map((run, index) => (index === pending ? { ...run, ...end } : run))));
5897
6214
  };
5898
6215
  const recordExecution = ({ didRequest, body, url }) => {
5899
6216
  const now = Date.now();
@@ -5923,6 +6240,7 @@ const createQueryDevtoolsStats = () => {
5923
6240
  receivedBytes: 0,
5924
6241
  response: null,
5925
6242
  hasResponse: false,
6243
+ error: null,
5926
6244
  }, runs().map((run) => (run.status === 'pending' ? { ...run, status: 'aborted', endedAt: now } : run)));
5927
6245
  };
5928
6246
  const recordResponse = (payload) => {
@@ -5945,15 +6263,31 @@ const createQueryDevtoolsStats = () => {
5945
6263
  receivedBytes: received.bytes,
5946
6264
  response: payload.body,
5947
6265
  hasResponse: true,
6266
+ error: null,
5948
6267
  });
5949
6268
  };
5950
6269
  const recordError = (options) => {
6270
+ const error = options?.error;
5951
6271
  stats.update((current) => ({
5952
6272
  ...current,
5953
6273
  errors: current.errors + 1,
5954
6274
  lastResponseWasFaulted: options?.faulted ?? false,
5955
6275
  }));
5956
- endRun({ endedAt: Date.now(), status: 'error', receivedBytes: 0, response: null, hasResponse: false });
6276
+ endRun({
6277
+ endedAt: Date.now(),
6278
+ status: 'error',
6279
+ receivedBytes: 0,
6280
+ response: null,
6281
+ hasResponse: false,
6282
+ error: error
6283
+ ? {
6284
+ code: error.code,
6285
+ messages: queryErrorMessages(error),
6286
+ body: error.raw.error,
6287
+ hasBody: error.raw.error !== null && error.raw.error !== undefined,
6288
+ }
6289
+ : null,
6290
+ });
5957
6291
  };
5958
6292
  const recordRetry = ({ attempt }) => {
5959
6293
  const current = runs();
@@ -6015,6 +6349,106 @@ const sumQueryDevtoolsStats = (handles) => {
6015
6349
  return total;
6016
6350
  };
6017
6351
 
6352
+ /**
6353
+ * How many destroyed queries the registry keeps. A tombstone holds the last response body it captured,
6354
+ * so the list is capped for the same reason the repository caps its unused entries.
6355
+ */
6356
+ const MAX_QUERY_DEVTOOLS_TOMBSTONES = 50;
6357
+ const frozen = (value) => () => value;
6358
+ const noop = () => undefined;
6359
+ /**
6360
+ * Reads one signal off a handle that is being destroyed. A query whose injector is already gone can
6361
+ * throw here, and losing one field is better than losing the whole tombstone.
6362
+ */
6363
+ const read = (get, fallback) => {
6364
+ try {
6365
+ return get();
6366
+ }
6367
+ catch {
6368
+ return fallback;
6369
+ }
6370
+ };
6371
+ /**
6372
+ * A destroyed query's last state, shaped like the query itself: every signal the panel reads is
6373
+ * replaced by a function returning the value it held when the query went away, and every action is a
6374
+ * no-op. Rendering a tombstone therefore costs the panel no second code path - the drawer reads
6375
+ * `entry.handle` exactly as it does for a live query, and gates its actions on
6376
+ * {@link QueryDevtoolsEntry.destroyedAt} instead.
6377
+ *
6378
+ * The captured request is a plain copy, not the request itself: the real one is destroyed the moment
6379
+ * its cache entry is, and holding it would keep its subscriptions and its cache entry alive.
6380
+ */
6381
+ const snapshotQueryDevtoolsHandle = (handle) => {
6382
+ const query = handle;
6383
+ if (!query || typeof query !== 'object')
6384
+ return handle;
6385
+ const live = read(() => query.subtle?.request() ?? null, null);
6386
+ const request = live
6387
+ ? {
6388
+ method: live.method,
6389
+ url: live.url,
6390
+ args: live.args,
6391
+ execute: () => false,
6392
+ destroy: () => false,
6393
+ isStale: frozen(false),
6394
+ loading: frozen(null),
6395
+ error: read(() => frozen(live.error()), frozen(null)),
6396
+ response: read(() => frozen(live.response()), frozen(null)),
6397
+ currentEvent: read(() => frozen(live.currentEvent()), frozen(null)),
6398
+ expiresAt: read(() => frozen(live.expiresAt()), frozen(null)),
6399
+ subtle: {
6400
+ retryState: frozen(null),
6401
+ attempts: read(() => frozen(live.subtle.attempts()), frozen(1)),
6402
+ lastDurationMs: read(() => frozen(live.subtle.lastDurationMs()), frozen(null)),
6403
+ resolveHeaders: read(() => frozen(live.subtle.resolveHeaders()), frozen(undefined)),
6404
+ lastPersistedResponseAt: read(() => frozen(live.subtle.lastPersistedResponseAt()), frozen(null)),
6405
+ lastExternalResponseAt: read(() => frozen(live.subtle.lastExternalResponseAt()), frozen(null)),
6406
+ },
6407
+ }
6408
+ : null;
6409
+ return {
6410
+ id: read(() => frozen(query.id()), frozen(null)),
6411
+ args: read(() => frozen(query.args()), frozen(null)),
6412
+ response: read(() => frozen(query.response()), frozen(null)),
6413
+ error: read(() => frozen(query.error()), frozen(null)),
6414
+ latestHttpEvent: read(() => frozen(query.latestHttpEvent()), frozen(null)),
6415
+ lastTimeExecutedAt: read(() => frozen(query.lastTimeExecutedAt()), frozen(null)),
6416
+ triggeredBy: read(() => frozen(query.triggeredBy()), frozen(null)),
6417
+ executionState: read(() => frozen(query.executionState()), frozen(null)),
6418
+ // A tombstone never loads: a forced loading state captured mid-flight would spin forever.
6419
+ loading: frozen(null),
6420
+ execute: () => false,
6421
+ reset: noop,
6422
+ subtle: {
6423
+ request: frozen(request),
6424
+ destroy: noop,
6425
+ setResponse: noop,
6426
+ setLoading: noop,
6427
+ setError: noop,
6428
+ },
6429
+ };
6430
+ };
6431
+ /**
6432
+ * The entry a destroyed query leaves behind: the same id, kind and metadata, a frozen
6433
+ * {@link snapshotQueryDevtoolsHandle} in place of the live query, and no `element` - a DOM node whose
6434
+ * component is gone must not be kept alive by the panel.
6435
+ *
6436
+ * `stats` is carried over as-is. The recorder closes over its own signals rather than over the query,
6437
+ * so the run history that makes a failed request readable survives without retaining anything.
6438
+ */
6439
+ const tombstoneOf = (entry, destroyedAt) => {
6440
+ const { element: _element, ...meta } = entry.meta;
6441
+ return {
6442
+ id: entry.id,
6443
+ kind: entry.kind,
6444
+ meta,
6445
+ handle: snapshotQueryDevtoolsHandle(entry.handle),
6446
+ createdAt: entry.createdAt,
6447
+ destroyedAt,
6448
+ stats: entry.stats,
6449
+ };
6450
+ };
6451
+
6018
6452
  const entries = /* @__PURE__ */ signal([], /* @ts-ignore */
6019
6453
  ...(ngDevMode ? [{ debugName: "entries" }] : /* istanbul ignore next */ []));
6020
6454
  /**
@@ -6134,9 +6568,39 @@ const registerEntry = (registration) => {
6134
6568
  formLinks: registration.formLinks,
6135
6569
  overrides: registration.overrides,
6136
6570
  };
6137
- entries.update((list) => [...list, fullEntry]);
6138
- return () => entries.update((list) => list.filter((e) => e.id !== fullEntry.id));
6571
+ // A registration that brings its own id can repeat one a tombstone still holds; the live entry wins.
6572
+ entries.update((list) => [...list.filter((e) => !(e.id === id && e.destroyedAt)), fullEntry]);
6573
+ return () => entries.update((list) => {
6574
+ const index = list.findIndex((e) => e.id === fullEntry.id);
6575
+ if (index === -1)
6576
+ return list;
6577
+ const next = list.slice();
6578
+ // Only queries leave a tombstone. A stack, sequence, form or auth provider is a container whose
6579
+ // interesting state is the queries it owns - each of which tombstones on its own.
6580
+ if (fullEntry.kind !== 'query') {
6581
+ next.splice(index, 1);
6582
+ return next;
6583
+ }
6584
+ next[index] = tombstoneOf(fullEntry, Date.now());
6585
+ return capTombstones(next);
6586
+ });
6139
6587
  };
6588
+ /** Drops the oldest tombstones once there are more than {@link MAX_QUERY_DEVTOOLS_TOMBSTONES} of them. */
6589
+ const capTombstones = (list) => {
6590
+ const excess = list.filter((e) => e.destroyedAt).length - MAX_QUERY_DEVTOOLS_TOMBSTONES;
6591
+ if (excess <= 0)
6592
+ return list;
6593
+ const doomed = new Set(list
6594
+ .filter((e) => e.destroyedAt)
6595
+ .sort((a, b) => (a.destroyedAt ?? 0) - (b.destroyedAt ?? 0))
6596
+ .slice(0, excess));
6597
+ return list.filter((e) => !doomed.has(e));
6598
+ };
6599
+ /**
6600
+ * Forgets every destroyed entry the registry is holding - the panel's "clear gone" action. Live
6601
+ * entries are untouched.
6602
+ */
6603
+ const clearQueryDevtoolsTombstones = () => entries.update((list) => list.filter((e) => !e.destroyedAt));
6140
6604
  /**
6141
6605
  * Enables the `@ethlete/query` devtools. Add this to your application providers (e.g. in
6142
6606
  * `bootstrapApplication`) to make query clients, queries, stacks, sequences and auth providers
@@ -6152,7 +6616,8 @@ const registerEntry = (registration) => {
6152
6616
  * });
6153
6617
  * ```
6154
6618
  */
6155
- const provideQueryDevtools = () => {
6619
+ const provideQueryDevtools = (options) => {
6620
+ setQueryDevtoolsResponseHistory(options?.responseHistory);
6156
6621
  setQueryDevtoolsRegistrar(registerEntry);
6157
6622
  setQueryDevtoolsStatsFactory(createQueryDevtoolsStats);
6158
6623
  setQueryDevtoolsFormLinksFactory(createQueryDevtoolsFormLinks);
@@ -6235,6 +6700,7 @@ const createGqlExecuteFn = (executeOptions) => {
6235
6700
  executeState,
6236
6701
  args: computedArgs,
6237
6702
  options,
6703
+ isRefreshable: executeOptions.creatorInternals.method === 'QUERY',
6238
6704
  });
6239
6705
  };
6240
6706
  exec['reset'] = reset;
@@ -6272,6 +6738,8 @@ const createSecureGqlExecuteFn = (executeOptions) => {
6272
6738
  authProvider,
6273
6739
  deps: executeOptions.deps,
6274
6740
  state: executeOptions.state,
6741
+ autoExecutes: shouldAutoExecuteGqlQuery(executeOptions.creatorInternals.method) &&
6742
+ !executeOptions.queryConfig.onlyManualExecution,
6275
6743
  transformAuthAndExec: (executeArgs, _tokens, headers, executeState) => {
6276
6744
  const { args, options: runOptions } = executeArgs ?? {};
6277
6745
  const query = transformGql(executeOptions.creatorInternals.query);
@@ -11045,6 +11513,7 @@ const buildDefaults = (fields) => {
11045
11513
  }
11046
11514
  return out;
11047
11515
  };
11516
+ const MAX_RESET_PASSES = 10;
11048
11517
  const changedKeysBetween = (previous, current) => Object.keys(current).filter((key) => !equal(previous?.[key], current[key]));
11049
11518
  /** Best-effort URL string → value coercion, mirroring the auto-transform of the legacy QueryForm. */
11050
11519
  const autoCoerce = (raw, defaultValue) => {
@@ -11098,12 +11567,11 @@ const nextQueryFormDevtoolsId = (name) => {
11098
11567
  return `query-form|${name}#${seq}`;
11099
11568
  };
11100
11569
  /**
11101
- * A signals-first query form: binds on-screen controls to typed fields, keeps a
11102
- * debounced committed value, syncs to the URL query params, and resets dependent
11103
- * fields via the `isResetBy` graph - all as signals.
11570
+ * Define a {@link QueryFormSignals}. Call `.observe()` to start syncing with the
11571
+ * URL. Must be called in an injection context.
11104
11572
  *
11105
11573
  * @example
11106
- * const qf = createQueryForm({
11574
+ * const qf = defineQueryForm({
11107
11575
  * fields: {
11108
11576
  * search: searchQueryField(),
11109
11577
  * sort: sortQueryField(),
@@ -11112,191 +11580,150 @@ const nextQueryFormDevtoolsId = (name) => {
11112
11580
  * }).observe();
11113
11581
  * // template: <input etInput [formField]="qf.fields.search" />
11114
11582
  */
11115
- class QueryFormSignals {
11116
- constructor(config) {
11117
- this.router = inject(Router);
11118
- this.route = inject(ActivatedRoute);
11119
- this.injector = inject(Injector);
11120
- this.queryParamChanges = injectQueryParamChanges();
11121
- this.previous = signal(null, /* @ts-ignore */
11122
- ...(ngDevMode ? [{ debugName: "previous" }] : /* istanbul ignore next */ []));
11123
- this.observing = signal(false, /* @ts-ignore */
11124
- ...(ngDevMode ? [{ debugName: "observing" }] : /* istanbul ignore next */ []));
11125
- this.pendingTimer = null;
11126
- this.commitPending = signal(false, /* @ts-ignore */
11127
- ...(ngDevMode ? [{ debugName: "commitPending" }] : /* istanbul ignore next */ []));
11128
- this.skipNextResets = false;
11129
- assertInInjectionContext(QueryFormSignals);
11130
- this._fields = config.fields;
11131
- this.prefix = config.queryParamPrefix;
11132
- this.defaults = buildDefaults(config.fields);
11133
- this.defaultValue = clone(this.defaults);
11134
- const initial = clone(this.defaults);
11135
- this.model = signal(initial, /* @ts-ignore */
11136
- ...(ngDevMode ? [{ debugName: "model" }] : /* istanbul ignore next */ []));
11137
- this.committed = signal(clone(initial), /* @ts-ignore */
11138
- ...(ngDevMode ? [{ debugName: "committed" }] : /* istanbul ignore next */ []));
11139
- this.fields = form(this.model);
11140
- const devtoolsName = config.name ?? (typeof this.prefix === 'string' ? this.prefix : 'form');
11141
- const devtoolsId = isQueryDevtoolsEnabled() ? nextQueryFormDevtoolsId(devtoolsName) : null;
11142
- // Reading `value` is how a query's args pick the form up, so the devtools learn which query a form
11143
- // drives by noting the read - see `QueryDevtoolsFormLinksHandle`.
11144
- this.value = devtoolsId
11145
- ? computed(() => {
11146
- noteQueryFormRead(devtoolsId);
11147
- return this.committed();
11148
- })
11149
- : this.committed.asReadonly();
11150
- this.previousValue = this.previous.asReadonly();
11151
- this.changes = computed(() => ({ previousValue: this.previous(), currentValue: this.committed() }), /* @ts-ignore */
11152
- ...(ngDevMode ? [{ debugName: "changes" }] : /* istanbul ignore next */ []));
11153
- this.activeFilterCount = computed(() => computeFilterCount(this._fields, this.committed(), this.defaults), /* @ts-ignore */
11154
- ...(ngDevMode ? [{ debugName: "activeFilterCount" }] : /* istanbul ignore next */ []));
11155
- // React to live control edits: schedule a debounced commit.
11156
- effect(() => {
11157
- const live = this.model();
11158
- untracked(() => this.onLiveChange(live));
11159
- });
11160
- // React to navigation (back/forward, external links): apply URL → form immediately.
11161
- effect(() => {
11162
- const changes = this.queryParamChanges();
11163
- untracked(() => {
11164
- if (!this.observing() || this.observeOptions?.syncOnNavigation === false)
11165
- return;
11166
- this.applyFromUrl(changes);
11583
+ const defineQueryForm = (config) => {
11584
+ assertInInjectionContext(defineQueryForm);
11585
+ const router = inject(Router);
11586
+ const route = inject(ActivatedRoute);
11587
+ const injector = inject(Injector);
11588
+ const destroyRef = inject(DestroyRef);
11589
+ const queryParamChanges = injectQueryParamChanges();
11590
+ const fieldDefs = config.fields;
11591
+ const prefix = config.queryParamPrefix;
11592
+ const defaults = buildDefaults(fieldDefs);
11593
+ const defaultValue = clone(defaults);
11594
+ /** Live field values (updated immediately by bound controls). */
11595
+ const model = signal(clone(defaults), /* @ts-ignore */
11596
+ ...(ngDevMode ? [{ debugName: "model" }] : /* istanbul ignore next */ []));
11597
+ /** The committed (debounced) value that drives `value`, the URL and the filter count. */
11598
+ const committed = signal(clone(defaults), /* @ts-ignore */
11599
+ ...(ngDevMode ? [{ debugName: "committed" }] : /* istanbul ignore next */ []));
11600
+ const previous = signal(null, /* @ts-ignore */
11601
+ ...(ngDevMode ? [{ debugName: "previous" }] : /* istanbul ignore next */ []));
11602
+ const observing = signal(false, /* @ts-ignore */
11603
+ ...(ngDevMode ? [{ debugName: "observing" }] : /* istanbul ignore next */ []));
11604
+ const commitPending = signal(false, /* @ts-ignore */
11605
+ ...(ngDevMode ? [{ debugName: "commitPending" }] : /* istanbul ignore next */ []));
11606
+ let observeOptions;
11607
+ let pendingTimer = null;
11608
+ let skipNextResets = false;
11609
+ const fields = form(model);
11610
+ const activeFilterCount = computed(() => computeFilterCount(fieldDefs, committed(), defaults), /* @ts-ignore */
11611
+ ...(ngDevMode ? [{ debugName: "activeFilterCount" }] : /* istanbul ignore next */ []));
11612
+ const paramKey = (key) => {
11613
+ if (!prefix)
11614
+ return key;
11615
+ return `${typeof prefix === 'string' ? prefix : prefix()}-${key}`;
11616
+ };
11617
+ /** What a field puts in the URL for a given value, or `undefined` when it writes nothing. */
11618
+ const queryParamFor = (key, def, value) => {
11619
+ const isDefault = equal(value, defaults[key]);
11620
+ const writeToUrl = def.appendToUrl !== false;
11621
+ const writeDefault = def.appendDefaultValueToUrl === true;
11622
+ if (!writeToUrl || (isDefault && !writeDefault))
11623
+ return undefined;
11624
+ if (def.valueToQueryParam)
11625
+ return def.valueToQueryParam(value);
11626
+ return value === null ? ET_NULL_VALUE : value;
11627
+ };
11628
+ const deserialize = (def, raw) => {
11629
+ if (def.queryParamToValue)
11630
+ return def.queryParamToValue(raw);
11631
+ if (def.skipAutoTransform)
11632
+ return raw;
11633
+ return autoCoerce(raw, resolveDefault(def));
11634
+ };
11635
+ const clearTimer = () => {
11636
+ if (pendingTimer !== null) {
11637
+ clearTimeout(pendingTimer);
11638
+ pendingTimer = null;
11639
+ }
11640
+ commitPending.set(false);
11641
+ };
11642
+ const writeToUrl = (value) => {
11643
+ const queryParams = {};
11644
+ for (const [key, def] of Object.entries(fieldDefs)) {
11645
+ queryParams[paramKey(key)] = queryParamFor(key, def, value[key]);
11646
+ }
11647
+ queueMicrotask(() => {
11648
+ router.navigate([], {
11649
+ queryParams,
11650
+ queryParamsHandling: 'merge',
11651
+ replaceUrl: observeOptions?.replaceUrl,
11167
11652
  });
11168
11653
  });
11169
- const destroyRef = inject(DestroyRef);
11170
- destroyRef.onDestroy(() => this.cleanup());
11171
- if (devtoolsId) {
11172
- destroyRef.onDestroy(registerQueryDevtoolsEntry({
11173
- id: devtoolsId,
11174
- kind: 'query-form',
11175
- handle: this.devtoolsHandle(),
11176
- meta: { name: devtoolsName },
11177
- }));
11178
- }
11179
- }
11180
- observe(options) {
11181
- if (this.observing()) {
11182
- if (isDevMode()) {
11183
- console.warn('QueryFormSignals.observe() was called more than once. Ignoring the extra call.');
11654
+ };
11655
+ const applyResets = (live, changedKeys) => {
11656
+ const next = { ...live };
11657
+ for (const [key, def] of Object.entries(fieldDefs)) {
11658
+ const resets = def.isResetBy;
11659
+ if (!resets?.length)
11660
+ continue;
11661
+ const triggered = resets.some((resetKey) => {
11662
+ if (!(resetKey in fieldDefs)) {
11663
+ if (isDevMode()) {
11664
+ console.warn(`defineQueryForm: isResetBy references unknown field "${resetKey}". Is it a typo?`);
11665
+ }
11666
+ return false;
11667
+ }
11668
+ return changedKeys.includes(resetKey);
11669
+ });
11670
+ if (!triggered)
11671
+ continue;
11672
+ const fieldDefault = defaults[key];
11673
+ if (!equal(next[key], fieldDefault)) {
11674
+ next[key] = fieldDefault;
11184
11675
  }
11185
- return this;
11186
11676
  }
11187
- this.observeOptions = options;
11188
- this.observing.set(true);
11189
- if (options?.syncOnNavigation !== false) {
11190
- this.applyFromUrl(this.route.snapshot.queryParams);
11191
- }
11192
- // Commit whatever the model holds now (URL-restored or programmatic defaults).
11193
- this.flush();
11194
- return this;
11195
- }
11196
- unobserve() {
11197
- this.cleanup();
11198
- }
11199
- setValue(value, options) {
11200
- if (options?.skipResets)
11201
- this.skipNextResets = true;
11202
- this.model.set(clone(value));
11203
- }
11204
- patchValue(value, options) {
11205
- if (options?.skipResets)
11206
- this.skipNextResets = true;
11207
- this.model.update((cur) => ({ ...cur, ...value }));
11208
- }
11209
- resetFieldToDefault(key, options) {
11210
- if (options?.skipResets)
11211
- this.skipNextResets = true;
11212
- this.model.update((cur) => ({ ...cur, [key]: this.defaults[key] }));
11213
- }
11214
- resetFieldsToDefault(keys, options) {
11215
- if (options?.skipResets)
11216
- this.skipNextResets = true;
11217
- this.model.update((cur) => {
11218
- const next = { ...cur };
11219
- for (const key of keys) {
11220
- next[key] = this.defaults[key];
11221
- }
11222
- return next;
11223
- });
11224
- }
11225
- resetAllFieldsToDefault(options) {
11226
- const skip = new Set((options?.skipFields ?? []).map((key) => key));
11227
- const keys = Object.keys(this._fields).filter((key) => !skip.has(key));
11228
- this.resetFieldsToDefault(keys, options);
11229
- }
11230
- /** Create a detached editor over the same fields, seeded from the current committed value. */
11231
- branch() {
11232
- return createBranch(this._fields, clone(this.committed()), this.injector);
11233
- }
11677
+ return next;
11678
+ };
11234
11679
  /**
11235
- * The live view `<et-query-devtools>` renders. Only built while the devtools are installed - it walks
11236
- * every field on every change, which an app without them should not pay for.
11680
+ * Resets are transitive: a field this pass reset counts as changed in the next one, so
11681
+ * `country → league → team` clears `team` as well when only `country` moved. Passes repeat until
11682
+ * nothing moves, which has to happen before `committed` is written - one query execution for the
11683
+ * whole chain, not one per hop. The cap only guards a cyclic `isResetBy` graph; a field already at
11684
+ * its default stops triggering, so a well-formed graph settles in as many passes as it is deep.
11237
11685
  */
11238
- devtoolsHandle() {
11239
- const fields = computed(() => {
11240
- const committed = this.committed();
11241
- const live = this.model();
11242
- return Object.entries(this._fields).map(([key, def]) => {
11243
- const value = committed[key];
11244
- return {
11245
- key,
11246
- paramKey: this.paramKey(key),
11247
- value,
11248
- liveValue: live[key],
11249
- defaultValue: this.defaults[key],
11250
- isDefault: equal(value, this.defaults[key]),
11251
- queryParam: this.queryParamFor(key, def, value),
11252
- debounceMs: def.debounce ?? null,
11253
- isResetBy: def.isResetBy ?? [],
11254
- countsAsFilter: !IGNORED_FILTER_COUNT_FIELDS.includes(key) && !def.skipInFilterCount,
11255
- };
11256
- });
11257
- }, /* @ts-ignore */
11258
- ...(ngDevMode ? [{ debugName: "fields" }] : /* istanbul ignore next */ []));
11259
- return {
11260
- fields,
11261
- // Not `this.value` - reading that from the panel would link the form to whichever query happens
11262
- // to be building its args at the time.
11263
- value: computed(() => this.committed()),
11264
- previousValue: computed(() => this.previous() ?? null),
11265
- defaultValue: this.defaults,
11266
- activeFilterCount: this.activeFilterCount,
11267
- isAtDefaults: computed(() => equal(this.committed(), this.defaults)),
11268
- isCommitPending: this.commitPending.asReadonly(),
11269
- isObserving: this.observing.asReadonly(),
11270
- resetField: (key) => this.resetFieldToDefault(key),
11271
- resetAll: () => this.resetAllFieldsToDefault(),
11272
- };
11273
- }
11274
- onLiveChange(live) {
11275
- if (!this.observing())
11276
- return;
11277
- if (equal(live, this.committed()))
11686
+ const resolveResets = (prev, live) => {
11687
+ let next = live;
11688
+ for (let pass = 0; pass < MAX_RESET_PASSES; pass++) {
11689
+ const applied = applyResets(next, changedKeysBetween(prev, next));
11690
+ if (equal(applied, next))
11691
+ return applied;
11692
+ next = applied;
11693
+ }
11694
+ if (isDevMode()) {
11695
+ console.warn(`defineQueryForm: isResetBy did not settle within ${MAX_RESET_PASSES} passes. Check for a cycle.`);
11696
+ }
11697
+ return next;
11698
+ };
11699
+ const flush = () => {
11700
+ clearTimer();
11701
+ const live = model();
11702
+ const prev = committed();
11703
+ if (equal(live, prev)) {
11704
+ skipNextResets = false;
11278
11705
  return;
11279
- const changedKeys = changedKeysBetween(this.committed(), live);
11280
- const debounceMs = this.resolveDebounce(changedKeys, live);
11281
- this.clearTimer();
11282
- if (debounceMs === null) {
11283
- this.flush();
11284
11706
  }
11285
- else {
11286
- this.commitPending.set(true);
11287
- this.pendingTimer = setTimeout(() => {
11288
- this.pendingTimer = null;
11289
- this.flush();
11290
- }, debounceMs);
11707
+ const next = skipNextResets ? { ...live } : resolveResets(prev, live);
11708
+ skipNextResets = false;
11709
+ previous.set(clone(prev));
11710
+ committed.set(clone(next));
11711
+ // Reflect any reset overrides back into the bound controls. The effect re-runs
11712
+ // but no-ops because the model now equals the committed value.
11713
+ if (!equal(next, live)) {
11714
+ model.set(clone(next));
11291
11715
  }
11292
- }
11716
+ if (observing() && observeOptions?.writeToQueryParams !== false) {
11717
+ writeToUrl(next);
11718
+ }
11719
+ };
11293
11720
  /** Shortest-debounce-wins; `null` means commit immediately. */
11294
- resolveDebounce(changedKeys, live) {
11721
+ const resolveDebounce = (changedKeys, live) => {
11295
11722
  if (!changedKeys.length)
11296
11723
  return null;
11297
11724
  const times = [];
11298
11725
  for (const key of changedKeys) {
11299
- const def = this._fields[key];
11726
+ const def = fieldDefs[key];
11300
11727
  if (!def)
11301
11728
  return null;
11302
11729
  if (def.disableDebounceIfFalsy && !live[key])
@@ -11306,61 +11733,41 @@ class QueryFormSignals {
11306
11733
  times.push(def.debounce);
11307
11734
  }
11308
11735
  return Math.min(...times);
11309
- }
11310
- flush() {
11311
- this.clearTimer();
11312
- const live = this.model();
11313
- const previous = this.committed();
11314
- if (equal(live, previous)) {
11315
- this.skipNextResets = false;
11736
+ };
11737
+ const onLiveChange = (live) => {
11738
+ if (!observing())
11316
11739
  return;
11740
+ if (equal(live, committed()))
11741
+ return;
11742
+ const changedKeys = changedKeysBetween(committed(), live);
11743
+ const debounceMs = resolveDebounce(changedKeys, live);
11744
+ clearTimer();
11745
+ if (debounceMs === null) {
11746
+ flush();
11317
11747
  }
11318
- const changedKeys = changedKeysBetween(previous, live);
11319
- const next = this.skipNextResets ? { ...live } : this.applyResets(live, changedKeys);
11320
- this.skipNextResets = false;
11321
- this.previous.set(clone(previous));
11322
- this.committed.set(clone(next));
11323
- // Reflect any reset overrides back into the bound controls. The effect re-runs
11324
- // but no-ops because the model now equals the committed value.
11325
- if (!equal(next, live)) {
11326
- this.model.set(clone(next));
11327
- }
11328
- if (this.observing() && this.observeOptions?.writeToQueryParams !== false) {
11329
- this.writeToUrl(next);
11330
- }
11331
- }
11332
- applyResets(live, changedKeys) {
11333
- const next = { ...live };
11334
- for (const [key, def] of Object.entries(this._fields)) {
11335
- const resets = def.isResetBy;
11336
- if (!resets?.length)
11337
- continue;
11338
- const triggered = resets.some((resetKey) => {
11339
- if (!(resetKey in this._fields)) {
11340
- if (isDevMode()) {
11341
- console.warn(`QueryFormSignals: isResetBy references unknown field "${resetKey}". Is it a typo?`);
11342
- }
11343
- return false;
11344
- }
11345
- return changedKeys.includes(resetKey);
11346
- });
11347
- if (!triggered)
11348
- continue;
11349
- const defaultValue = this.defaults[key];
11350
- if (!equal(next[key], defaultValue)) {
11351
- next[key] = defaultValue;
11352
- }
11748
+ else {
11749
+ commitPending.set(true);
11750
+ pendingTimer = setTimeout(() => {
11751
+ pendingTimer = null;
11752
+ flush();
11753
+ }, debounceMs);
11353
11754
  }
11354
- return next;
11355
- }
11356
- applyFromUrl(params) {
11357
- const current = { ...this.committed() };
11755
+ };
11756
+ const commitFromUrl = (next) => {
11757
+ clearTimer();
11758
+ previous.set(clone(committed()));
11759
+ committed.set(clone(next));
11760
+ // Feed the URL value into the live model & bound controls; the effect no-ops (model === committed).
11761
+ model.set(clone(next));
11762
+ };
11763
+ const applyFromUrl = (params) => {
11764
+ const current = { ...committed() };
11358
11765
  let changed = false;
11359
- for (const [key, def] of Object.entries(this._fields)) {
11360
- const raw = params[this.paramKey(key)];
11766
+ for (const [key, def] of Object.entries(fieldDefs)) {
11767
+ const raw = params[paramKey(key)];
11361
11768
  if (raw === undefined)
11362
11769
  continue;
11363
- const value = raw === ET_PROPERTY_REMOVED ? this.defaults[key] : this.deserialize(def, raw);
11770
+ const value = raw === ET_PROPERTY_REMOVED ? defaults[key] : deserialize(def, raw);
11364
11771
  if (!equal(current[key], value)) {
11365
11772
  current[key] = value;
11366
11773
  changed = true;
@@ -11368,80 +11775,149 @@ class QueryFormSignals {
11368
11775
  }
11369
11776
  if (!changed)
11370
11777
  return;
11371
- this.commitFromUrl(current);
11372
- }
11373
- commitFromUrl(next) {
11374
- this.clearTimer();
11375
- this.previous.set(clone(this.committed()));
11376
- this.committed.set(clone(next));
11377
- // Feed the URL value into the live model & bound controls; the effect no-ops (model === committed).
11378
- this.model.set(clone(next));
11379
- }
11380
- deserialize(def, raw) {
11381
- if (def.queryParamToValue)
11382
- return def.queryParamToValue(raw);
11383
- if (def.skipAutoTransform)
11384
- return raw;
11385
- return autoCoerce(raw, resolveDefault(def));
11386
- }
11387
- /** What a field puts in the URL for a given value, or `undefined` when it writes nothing. */
11388
- queryParamFor(key, def, value) {
11389
- const isDefault = equal(value, this.defaults[key]);
11390
- const writeToUrl = def.appendToUrl !== false;
11391
- const writeDefault = def.appendDefaultValueToUrl === true;
11392
- if (!writeToUrl || (isDefault && !writeDefault))
11393
- return undefined;
11394
- if (def.valueToQueryParam)
11395
- return def.valueToQueryParam(value);
11396
- return value === null ? ET_NULL_VALUE : value;
11397
- }
11398
- writeToUrl(value) {
11399
- const queryParams = {};
11400
- for (const [key, def] of Object.entries(this._fields)) {
11401
- queryParams[this.paramKey(key)] = this.queryParamFor(key, def, value[key]);
11402
- }
11403
- queueMicrotask(() => {
11404
- this.router.navigate([], {
11405
- queryParams,
11406
- queryParamsHandling: 'merge',
11407
- replaceUrl: this.observeOptions?.replaceUrl,
11408
- });
11409
- });
11410
- }
11411
- paramKey(key) {
11412
- const prefix = this.prefix;
11413
- if (!prefix)
11414
- return key;
11415
- return `${typeof prefix === 'string' ? prefix : prefix()}-${key}`;
11416
- }
11417
- clearTimer() {
11418
- if (this.pendingTimer !== null) {
11419
- clearTimeout(this.pendingTimer);
11420
- this.pendingTimer = null;
11421
- }
11422
- this.commitPending.set(false);
11423
- }
11424
- cleanup() {
11425
- if (!this.observing())
11778
+ commitFromUrl(current);
11779
+ };
11780
+ const cleanup = () => {
11781
+ if (!observing())
11426
11782
  return;
11427
- this.observing.set(false);
11428
- this.clearTimer();
11429
- if (this.observeOptions?.writeToQueryParams === false)
11783
+ observing.set(false);
11784
+ clearTimer();
11785
+ if (observeOptions?.writeToQueryParams === false)
11430
11786
  return;
11431
11787
  const queryParams = {};
11432
- for (const key of Object.keys(this._fields)) {
11433
- queryParams[this.paramKey(key)] = undefined;
11788
+ for (const key of Object.keys(fieldDefs)) {
11789
+ queryParams[paramKey(key)] = undefined;
11434
11790
  }
11435
11791
  queueMicrotask(() => {
11436
- this.router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl: true });
11792
+ router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl: true });
11793
+ });
11794
+ };
11795
+ const setValue = (value, options) => {
11796
+ if (options?.skipResets)
11797
+ skipNextResets = true;
11798
+ model.set(clone(value));
11799
+ };
11800
+ const patchValue = (value, options) => {
11801
+ if (options?.skipResets)
11802
+ skipNextResets = true;
11803
+ model.update((cur) => ({ ...cur, ...value }));
11804
+ };
11805
+ const resetFieldsToDefault = (keys, options) => {
11806
+ if (options?.skipResets)
11807
+ skipNextResets = true;
11808
+ model.update((cur) => {
11809
+ const next = { ...cur };
11810
+ for (const key of keys) {
11811
+ next[key] = defaults[key];
11812
+ }
11813
+ return next;
11437
11814
  });
11815
+ };
11816
+ const devtoolsName = config.name ?? (typeof prefix === 'string' ? prefix : 'form');
11817
+ const devtoolsId = isQueryDevtoolsEnabled() ? nextQueryFormDevtoolsId(devtoolsName) : null;
11818
+ const queryForm = {
11819
+ fields,
11820
+ // Reading `value` is how a query's args pick the form up, so the devtools learn which query a form
11821
+ // drives by noting the read - see `QueryDevtoolsFormLinksHandle`.
11822
+ value: devtoolsId
11823
+ ? computed(() => {
11824
+ noteQueryFormRead(devtoolsId);
11825
+ return committed();
11826
+ })
11827
+ : committed.asReadonly(),
11828
+ previousValue: previous.asReadonly(),
11829
+ changes: computed(() => ({ previousValue: previous(), currentValue: committed() })),
11830
+ activeFilterCount,
11831
+ defaultValue,
11832
+ observe: (options) => {
11833
+ if (observing()) {
11834
+ if (isDevMode()) {
11835
+ console.warn('defineQueryForm: observe() was called more than once. Ignoring the extra call.');
11836
+ }
11837
+ return queryForm;
11838
+ }
11839
+ observeOptions = options;
11840
+ observing.set(true);
11841
+ if (options?.syncOnNavigation !== false) {
11842
+ applyFromUrl(route.snapshot.queryParams);
11843
+ }
11844
+ // Commit whatever the model holds now (URL-restored or programmatic defaults).
11845
+ flush();
11846
+ return queryForm;
11847
+ },
11848
+ unobserve: () => cleanup(),
11849
+ setValue,
11850
+ patchValue,
11851
+ resetFieldToDefault: (key, options) => resetFieldsToDefault([key], options),
11852
+ resetFieldsToDefault,
11853
+ resetAllFieldsToDefault: (options) => {
11854
+ const skip = new Set((options?.skipFields ?? []).map((key) => key));
11855
+ const keys = Object.keys(fieldDefs).filter((key) => !skip.has(key));
11856
+ resetFieldsToDefault(keys, options);
11857
+ },
11858
+ branch: () => createBranch(fieldDefs, clone(committed()), injector),
11859
+ };
11860
+ // React to live control edits: schedule a debounced commit.
11861
+ effect(() => {
11862
+ const live = model();
11863
+ untracked(() => onLiveChange(live));
11864
+ });
11865
+ // React to navigation (back/forward, external links): apply URL → form immediately.
11866
+ effect(() => {
11867
+ const changes = queryParamChanges();
11868
+ untracked(() => {
11869
+ if (!observing() || observeOptions?.syncOnNavigation === false)
11870
+ return;
11871
+ applyFromUrl(changes);
11872
+ });
11873
+ });
11874
+ destroyRef.onDestroy(() => cleanup());
11875
+ if (devtoolsId) {
11876
+ /**
11877
+ * The live view `<et-query-devtools>` renders. Only built while the devtools are installed - it walks
11878
+ * every field on every change, which an app without them should not pay for.
11879
+ */
11880
+ const handle = {
11881
+ fields: computed(() => {
11882
+ const committedValue = committed();
11883
+ const live = model();
11884
+ return Object.entries(fieldDefs).map(([key, def]) => {
11885
+ const value = committedValue[key];
11886
+ return {
11887
+ key,
11888
+ paramKey: paramKey(key),
11889
+ value,
11890
+ liveValue: live[key],
11891
+ defaultValue: defaults[key],
11892
+ isDefault: equal(value, defaults[key]),
11893
+ queryParam: queryParamFor(key, def, value),
11894
+ debounceMs: def.debounce ?? null,
11895
+ isResetBy: def.isResetBy ?? [],
11896
+ countsAsFilter: !IGNORED_FILTER_COUNT_FIELDS.includes(key) && !def.skipInFilterCount,
11897
+ };
11898
+ });
11899
+ }),
11900
+ // Not `queryForm.value` - reading that from the panel would link the form to whichever query happens
11901
+ // to be building its args at the time.
11902
+ value: computed(() => committed()),
11903
+ previousValue: computed(() => previous() ?? null),
11904
+ defaultValue: defaults,
11905
+ activeFilterCount,
11906
+ isAtDefaults: computed(() => equal(committed(), defaults)),
11907
+ isCommitPending: commitPending.asReadonly(),
11908
+ isObserving: observing.asReadonly(),
11909
+ resetField: (key) => queryForm.resetFieldToDefault(key),
11910
+ resetAll: () => queryForm.resetAllFieldsToDefault(),
11911
+ };
11912
+ destroyRef.onDestroy(registerQueryDevtoolsEntry({
11913
+ id: devtoolsId,
11914
+ kind: 'query-form',
11915
+ handle,
11916
+ meta: { name: devtoolsName },
11917
+ }));
11438
11918
  }
11439
- }
11440
- /**
11441
- * Create a {@link QueryFormSignals}. Call `.observe()` to start syncing with the
11442
- * URL. Must be called in an injection context.
11443
- */
11444
- const createQueryForm = (config) => new QueryFormSignals(config);
11919
+ return queryForm;
11920
+ };
11445
11921
 
11446
11922
  const normalizeConfig = (config) => {
11447
11923
  if (!config)
@@ -11572,12 +12048,15 @@ const createWebSocketClient = (options) => {
11572
12048
  const join = (name) => {
11573
12049
  emit({ event: 'join-room', data: name, room: name });
11574
12050
  const existingRoom = rooms.get(name);
11575
- if (existingRoom)
12051
+ if (existingRoom) {
12052
+ existingRoom.joinCount++;
11576
12053
  return existingRoom;
12054
+ }
11577
12055
  const message = signal(null, /* @ts-ignore */
11578
12056
  ...(ngDevMode ? [{ debugName: "message" }] : /* istanbul ignore next */ []));
11579
12057
  const newRoom = {
11580
12058
  latestMessage: message,
12059
+ joinCount: 1,
11581
12060
  };
11582
12061
  rooms.set(name, newRoom);
11583
12062
  syncDevtoolsRooms();
@@ -11611,10 +12090,15 @@ const createWebSocketClient = (options) => {
11611
12090
  return roomData.asReadonly();
11612
12091
  };
11613
12092
  const leaveRoom = (room) => {
11614
- if (!rooms.has(room)) {
12093
+ const joinedRoom = rooms.get(room);
12094
+ if (!joinedRoom) {
11615
12095
  if (isDevMode())
11616
12096
  throw roomNotJoined(room);
12097
+ return;
11617
12098
  }
12099
+ joinedRoom.joinCount--;
12100
+ if (joinedRoom.joinCount > 0)
12101
+ return;
11618
12102
  emit({ event: 'leave-room', data: room, room });
11619
12103
  rooms.delete(room);
11620
12104
  syncDevtoolsRooms();
@@ -11679,5 +12163,5 @@ const createWebSocketClient = (options) => {
11679
12163
  * Generated bundle index. Do not edit.
11680
12164
  */
11681
12165
 
11682
- export { BasicAuthProvider, BearerAuthFeatureType, BooleanArrayQueryField, CLEAR_QUERY_ARGS, CustomHeaderAuthProvider, DEFAULT_KEEP_UNUSED_FOR, DEFAULT_QUERY_PERSISTENCE_MAX_AGE, DEFAULT_QUERY_PERSISTENCE_MAX_ENTRIES, DEFAULT_QUERY_PERSISTENCE_VERSION, DEFAULT_QUERY_PERSISTENCE_WRITE_DELAY, DateArrayQueryField, DateQueryField, EMPTY_QUERY_DEVTOOLS_FAULT, EntityStore, IGNORED_FILTER_COUNT_FIELDS, INFINITY_QUERY_TOKEN, InfinityQuery, InfinityQueryDirective, InfinityQueryTriggerDirective, LegacyQuery, LegacyQueryCreator, MAX_UNUSED_ENTRIES, NumberArrayQueryField, ParseHttpErrorCodeToMessageDePipe, ParseHttpErrorCodeToMessageEnPipe, ParseHttpErrorCodeToTitleDePipe, ParseHttpErrorCodeToTitleEnPipe, QUERY_CLIENT_DEVTOOLS_TOKEN, QUERY_DEVTOOLS_FAULT_STATUSES, QUERY_PERSISTENCE_STORE_VERSION, QUERY_SYNC_PROTOCOL_VERSION, QueryClientFeatureType, QueryDevtoolsComponent, QueryDirective, QueryFeatureType, QueryField, QueryForm, QueryFormSignals, QueryRuntimeErrorCode, QueryShortNamePipe, QueryStore, SERVER_ERROR_KIND, SERVER_VIOLATION_ERROR_KIND, SPEED_BUFFER_TIME_IN_MS, SearchQueryField, SortQueryField, StringArrayQueryField, V2BearerAuthProvider, V2Query, V2QueryClient, V2QueryCreator, addQueryContainerHandling, applyQueryDevtoolsOverrides, applyQueryFeatures, authExtractTokensResponseMissingAccessToken, authExtractTokensResponseMissingRefreshToken, authExtractTokensResponseNotObject, authProviderFeatureUsedMultipleTimes, booleanArrayQueryField, buildQueryCacheKey, buildQueryString, buildRoute, buildTimestampFromSeconds, castQueryCreatorTypes, circularQueryDependency, circularQueryDependencyChecker, clearQueryDevtoolsFaults, collectLeafPaths, collectQueryFormLinks, computeQueryAuthHeader, computeQueryBody, computeQueryHeaders, computeQueryMethod, computeQueryQueryParams, consumeSuppressQueryStackDevtools, contentfulGqlLikePaginationAdapter, createBaseQuery, createBaseQueryCreator, createBearerAuthProvider, createDeleteQuery, createExecuteFn, createGetQuery, createGqlExecuteFn, createGqlMutationViaGet, createGqlMutationViaPost, createGqlQuery, createGqlQueryCreator, createGqlQueryViaGet, createGqlQueryViaPost, createHttpRequest, createIndexedDbQueryPersistenceAdapter, createInfinityQueryConfig, createLegacyQueryCreator, createNoopQueryPersistenceAdapter, createPagedQueryStack, createPatchQuery, createPersistentAuthFeature, createPostQuery, createPutQuery, createQuery, createQueryClient, createQueryCollection, createQueryCollectionSignal, createQueryCollectionSubject, createQueryContext, createQueryCreator, createQueryDevtoolsFormLinks, createQueryDevtoolsFormLinksRecorder, createQueryDevtoolsOverrides, createQueryDevtoolsOverridesRecorder, createQueryDevtoolsStats, createQueryDevtoolsStatsRecorder, createQueryErrorResponse, createQueryFeature, createQueryForm, createQueryInvalidationFilter, createQueryKeyLockManager, createQueryObject, createQueryPersistenceEngine, createQueryRepository, createQuerySnapshotFn, createQueryStack, createQuerySyncEngine, createQuerySyncTransport, createSecureDeleteQuery, createSecureExecuteFactory, createSecureExecuteFn, createSecureGetQuery, createSecureGqlExecuteFn, createSecureGqlMutationViaGet, createSecureGqlMutationViaPost, createSecureGqlQuery, createSecureGqlQueryCreator, createSecureGqlQueryViaGet, createSecureGqlQueryViaPost, createSecurePatchQuery, createSecurePostQuery, createSecurePutQuery, createSecureQuery, createSecureQueryCreator, createTrackingFeature, createWebSocketClient, dateArrayQueryField, dateQueryField, decryptBearer, deepFreeze, def, describeQueryDevtoolsFeatures, detectContentTypeHeader, detectPaginationShape, dynLikePaginationAdapter, effectComputed, emptyQueryArgs, ethletePaginationAdapter, executeUntilSettled, extractExpiresInSeconds, extractFormViolations, extractHtmlErrorMessage, extractQuery, fakePaginationAdapter, filterFailure, filterNull, filterQueryStates, filterSuccess, forEachHeader, formatQueryDevtoolsDuration, getBearerAuthProviderName, getDefaultHeaders, getQueryClientName, getQueryFeatureUsage, getResponseUrl, ggLikePaginationAdapter, gql, gqlDataPropertyMissingInResponse, hasHeader, htmlErrorPayload, htmlQueryErrorParser, ignoreAutoRefresh, injectInfinityQueryResponseDelay, injectQueryContext, insertFrom, invalidBaseRouteError, invalidBodyError, invalidRouteError, invalidStateInsideSecureExecuteFactory, isAutoRefresh, isBasicAuthProvider, isBearerAuthProvider, isClassValidatorError, isCreateGqlQueryOptions, isCustomHeaderAuthProvider, isDateShapedLeaf, isEmptyString, isGqlQueryConfig, isHtmlErrorPayload, isLegacyQuery, isNaN$1 as isNaN, isPageOutOfRangeError, isQuery, isQueryCollection, isQueryDevtoolsEnabled, isQueryDevtoolsFaultArmed, isQueryDevtoolsFaultInjectionEnabled, isQueryErrorResponse, isQueryStateCancelled, isQueryStateFailure, isQueryStateLoading, isQueryStatePrepared, isQueryStateSuccess, isRequestError, isSymfonyError, isSymfonyFormViolationListError, isSymfonyListError, isSymfonyPagerfantaOutOfRangeError, isUnderInvalidatedUrl, legacyPrepareFallbackInjector, legacyPrepareWithoutInjectionContext, mapToPaginated, mapViolationsToFormErrors, maybeExecute, measureQueryDevtoolsPayload, mergeHeaders, nestedEffect, noteQueryFormRead, numberArrayQueryField, pagedQueryStackNextPageCalledWithoutPreviousPage, pagedQueryStackPageBiggerThanTotalPages, pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage, paginatedEntityValueUpdater, parseAllXhrResponseHeaders, parseHttpErrorCodeToMessageDe, parseHttpErrorCodeToMessageEn, parseHttpErrorCodeToTitleDe, parseHttpErrorCodeToTitleEn, parseQueryRoute, pathParamsMissingInRouteFunctionError, provideInfinityQueryResponseDelay, provideLegacyPrepareFallback, provideQueryClientForDevtools, provideQueryDevtools, queryArrayComputed, queryClientFeatureUsedMultipleTimes, queryComputed, queryComputedTillTruthy, queryDevtoolsEntries, queryDevtoolsFaults, queryDevtoolsFnDetail, queryErrorMessage, queryErrorMessages, queryExecute, queryFeatureUsedMultipleTimes, queryField, querySequence, querySequenceAlreadyRunning, queryStackTotalQueriesAndExpectedQueriesMismatch, queryStackWithArgsUsed, queryStackWithResponseUpdateUsed, queryStateAlreadyHasKeyError, queryStateCannotTransformError, queryStateDoesNotContainKeyError, queryStateErrorSignal, queryStateLoadingSignal, queryStateResponseSignal, queryStateSignal, registerQueryDevtoolsEntry, registerQueryErrorParser, removeFrom, request, resetExecuteState, resetPageOnError, resolveInvalidationUrl, resolveQueryDevtoolsFault, resolveQueryDevtoolsFaultForAttempt, runDefaultQueryRetry, runQueryErrorParsers, searchQueryField, serializeBody, setDefaultQueryRetryFn, setQueryDevtoolsFault, setQueryDevtoolsFaultResolver, setQueryDevtoolsFormLinksFactory, setQueryDevtoolsOverridesFactory, setQueryDevtoolsRegistrar, setQueryDevtoolsStatsFactory, setupQueryDependencies, setupQueryExecuteState, setupQueryState, shouldAutoExecuteGqlQuery, shouldAutoExecuteQuery, shouldCacheQuery, shouldRetryRequest, silenceMissingWithArgsFeatureErrorUsedButWithArgsPresent, skipPaginationPageParamCalculator, smartDuplicateArrayItem, sortQueryField, splitQueryConfig, stringArrayQueryField, stringifyQueryRouteParts, sumQueryDevtoolsStats, suppressNextQueryStackDevtools, switchQueryCollectionState, switchQueryState, symfonyQueryErrorParser, takeUntilResponse, toQuerySignal, toQuerySubject, tokensNotAvailableInsideAuthAndExec, transformArrayResponse, transformExecStateToQueryState, transformGql, transformMethod, transformPaginatedResponse, transformToBoolean, transformToBooleanArray, transformToDate, transformToDateArray, transformToNumber, transformToNumberArray, transformToSort, transformToSortQueryParam, transformToString, transformToStringArray, uncacheableRequestHasAllowCacheParam, uncacheableRequestHasCacheKeyParam, unwrapQuerySyncMessage, v2BuildQueryCacheKey, v2ExtractExpiresInSeconds, v2ShouldCacheQuery, v2ShouldRetryRequest, validateWithQuery, validateWithV2Query, withArgs, withArgsQueryFeatureMissingButRouteIsFunction, withAuthenticationQuery, withAutoRefresh, withAutoRefreshUsedInManualQuery, withAutoRefreshUsedOnUnsupportedHttpMethod, withBearerAuthMultiTabSync, withDefaultRetry, withErrorHandling, withEthleteApiErrors, withHtmlErrorParsing, withInactivityLogout, withLogging, withMultiTabSync, withPageResetOnError, withPersistentAuth, withPolling, withPollingUsedOnUnsupportedHttpMethod, withQueryPersistence, withRefreshQuery, withResponseUpdate, withSuccessHandling, withSymfonyErrors, withTokenExpirationWarning, withTokenRevocation, withTracking, wrapAsObservableSignal, wrapQuerySyncMessage };
12166
+ export { BasicAuthProvider, BearerAuthFeatureType, BooleanArrayQueryField, CLEAR_QUERY_ARGS, CustomHeaderAuthProvider, DEFAULT_KEEP_UNUSED_FOR, DEFAULT_QUERY_PERSISTENCE_MAX_AGE, DEFAULT_QUERY_PERSISTENCE_MAX_ENTRIES, DEFAULT_QUERY_PERSISTENCE_VERSION, DEFAULT_QUERY_PERSISTENCE_WRITE_DELAY, DateArrayQueryField, DateQueryField, EMPTY_QUERY_DEVTOOLS_FAULT, EntityStore, IGNORED_FILTER_COUNT_FIELDS, INFINITY_QUERY_TOKEN, InfinityQuery, InfinityQueryDirective, InfinityQueryTriggerDirective, LegacyQuery, LegacyQueryCreator, MAX_QUERY_DEVTOOLS_TOMBSTONES, MAX_UNUSED_ENTRIES, NumberArrayQueryField, ParseHttpErrorCodeToMessageDePipe, ParseHttpErrorCodeToMessageEnPipe, ParseHttpErrorCodeToTitleDePipe, ParseHttpErrorCodeToTitleEnPipe, QUERY_CLIENT_DEVTOOLS_TOKEN, QUERY_DEVTOOLS_FAULT_STATUSES, QUERY_PERSISTENCE_STORE_VERSION, QUERY_SYNC_PROTOCOL_VERSION, QueryClientFeatureType, QueryDevtoolsComponent, QueryDirective, QueryFeatureType, QueryField, QueryForm, QueryRuntimeErrorCode, QueryShortNamePipe, QueryStore, SERVER_ERROR_KIND, SERVER_VIOLATION_ERROR_KIND, SPEED_BUFFER_TIME_IN_MS, SearchQueryField, SortQueryField, StringArrayQueryField, V2BearerAuthProvider, V2Query, V2QueryClient, V2QueryCreator, addQueryContainerHandling, applyQueryDevtoolsOverrides, applyQueryFeatures, authExtractTokensResponseMissingAccessToken, authExtractTokensResponseMissingRefreshToken, authExtractTokensResponseNotObject, authProviderFeatureUsedMultipleTimes, booleanArrayQueryField, buildQueryCacheKey, buildQueryString, buildRoute, buildTimestampFromSeconds, castQueryCreatorTypes, circularQueryDependency, circularQueryDependencyChecker, clearQueryDevtoolsFaults, clearQueryDevtoolsTombstones, collectLeafPaths, collectQueryFormLinks, computeQueryAuthHeader, computeQueryBody, computeQueryHeaders, computeQueryMethod, computeQueryQueryParams, consumeSuppressQueryStackDevtools, contentfulGqlLikePaginationAdapter, createBaseQuery, createBaseQueryCreator, createBearerAuthProvider, createDeleteQuery, createExecuteFn, createGetQuery, createGqlExecuteFn, createGqlMutationViaGet, createGqlMutationViaPost, createGqlQuery, createGqlQueryCreator, createGqlQueryViaGet, createGqlQueryViaPost, createHttpRequest, createIndexedDbQueryPersistenceAdapter, createInfinityQueryConfig, createLegacyQueryCreator, createNoopQueryPersistenceAdapter, createPagedQueryStack, createPatchQuery, createPersistentAuthFeature, createPostQuery, createPutQuery, createQuery, createQueryClient, createQueryCollection, createQueryCollectionSignal, createQueryCollectionSubject, createQueryContext, createQueryCreator, createQueryDevtoolsFormLinks, createQueryDevtoolsFormLinksRecorder, createQueryDevtoolsOverrides, createQueryDevtoolsOverridesRecorder, createQueryDevtoolsStats, createQueryDevtoolsStatsRecorder, createQueryErrorResponse, createQueryFeature, createQueryInvalidationFilter, createQueryKeyLockManager, createQueryObject, createQueryPersistenceEngine, createQueryRepository, createQuerySnapshotFn, createQueryStack, createQuerySyncEngine, createQuerySyncTransport, createSecureDeleteQuery, createSecureExecuteFactory, createSecureExecuteFn, createSecureGetQuery, createSecureGqlExecuteFn, createSecureGqlMutationViaGet, createSecureGqlMutationViaPost, createSecureGqlQuery, createSecureGqlQueryCreator, createSecureGqlQueryViaGet, createSecureGqlQueryViaPost, createSecurePatchQuery, createSecurePostQuery, createSecurePutQuery, createSecureQuery, createSecureQueryCreator, createTrackingFeature, createWebSocketClient, dateArrayQueryField, dateQueryField, decryptBearer, deepFreeze, def, defineQueryForm, describeQueryDevtoolsFeatures, detectContentTypeHeader, detectPaginationShape, dynLikePaginationAdapter, effectComputed, emptyQueryArgs, ethletePaginationAdapter, executeUntilSettled, extractExpiresInSeconds, extractFormViolations, extractHtmlErrorMessage, extractQuery, fakePaginationAdapter, filterFailure, filterNull, filterQueryStates, filterSuccess, forEachHeader, formatQueryDevtoolsDuration, generateQueryDevtoolsNumberPreset, generateQueryDevtoolsStringPreset, getBearerAuthProviderName, getDefaultHeaders, getQueryClientName, getQueryFeatureUsage, getResponseUrl, ggLikePaginationAdapter, gql, gqlDataPropertyMissingInResponse, hasHeader, hasQueryDevtoolsOverridesAtPath, htmlErrorPayload, htmlQueryErrorParser, ignoreAutoRefresh, injectInfinityQueryResponseDelay, injectQueryContext, insertFrom, invalidBaseRouteError, invalidBodyError, invalidRouteError, invalidStateInsideSecureExecuteFactory, isAutoRefresh, isBasicAuthProvider, isBearerAuthProvider, isClassValidatorError, isCreateGqlQueryOptions, isCustomHeaderAuthProvider, isDateShapedLeaf, isEmptyString, isGqlQueryConfig, isHtmlErrorPayload, isLegacyQuery, isNaN$1 as isNaN, isPageOutOfRangeError, isQuery, isQueryCollection, isQueryDevtoolsEnabled, isQueryDevtoolsFaultArmed, isQueryDevtoolsFaultInjectionEnabled, isQueryErrorResponse, isQueryStateCancelled, isQueryStateFailure, isQueryStateLoading, isQueryStatePrepared, isQueryStateSuccess, isRequestError, isSymfonyError, isSymfonyFormViolationListError, isSymfonyListError, isSymfonyPagerfantaOutOfRangeError, isUnderInvalidatedUrl, legacyPrepareFallbackInjector, legacyPrepareWithoutInjectionContext, mapToPaginated, mapViolationsToFormErrors, maybeExecute, measureQueryDevtoolsPayload, mergeHeaders, nestedEffect, noteQueryFormRead, numberArrayQueryField, pagedQueryStackNextPageCalledWithoutPreviousPage, pagedQueryStackPageBiggerThanTotalPages, pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage, paginatedEntityValueUpdater, parseAllXhrResponseHeaders, parseHttpErrorCodeToMessageDe, parseHttpErrorCodeToMessageEn, parseHttpErrorCodeToTitleDe, parseHttpErrorCodeToTitleEn, parseQueryRoute, pathParamsMissingInRouteFunctionError, provideInfinityQueryResponseDelay, provideLegacyPrepareFallback, provideQueryClientForDevtools, provideQueryDevtools, queryArrayComputed, queryClientFeatureUsedMultipleTimes, queryComputed, queryComputedTillTruthy, queryDevtoolsEntries, queryDevtoolsFaults, queryDevtoolsFnDetail, queryDevtoolsResponseHistory, queryErrorMessage, queryErrorMessages, queryExecute, queryFeatureUsedMultipleTimes, queryField, querySequence, querySequenceAlreadyRunning, queryStackTotalQueriesAndExpectedQueriesMismatch, queryStackWithArgsUsed, queryStackWithResponseUpdateUsed, queryStateAlreadyHasKeyError, queryStateCannotTransformError, queryStateDoesNotContainKeyError, queryStateErrorSignal, queryStateLoadingSignal, queryStateResponseSignal, queryStateSignal, registerQueryDevtoolsEntry, registerQueryErrorParser, removeFrom, request, resetExecuteState, resetPageOnError, resolveInvalidationUrl, resolveQueryDevtoolsFault, resolveQueryDevtoolsFaultForAttempt, runDefaultQueryRetry, runQueryErrorParsers, searchQueryField, serializeBody, setDefaultQueryRetryFn, setQueryDevtoolsFault, setQueryDevtoolsFaultResolver, setQueryDevtoolsFormLinksFactory, setQueryDevtoolsOverridesFactory, setQueryDevtoolsRegistrar, setQueryDevtoolsResponseHistory, setQueryDevtoolsStatsFactory, setupQueryDependencies, setupQueryExecuteState, setupQueryState, shouldAutoExecuteGqlQuery, shouldAutoExecuteQuery, shouldCacheQuery, shouldRetryRequest, silenceMissingWithArgsFeatureErrorUsedButWithArgsPresent, skipPaginationPageParamCalculator, smartDuplicateArrayItem, snapshotQueryDevtoolsHandle, sortQueryField, splitQueryConfig, stringArrayQueryField, stringifyQueryRouteParts, sumQueryDevtoolsStats, suppressNextQueryStackDevtools, switchQueryCollectionState, switchQueryState, symfonyQueryErrorParser, takeUntilResponse, toQuerySignal, toQuerySubject, tokensNotAvailableInsideAuthAndExec, tombstoneOf, transformArrayResponse, transformExecStateToQueryState, transformGql, transformMethod, transformPaginatedResponse, transformToBoolean, transformToBooleanArray, transformToDate, transformToDateArray, transformToNumber, transformToNumberArray, transformToSort, transformToSortQueryParam, transformToString, transformToStringArray, uncacheableRequestHasAllowCacheParam, uncacheableRequestHasCacheKeyParam, unwrapQuerySyncMessage, v2BuildQueryCacheKey, v2ExtractExpiresInSeconds, v2ShouldCacheQuery, v2ShouldRetryRequest, validateWithQuery, validateWithV2Query, withArgs, withArgsQueryFeatureMissingButRouteIsFunction, withAuthenticationQuery, withAutoRefresh, withAutoRefreshUsedInManualQuery, withAutoRefreshUsedOnUnsupportedHttpMethod, withBearerAuthMultiTabSync, withDefaultRetry, withErrorHandling, withEthleteApiErrors, withHtmlErrorParsing, withInactivityLogout, withLogging, withMultiTabSync, withPageResetOnError, withPersistentAuth, withPolling, withPollingUsedOnUnsupportedHttpMethod, withQueryPersistence, withRefreshQuery, withResponseUpdate, withSuccessHandling, withSymfonyErrors, withTokenExpirationWarning, withTokenRevocation, withTracking, wrapAsObservableSignal, wrapQuerySyncMessage };
11683
12167
  //# sourceMappingURL=ethlete-query.mjs.map