@ethlete/query 6.0.0-next.37 → 6.0.0-next.39

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.
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { inject, computed, DestroyRef, InjectionToken, Injector, EnvironmentInjector, ElementRef, ErrorHandler, createEnvironmentInjector, effect, untracked, signal, linkedSignal, runInInjectionContext, isDevMode, PLATFORM_ID, resource, DOCUMENT, VERSION, makeEnvironmentProviders, assertInInjectionContext, provideEnvironmentInitializer, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, input, Pipe, ViewEncapsulation, Component, NgZone } from '@angular/core';
3
3
  import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
4
4
  import { UrlTree, Router, RedirectCommand, ActivatedRoute } from '@angular/router';
5
- import { filter, take, map, defer, from, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, merge, EMPTY, takeWhile, firstValueFrom, concatMap, fromEvent, throttleTime, interval, combineLatest, Observable, takeUntil, startWith, pairwise, BehaviorSubject, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
5
+ import { filter, take, map, defer, from, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, EMPTY, takeUntil, finalize, mergeMap, toArray, merge, takeWhile, firstValueFrom, race, concatMap, fromEvent, throttleTime, interval, combineLatest, Observable, startWith, pairwise, BehaviorSubject, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
6
6
  import { defineStaticProvider, RuntimeError, clamp, randomId, defineRootProvider, isObject, injectUnsavedChangesCoordinator, injectRoute, getCookie, getDomain, deleteCookie, setCookie, CORE_VERSION, createDestroy, computedTillTruthy, defineProvider, toProvideFn, toInjectFn, nextFrame, injectQueryParams, injectQueryParamChanges, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
7
7
  import { HttpClient, HttpEventType, HttpErrorResponse, HttpResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http';
8
8
  import { getActiveConsumer, setActiveConsumer } from '@angular/core/primitives/signals';
@@ -283,6 +283,26 @@ const setQueryDevtoolsTokenPayloadPatcher = (fn) => {
283
283
  * @internal
284
284
  */
285
285
  const patchQueryDevtoolsTokenPayload = (options) => (tokenPayloadPatcher ? tokenPayloadPatcher(options) : options.payload);
286
+ /** The batch item being created right now, or `null` outside one. */
287
+ let batchOwner = null;
288
+ /**
289
+ * Runs `create` with every query it creates attributed to `owner`'s batch and item. The attribution has
290
+ * to be ambient: a batch's item query is built by the query creator the caller handed it, which takes no
291
+ * devtools argument - so there is nothing to thread the owner through. Nested runs restore the outer one.
292
+ * @internal
293
+ */
294
+ const runInQueryDevtoolsBatch = (owner, create) => {
295
+ const outer = batchOwner;
296
+ batchOwner = owner;
297
+ try {
298
+ return create();
299
+ }
300
+ finally {
301
+ batchOwner = outer;
302
+ }
303
+ };
304
+ /** The batch item a query being created belongs to, or `null` for one created outside a batch. @internal */
305
+ const currentQueryDevtoolsBatch = () => batchOwner;
286
306
  let suppressStackRegistration = false;
287
307
  /**
288
308
  * Suppresses devtools registration for the next {@link createQueryStack} call. Used by
@@ -409,6 +429,9 @@ const QueryRuntimeErrorCode = {
409
429
  CIRCULAR_QUERY_DEPENDENCY: 800,
410
430
  // Query Sequence
411
431
  QUERY_SEQUENCE_ALREADY_RUNNING: 900,
432
+ // Query Batch
433
+ QUERY_BATCH_ALREADY_RUNNING: 910,
434
+ QUERY_BATCH_WITH_ARGS_USED: 911,
412
435
  // Legacy interop
413
436
  LEGACY_PREPARE_WITHOUT_INJECTION_CONTEXT: 950,
414
437
  LEGACY_QUERY_STATE_ALREADY_HAS_KEY: 951,
@@ -507,6 +530,12 @@ const legacyPrepareWithoutInjectionContext = (creatorName, method = 'prepare') =
507
530
  const querySequenceAlreadyRunning = () => {
508
531
  return new RuntimeError(QueryRuntimeErrorCode.QUERY_SEQUENCE_ALREADY_RUNNING, `run() was called on a query sequence that is already running. Wait for the current run to settle before starting another.`);
509
532
  };
533
+ const queryBatchAlreadyRunning = () => {
534
+ return new RuntimeError(QueryRuntimeErrorCode.QUERY_BATCH_ALREADY_RUNNING, `run() or retryFailed() was called on a query batch that is already running. Await the current run, or call cancel() first.`);
535
+ };
536
+ const queryBatchWithArgsUsed = () => {
537
+ return new RuntimeError(QueryRuntimeErrorCode.QUERY_BATCH_WITH_ARGS_USED, `The "withArgs()" feature cannot be used inside a query batch. Each item's args come from the batch's "args" option.`);
538
+ };
510
539
 
511
540
  const isClassValidatorError = (error) => {
512
541
  return typeof error === 'object' && error !== null && 'statusCode' in error && 'message' in error && 'error' in error;
@@ -972,12 +1001,16 @@ const setupQueryState = (options) => {
972
1001
  requestEventsSubscription.unsubscribe();
973
1002
  requestEventsSubscription = request.events$.subscribe((event) => requestEvents$.next(event));
974
1003
  };
1004
+ options.destroyRef?.onDestroy(() => {
1005
+ requestEventsSubscription.unsubscribe();
1006
+ requestEvents$.complete();
1007
+ });
975
1008
  const executionState = computed(() => {
976
1009
  const currentResponse = response();
977
1010
  const currentError = error();
978
1011
  const currentLoading = loading();
979
1012
  if (currentLoading) {
980
- if (currentResponse) {
1013
+ if (currentResponse !== null) {
981
1014
  return {
982
1015
  type: 'loading',
983
1016
  hasCachedResponse: true,
@@ -991,10 +1024,19 @@ const setupQueryState = (options) => {
991
1024
  loading: currentLoading,
992
1025
  };
993
1026
  }
1027
+ else if (currentError && currentResponse !== null) {
1028
+ return {
1029
+ type: 'failure',
1030
+ error: currentError,
1031
+ hasCachedResponse: true,
1032
+ cachedResponse: currentResponse,
1033
+ };
1034
+ }
994
1035
  else if (currentError) {
995
1036
  return {
996
1037
  type: 'failure',
997
1038
  error: currentError,
1039
+ hasCachedResponse: false,
998
1040
  };
999
1041
  }
1000
1042
  else if (currentResponse !== null || latestHttpEvent()?.type === HttpEventType.Response) {
@@ -1204,11 +1246,13 @@ const createBaseQuery = (options) => {
1204
1246
  queryConfig: options.queryConfig,
1205
1247
  });
1206
1248
  return runInInjectionContext(deps.injector, () => {
1249
+ const batchOwner = currentQueryDevtoolsBatch();
1207
1250
  const devtoolsStats = createQueryDevtoolsStatsRecorder();
1208
1251
  const devtoolsFormLinks = createQueryDevtoolsFormLinksRecorder();
1209
1252
  const devtoolsOverrides = createQueryDevtoolsOverridesRecorder();
1210
1253
  const state = setupQueryState({
1211
1254
  transformResponse: options.creator?.transformResponse,
1255
+ destroyRef: deps.destroyRef,
1212
1256
  devtoolsStats,
1213
1257
  devtoolsFormLinks,
1214
1258
  devtoolsOverrides,
@@ -1271,6 +1315,8 @@ const createBaseQuery = (options) => {
1271
1315
  client: deps.client,
1272
1316
  element: deps.hostElement,
1273
1317
  gqlQuery: options.creatorInternals.query,
1318
+ batch: batchOwner?.batch,
1319
+ batchItemIndex: batchOwner?.index,
1274
1320
  },
1275
1321
  });
1276
1322
  deps.destroyRef.onDestroy(unregister);
@@ -2924,6 +2970,259 @@ const createIndexedDbQueryPersistenceAdapter = (options) => {
2924
2970
  return { loadIndex, read, write, remove, clear, isSupported: true };
2925
2971
  };
2926
2972
 
2973
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2974
+ /**
2975
+ * Runs the same mutation over a list of items - the bulk edit / bulk delete shape - with a bounded
2976
+ * number of requests in flight and a per-item outcome.
2977
+ *
2978
+ * Where a query stack fans a reactive read out over many arg sets and keeps every query alive, a
2979
+ * batch is imperative and disposable: it is triggered by a button, it tolerates a partial failure,
2980
+ * and it destroys each query as soon as that item settles.
2981
+ *
2982
+ * Call it from an injection context; the host's destruction stops an in-flight run.
2983
+ *
2984
+ * @example
2985
+ * ```ts
2986
+ * protected archivePosts = createQueryBatch({
2987
+ * queryCreator: patchPost,
2988
+ * args: (post: Post) => ({ pathParams: { id: post.id }, body: { archived: true } }),
2989
+ * concurrency: 6,
2990
+ * });
2991
+ *
2992
+ * archiveSelected() {
2993
+ * this.archivePosts
2994
+ * .run(this.selection())
2995
+ * .pipe(tap((result) => result.ok || this.toast.error(`${result.failed.length} failed`)))
2996
+ * .subscribe();
2997
+ * }
2998
+ * ```
2999
+ *
3000
+ * ```html
3001
+ * <button et-button [loading]="archivePosts.running()" [progress]="archivePosts.progress()">Archive</button>
3002
+ * @if (archivePosts.failed()) {
3003
+ * <button (click)="retry()">Retry {{ archivePosts.failed() }} failed</button>
3004
+ * }
3005
+ * ```
3006
+ */
3007
+ const createQueryBatch = (options) => {
3008
+ const { queryCreator, args: mapArgs, stopOnError, allowCache, onItemSettled, features = [] } = options;
3009
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
3010
+ if (features.some((f) => f.type === QueryFeatureType.WITH_ARGS)) {
3011
+ throw queryBatchWithArgsUsed();
3012
+ }
3013
+ const injector = inject(Injector);
3014
+ const destroyRef = inject(DestroyRef);
3015
+ const devtoolsHandle = isQueryDevtoolsEnabled()
3016
+ ? { current: null }
3017
+ : undefined;
3018
+ const status = signal('idle', /* @ts-ignore */
3019
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
3020
+ const running = signal(false, /* @ts-ignore */
3021
+ ...(ngDevMode ? [{ debugName: "running" }] : /* istanbul ignore next */ []));
3022
+ const total = signal(0, /* @ts-ignore */
3023
+ ...(ngDevMode ? [{ debugName: "total" }] : /* istanbul ignore next */ []));
3024
+ const inFlight = signal(0, /* @ts-ignore */
3025
+ ...(ngDevMode ? [{ debugName: "inFlight" }] : /* istanbul ignore next */ []));
3026
+ const results = signal([], /* @ts-ignore */
3027
+ ...(ngDevMode ? [{ debugName: "results" }] : /* istanbul ignore next */ []));
3028
+ const runStartedAt = signal(null, /* @ts-ignore */
3029
+ ...(ngDevMode ? [{ debugName: "runStartedAt" }] : /* istanbul ignore next */ []));
3030
+ const lastSettleAt = signal(null, /* @ts-ignore */
3031
+ ...(ngDevMode ? [{ debugName: "lastSettleAt" }] : /* istanbul ignore next */ []));
3032
+ const completedBeforeRun = signal(0, /* @ts-ignore */
3033
+ ...(ngDevMode ? [{ debugName: "completedBeforeRun" }] : /* istanbul ignore next */ []));
3034
+ let settled = [];
3035
+ let cancelRequested = false;
3036
+ let hostDestroyed = false;
3037
+ const hostDestroyed$ = new Subject();
3038
+ destroyRef.onDestroy(() => {
3039
+ hostDestroyed = true;
3040
+ cancelRequested = true;
3041
+ hostDestroyed$.next();
3042
+ hostDestroyed$.complete();
3043
+ });
3044
+ const publish = () => results.set(settled.filter((r) => !!r));
3045
+ const record = (result) => {
3046
+ settled[result.index] = result;
3047
+ lastSettleAt.set(Date.now());
3048
+ publish();
3049
+ onItemSettled?.(result);
3050
+ };
3051
+ const countOf = (of) => computed(() => results().filter((r) => r.status === of).length);
3052
+ const completed = computed(() => results().length, /* @ts-ignore */
3053
+ ...(ngDevMode ? [{ debugName: "completed" }] : /* istanbul ignore next */ []));
3054
+ const failedResults = computed(() => results().filter((r) => r.status === 'error'), /* @ts-ignore */
3055
+ ...(ngDevMode ? [{ debugName: "failedResults" }] : /* istanbul ignore next */ []));
3056
+ // Measured from the run's start rather than between the last two settles: items land in waves of
3057
+ // `concurrency`, so an instantaneous rate swings between zero and a burst.
3058
+ const itemsPerSecond = computed(() => {
3059
+ const startedAt = runStartedAt();
3060
+ const measuredAt = lastSettleAt();
3061
+ if (startedAt === null || measuredAt === null)
3062
+ return null;
3063
+ const elapsedMs = measuredAt - startedAt;
3064
+ const settledInRun = completed() - completedBeforeRun();
3065
+ // One settled item says nothing about a run of `concurrency` parallel ones, and a run shorter
3066
+ // than the buffer extrapolates a whole batch out of its own warm-up.
3067
+ if (elapsedMs < SPEED_BUFFER_TIME_IN_MS || settledInRun < Math.min(concurrency, total()))
3068
+ return null;
3069
+ return (settledInRun / elapsedMs) * 1000;
3070
+ }, /* @ts-ignore */
3071
+ ...(ngDevMode ? [{ debugName: "itemsPerSecond" }] : /* istanbul ignore next */ []));
3072
+ const remainingTime = computed(() => {
3073
+ const rate = itemsPerSecond();
3074
+ const outstanding = total() - completed();
3075
+ if (rate === null || rate <= 0 || outstanding <= 0)
3076
+ return null;
3077
+ return Math.round((outstanding / rate) * 1000);
3078
+ }, /* @ts-ignore */
3079
+ ...(ngDevMode ? [{ debugName: "remainingTime" }] : /* istanbul ignore next */ []));
3080
+ const runEntry = (entry) => defer(() => {
3081
+ if (cancelRequested) {
3082
+ record({ status: 'cancelled', index: entry.index, item: entry.item });
3083
+ return EMPTY;
3084
+ }
3085
+ const args = mapArgs(entry.item, entry.index);
3086
+ if (args === null) {
3087
+ record({ status: 'skipped', index: entry.index, item: entry.item });
3088
+ return EMPTY;
3089
+ }
3090
+ // The args reach the query through `execute()`, never `withArgs()`, so a function route has to
3091
+ // be told the missing feature is intentional or creating the query throws ET100.
3092
+ const create = () => queryCreator({ silenceMissingWithArgsFeatureError: true }, ...features);
3093
+ const query = runInInjectionContext(injector, () => devtoolsHandle ? runInQueryDevtoolsBatch({ batch: devtoolsHandle, index: entry.index }, create) : create());
3094
+ inFlight.update((count) => count + 1);
3095
+ query.execute({ args, options: { allowCache } });
3096
+ const snapshot = query.createSnapshot();
3097
+ return snapshot.isAlive.asObservable().pipe(filter((isAlive) => !isAlive), take(1),
3098
+ // A destroyed host tears the query down without ever settling it, so the run would hang.
3099
+ takeUntil(hostDestroyed$), tap(() => {
3100
+ const error = snapshot.error();
3101
+ if (error) {
3102
+ record({ status: 'error', index: entry.index, item: entry.item, args, error });
3103
+ if (stopOnError)
3104
+ cancelRequested = true;
3105
+ return;
3106
+ }
3107
+ record({
3108
+ status: 'success',
3109
+ index: entry.index,
3110
+ item: entry.item,
3111
+ args,
3112
+ response: snapshot.response(),
3113
+ });
3114
+ }), finalize(() => {
3115
+ inFlight.update((count) => count - 1);
3116
+ // The host's destruction already tore the query's injector down; destroying it again throws.
3117
+ if (!hostDestroyed)
3118
+ query.subtle.destroy();
3119
+ }));
3120
+ });
3121
+ const settleRun = (entries) => {
3122
+ const stoppedEarly = cancelRequested;
3123
+ for (const entry of entries) {
3124
+ settled[entry.index] ??= { status: 'cancelled', index: entry.index, item: entry.item };
3125
+ }
3126
+ publish();
3127
+ running.set(false);
3128
+ const all = results();
3129
+ const succeeded = all.filter((r) => r.status === 'success');
3130
+ const failed = all.filter((r) => r.status === 'error');
3131
+ const skipped = all.filter((r) => r.status === 'skipped');
3132
+ const notAttempted = all.filter((r) => r.status === 'cancelled');
3133
+ if (failed.length === 0)
3134
+ status.set(notAttempted.length === 0 ? 'success' : 'cancelled');
3135
+ else
3136
+ status.set(succeeded.length === 0 ? 'error' : 'partial');
3137
+ return {
3138
+ ok: failed.length === 0 && notAttempted.length === 0,
3139
+ cancelled: stoppedEarly,
3140
+ results: all,
3141
+ succeeded,
3142
+ failed,
3143
+ skipped,
3144
+ notAttempted,
3145
+ };
3146
+ };
3147
+ const runEntries = (collect) => defer(() => {
3148
+ if (running())
3149
+ throw queryBatchAlreadyRunning();
3150
+ const entries = collect();
3151
+ cancelRequested = false;
3152
+ running.set(true);
3153
+ status.set('running');
3154
+ publish();
3155
+ // After `publish()`: a retry clears its entries first, so this reads the count the retried
3156
+ // items are measured against, not the one from before they were dropped.
3157
+ completedBeforeRun.set(completed());
3158
+ runStartedAt.set(Date.now());
3159
+ lastSettleAt.set(null);
3160
+ return from(entries).pipe(mergeMap((entry) => runEntry(entry), concurrency), toArray(), map(() => settleRun(entries)));
3161
+ });
3162
+ const run = (items) => runEntries(() => {
3163
+ settled = new Array(items.length);
3164
+ total.set(items.length);
3165
+ return items.map((item, index) => ({ item, index }));
3166
+ });
3167
+ const retryFailed = () => runEntries(() => {
3168
+ const entries = results()
3169
+ .filter((r) => r.status === 'error' || r.status === 'cancelled')
3170
+ .map((r) => ({ item: r.item, index: r.index }));
3171
+ for (const entry of entries) {
3172
+ settled[entry.index] = undefined;
3173
+ }
3174
+ return entries;
3175
+ });
3176
+ const batch = {
3177
+ status: status.asReadonly(),
3178
+ running: running.asReadonly(),
3179
+ total: total.asReadonly(),
3180
+ completed,
3181
+ inFlight: inFlight.asReadonly(),
3182
+ succeeded: countOf('success'),
3183
+ failed: countOf('error'),
3184
+ skipped: countOf('skipped'),
3185
+ progress: computed(() => {
3186
+ const count = total();
3187
+ return count === 0 ? 0 : (completed() / count) * 100;
3188
+ }),
3189
+ itemsPerSecond,
3190
+ remainingTime,
3191
+ results: results.asReadonly(),
3192
+ errors: computed(() => failedResults().map((r) => r.error)),
3193
+ failedItems: computed(() => failedResults().map((r) => r.item)),
3194
+ run,
3195
+ retryFailed,
3196
+ cancel: () => {
3197
+ cancelRequested = true;
3198
+ },
3199
+ reset: () => {
3200
+ if (running())
3201
+ return;
3202
+ settled = [];
3203
+ total.set(0);
3204
+ results.set([]);
3205
+ status.set('idle');
3206
+ runStartedAt.set(null);
3207
+ lastSettleAt.set(null);
3208
+ completedBeforeRun.set(0);
3209
+ },
3210
+ };
3211
+ if (devtoolsHandle) {
3212
+ devtoolsHandle.current = batch;
3213
+ const internals = queryCreator.subtle.creatorInternals;
3214
+ const unregister = registerQueryDevtoolsEntry({
3215
+ kind: 'query-batch',
3216
+ handle: devtoolsHandle,
3217
+ route: internals.route,
3218
+ clientRef: internals.client,
3219
+ meta: { method: internals.method, concurrency, stopOnError: !!stopOnError },
3220
+ });
3221
+ destroyRef.onDestroy(unregister);
3222
+ }
3223
+ return batch;
3224
+ };
3225
+
2927
3226
  /**
2928
3227
  * Resolves an {@link QueryInvalidationOptions.url} to the absolute form request URLs are built in, so
2929
3228
  * the comparison - and the message the other tabs receive - never depends on who resolves it.
@@ -3219,13 +3518,14 @@ const createQueryKeyLockManager = (namespace) => {
3219
3518
  }
3220
3519
  keyStates.set(states);
3221
3520
  };
3222
- const hold = (key) => {
3521
+ const hold = (key, options = {}) => {
3223
3522
  const isHolder = signal(false, /* @ts-ignore */
3224
3523
  ...(ngDevMode ? [{ debugName: "isHolder" }] : /* istanbul ignore next */ []));
3225
3524
  const abortController = new AbortController();
3226
3525
  const activeHold = { key, isHolder: isHolder.asReadonly() };
3227
3526
  let releaseHeldLock = null;
3228
3527
  let isReleased = false;
3528
+ let wasGranted = false;
3229
3529
  activeHolds.add(activeHold);
3230
3530
  publishKeyStates();
3231
3531
  const release = () => {
@@ -3245,12 +3545,16 @@ const createQueryKeyLockManager = (namespace) => {
3245
3545
  abortController.abort();
3246
3546
  }
3247
3547
  };
3548
+ // A stealing request is granted on the spot, so it never queues and has nothing to abort - and
3549
+ // the platform rejects `signal` together with `steal` outright.
3550
+ const requestOptions = options.steal ? { steal: true } : { signal: abortController.signal };
3248
3551
  locks
3249
- .request(`${namespace}:${key}`, { signal: abortController.signal }, () => {
3552
+ .request(`${namespace}:${key}`, requestOptions, () => {
3250
3553
  // Aborting a request the platform has already granted does nothing, so a release that raced
3251
3554
  // the grant lands here instead: take the lock and give it straight back.
3252
3555
  if (isReleased)
3253
3556
  return Promise.resolve();
3557
+ wasGranted = true;
3254
3558
  isHolder.set(true);
3255
3559
  publishKeyStates();
3256
3560
  return new Promise((resolve) => {
@@ -3262,6 +3566,13 @@ const createQueryKeyLockManager = (namespace) => {
3262
3566
  // standby hold ends. Nothing to recover from in either case - this tab is simply not the
3263
3567
  // holder, which is what the signal already says.
3264
3568
  isHolder.set(false);
3569
+ // A hold that had the lock only ends in a rejection one way: another tab stole it. This one
3570
+ // is over, so it leaves the published states behind and lets the caller decide what comes next.
3571
+ if (!wasGranted || isReleased)
3572
+ return;
3573
+ activeHolds.delete(activeHold);
3574
+ publishKeyStates();
3575
+ options.onLost?.();
3265
3576
  });
3266
3577
  return { isHolder: activeHold.isHolder, release };
3267
3578
  };
@@ -4320,6 +4631,8 @@ const buildSequence = (steps, state, devtoolsHandle) => {
4320
4631
  state.running.set(false);
4321
4632
  return { ok: true, responses: responses, snapshots };
4322
4633
  };
4634
+ const completed = computed(() => state.snapshots().length, /* @ts-ignore */
4635
+ ...(ngDevMode ? [{ debugName: "completed" }] : /* istanbul ignore next */ []));
4323
4636
  const sequence = {
4324
4637
  then: (query, mapArgs) => buildSequence([
4325
4638
  ...steps,
@@ -4328,6 +4641,8 @@ const buildSequence = (steps, state, devtoolsHandle) => {
4328
4641
  status: state.status.asReadonly(),
4329
4642
  running: state.running.asReadonly(),
4330
4643
  currentStep: state.currentStep.asReadonly(),
4644
+ completed,
4645
+ progress: computed(() => (steps.length === 0 ? 0 : (completed() / steps.length) * 100)),
4331
4646
  total: steps.length,
4332
4647
  queries: steps.map((s) => s.query),
4333
4648
  error: state.error.asReadonly(),
@@ -4500,6 +4815,54 @@ const resolveFieldTreePath = (fieldTree, path) => {
4500
4815
  return typeof current === 'function' ? current : null;
4501
4816
  };
4502
4817
 
4818
+ /**
4819
+ * Builds a signal form's `submission.action` around a mutation: submitting executes the query with
4820
+ * args derived from the form's value, waits for it to settle, and maps a failed request's violations
4821
+ * back onto the fields that caused them. `form().submitting()` therefore covers the whole round trip.
4822
+ *
4823
+ * This is the submit-time counterpart to {@link validateWithQuery}. The query's args come from the
4824
+ * submitted value rather than a `withArgs()` feature, which also means the query no longer reads the
4825
+ * form it belongs to - so it can be declared before it, and no derivation runs per keystroke.
4826
+ *
4827
+ * ```ts
4828
+ * protected createPost = createQuerySubmission({
4829
+ * queryCreator: postPost,
4830
+ * args: (value) => ({ body: value }),
4831
+ * onSuccess: (post) => this.router.navigate(['/posts', post.id]),
4832
+ * });
4833
+ *
4834
+ * protected form = form(this.model, postSchema, {
4835
+ * submission: { action: this.createPost.action },
4836
+ * });
4837
+ * ```
4838
+ *
4839
+ * ```html
4840
+ * <form [etForm]="form">…</form>
4841
+ * <et-query-error [error]="createPost.query.error()" [query]="createPost.query" />
4842
+ * ```
4843
+ *
4844
+ * Call it from an injection context.
4845
+ */
4846
+ const createQuerySubmission = (config) => {
4847
+ // The args reach the query through `execute()`, never `withArgs()`, so a function route has to be
4848
+ // told the missing feature is intentional or creating the query throws ET100.
4849
+ const query = config.queryCreator({ silenceMissingWithArgsFeatureError: true });
4850
+ const action = async (field) => {
4851
+ const args = config.args?.(field().value(), field) ?? null;
4852
+ if (config.args && args === null)
4853
+ return undefined;
4854
+ const snapshot = await executeUntilSettled(query, args === null ? undefined : { args });
4855
+ const error = snapshot.error();
4856
+ if (error) {
4857
+ return (config.mapViolations?.(error, field) ??
4858
+ mapViolationsToFormErrors({ fieldTree: field, error, rewritePath: config.rewritePath }));
4859
+ }
4860
+ config.onSuccess?.(snapshot.response(), field);
4861
+ return undefined;
4862
+ };
4863
+ return { query, action };
4864
+ };
4865
+
4503
4866
  // Shared core for the query-backed async validators (`validateWithQuery` /
4504
4867
  // `validateWithV2Query`). Everything except how the query is executed - the `factory` that turns
4505
4868
  // the debounced params into a `Resource` - is identical between the v2 and v3 variants, so it is
@@ -4986,8 +5349,35 @@ const rescheduleDelayMs = 5000;
4986
5349
  * leadership over mid-flight simply never acts on it.
4987
5350
  */
4988
5351
  const delegatedRefreshRetryMs = 3000;
4989
- /** How often a delegated refresh is re-asked for before the tab leaves it to the next 401. */
5352
+ /** How often a delegated refresh is re-asked for before this tab refreshes the tokens itself. */
4990
5353
  const maxDelegatedRefreshAttempts = 3;
5354
+ /**
5355
+ * The same budget for a leader that answered with "a refresh started here". It is working on the
5356
+ * refresh, so it gets longer than a silent one - but not forever, because the answer says a refresh
5357
+ * started, not that it will ever finish.
5358
+ */
5359
+ const maxDelegatedRefreshWindows = 6;
5360
+ /**
5361
+ * How long before its expiry an access token counts as stale, which is the point a follower stops
5362
+ * waiting for the leader. Long enough to run the delegation ladder before the token is worthless,
5363
+ * short enough that a leader whose timer is merely throttled still gets to act first.
5364
+ */
5365
+ const followerTakeoverGraceMs = 30000;
5366
+ /**
5367
+ * How long a tab that took a refresh over holds the refresh lock at most. It is released as soon as
5368
+ * the refresh reports back; this only bounds the wait for one that never does.
5369
+ */
5370
+ const takeoverRefreshMaxLockHoldMs = 15000;
5371
+ /**
5372
+ * The longest delay `setTimeout` can hold. Anything above it fires straight away, which would turn a
5373
+ * long wait into a busy loop that spends every re-arm at once.
5374
+ */
5375
+ const maxTimerDelayMs = 2147483647;
5376
+ /**
5377
+ * The lock a tab must hold to spend the refresh token outside the leadership. Two followers go stale
5378
+ * at the same instant - their access token is the same one - so the takeover needs a mutex of its own.
5379
+ */
5380
+ const refreshLockName = (providerName) => `ethlete-auth:refresh:${providerName}`;
4991
5381
  /**
4992
5382
  * How often a scheduled refresh may re-arm before it waits for a new token pair. A tab that keeps
4993
5383
  * declining is either not the leader or busy issuing tokens; either way a 401 still forces a refresh.
@@ -5046,18 +5436,20 @@ const withRefreshQuery = (key, config) => {
5046
5436
  let unauthorizedRefreshStreak = 0;
5047
5437
  let delegatedRefreshAttempts = 0;
5048
5438
  let isWatchingDelegatedRefresh = false;
5439
+ let hasLeaderAnsweredDelegation = false;
5049
5440
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5050
5441
  const refreshQuery = () => context.queries[key];
5051
5442
  const executeRefresh = (reason) => {
5052
5443
  const currentRefreshToken = context.refreshToken();
5053
5444
  if (!currentRefreshToken)
5054
5445
  return 'noToken';
5055
- if (!context.isLeader()) {
5056
- // A scheduled tick on a follower is pure duplicate: multi-tab sync keeps the access token
5057
- // identical across tabs, so the leader's own timer is due at the same instant and it is the
5058
- // only tab allowed to act. Delegation is for the 401 path, where this tab has a request that
5059
- // really failed and the leader has no way of knowing.
5060
- if (reason === 'scheduled')
5446
+ if (reason !== 'takeover' && !context.isLeader()) {
5447
+ // A scheduled tick on a follower is pure duplicate while the token still has life left: multi-tab
5448
+ // sync keeps the access token identical across tabs, so the leader's own timer is due at the same
5449
+ // instant and it is the only tab allowed to act. Once the token is stale that reasoning is spent -
5450
+ // the leader had its moment and let it pass - so the tab delegates and, if that goes unanswered,
5451
+ // takes the refresh over.
5452
+ if (reason === 'scheduled' && !isAccessTokenStale())
5061
5453
  return 'notLeader';
5062
5454
  context.refreshCoordination?.request();
5063
5455
  watchDelegatedRefresh(context.accessToken());
@@ -5087,16 +5479,28 @@ const withRefreshQuery = (key, config) => {
5087
5479
  // the request has to go out.
5088
5480
  lastRefreshTime = now;
5089
5481
  refreshQuery().execute(buildArgs(currentRefreshToken), { triggeredBy: 'token-refresh' });
5482
+ // Answers every follower waiting on a delegated refresh, not just the one that asked: they all
5483
+ // hold the same access token, so they all go stale at the same moment.
5484
+ context.refreshCoordination?.announceStart();
5090
5485
  return 'executed';
5091
5486
  };
5092
5487
  /**
5093
- * Asks the leader again if a delegated refresh went unanswered - the request is a channel message
5094
- * with no ack, so a frozen leader, or one that handed the leadership over mid-flight, drops it
5095
- * silently. One watch at a time, and by the time it re-asks this tab may be the leader itself;
5096
- * `executeRefresh` re-decides that on every attempt.
5488
+ * Asks the leader again if a delegated refresh went unanswered, and refreshes the tokens in this tab
5489
+ * once the leader has run out of chances. A frozen leader keeps the lock that makes it the leader
5490
+ * while it runs no timer and reads no message, so waiting for it is waiting for nothing.
5491
+ *
5492
+ * One watch at a time, and by the time it re-asks this tab may be the leader itself; `executeRefresh`
5493
+ * re-decides that on every attempt.
5097
5494
  */
5098
5495
  function watchDelegatedRefresh(tokenAtRequest) {
5099
- if (isWatchingDelegatedRefresh || delegatedRefreshAttempts >= maxDelegatedRefreshAttempts)
5496
+ if (isWatchingDelegatedRefresh)
5497
+ return;
5498
+ // A new ladder judges the leader by what it answers this time round, not by an answer from an hour
5499
+ // ago - the tab that gave it may not even be open any more.
5500
+ if (delegatedRefreshAttempts === 0)
5501
+ hasLeaderAnsweredDelegation = false;
5502
+ const budget = hasLeaderAnsweredDelegation ? maxDelegatedRefreshWindows : maxDelegatedRefreshAttempts;
5503
+ if (delegatedRefreshAttempts >= budget)
5100
5504
  return;
5101
5505
  delegatedRefreshAttempts++;
5102
5506
  isWatchingDelegatedRefresh = true;
@@ -5107,11 +5511,62 @@ const withRefreshQuery = (key, config) => {
5107
5511
  // A new access token is the leader's answer - nothing to re-ask for.
5108
5512
  if (context.accessToken() !== tokenAtRequest) {
5109
5513
  delegatedRefreshAttempts = 0;
5514
+ hasLeaderAnsweredDelegation = false;
5515
+ return;
5516
+ }
5517
+ const spentBudget = hasLeaderAnsweredDelegation ? maxDelegatedRefreshWindows : maxDelegatedRefreshAttempts;
5518
+ if (delegatedRefreshAttempts >= spentBudget) {
5519
+ void takeOverRefresh(tokenAtRequest);
5110
5520
  return;
5111
5521
  }
5112
5522
  executeRefresh('unauthorized');
5113
5523
  });
5114
5524
  }
5525
+ /** Runs the refresh and keeps the refresh lock until it reports back, or until the bound runs out. */
5526
+ const runTakenOverRefresh = async () => {
5527
+ const attempt = executeRefresh('takeover');
5528
+ // The ladder starts over whatever came of it, including a takeover that could not run yet - a
5529
+ // refresh already in flight, or the floor under the 401-driven ones. Leaving the budget spent
5530
+ // would retire the only path a follower has to a token the leader is not going to issue.
5531
+ delegatedRefreshAttempts = 0;
5532
+ hasLeaderAnsweredDelegation = false;
5533
+ if (attempt !== 'executed')
5534
+ return;
5535
+ await new Promise((resolve) => {
5536
+ race(context.afterTokenRefresh$, timer(takeoverRefreshMaxLockHoldMs))
5537
+ .pipe(take(1), takeUntilDestroyed(destroyRef))
5538
+ .subscribe({ next: () => resolve(), complete: () => resolve() });
5539
+ });
5540
+ };
5541
+ /**
5542
+ * Spends the refresh token in this tab, after the leader failed to. Guarded by a lock of its own
5543
+ * rather than by the leadership, which the unresponsive tab still holds: every follower runs out of
5544
+ * patience at the same instant, and a single-use refresh token must not be spent twice.
5545
+ *
5546
+ * `ifAvailable` makes it a try-lock, so the tab that does not get the lock stands down instead of
5547
+ * queueing for a turn it must no longer take - the winner's new token pair arrives over the sync
5548
+ * channel long before then.
5549
+ */
5550
+ const takeOverRefresh = async (tokenAtRequest) => {
5551
+ if (context.accessToken() !== tokenAtRequest)
5552
+ return;
5553
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
5554
+ if (!locks) {
5555
+ await runTakenOverRefresh();
5556
+ return;
5557
+ }
5558
+ await locks.request(refreshLockName(context.name), { ifAvailable: true }, async (lock) => {
5559
+ if (!lock) {
5560
+ // Another tab is spending the refresh token right now. Start the ladder over rather than race
5561
+ // it: this tab hears about the pair it issues, and needs a way back if it never issues one.
5562
+ delegatedRefreshAttempts = 0;
5563
+ return;
5564
+ }
5565
+ if (context.accessToken() !== tokenAtRequest)
5566
+ return;
5567
+ await runTakenOverRefresh();
5568
+ });
5569
+ };
5115
5570
  /**
5116
5571
  * How long a scheduled refresh that did not spend the refresh token waits before trying again.
5117
5572
  * `null` re-arms nothing, because only a new token pair can change the outcome - and that restarts
@@ -5126,11 +5581,14 @@ const withRefreshQuery = (key, config) => {
5126
5581
  return Math.max(minRefreshInterval - (Date.now() - lastRefreshTime), rescheduleDelayMs);
5127
5582
  case 'busy':
5128
5583
  return rescheduleDelayMs;
5129
- // Both wait for the leader: `delegated` asked it to act, `notLeader` left it to its own timer.
5130
- // Coming back keeps this tab covered if the leadership moves here in the meantime.
5131
5584
  case 'delegated':
5132
- case 'notLeader':
5133
5585
  return minRefreshInterval;
5586
+ // Left to the leader's own timer, which is due at the same instant as this one. Coming back at
5587
+ // the staleness deadline instead of on a fixed interval is what makes the wait bounded: a fixed
5588
+ // interval spends `maxRescheduleAttempts` within minutes of a token that can live for hours, and
5589
+ // the tab is then left with no armed timer at all.
5590
+ case 'notLeader':
5591
+ return Math.min(Math.max(msUntilAccessTokenIsStale() ?? minRefreshInterval, rescheduleDelayMs), maxTimerDelayMs);
5134
5592
  }
5135
5593
  };
5136
5594
  const calculateRefreshBuffer = (tokenLifetimeMs) => {
@@ -5143,8 +5601,8 @@ const withRefreshQuery = (key, config) => {
5143
5601
  const calculatedBuffer = tokenLifetimeMs * (1 - percentage);
5144
5602
  return Math.max(minBufferMs, Math.min(maxBufferMs, calculatedBuffer));
5145
5603
  };
5146
- /** When this access token is due to be refreshed, or `null` if it cannot be scheduled at all. */
5147
- const scheduledRefreshDelay = (token) => {
5604
+ /** When this token expires, as a timestamp, or `null` if the expiry cannot be read off it. */
5605
+ const tokenExpiresAt = (token) => {
5148
5606
  try {
5149
5607
  const decoded = context.bearerDecryptFn ? context.bearerDecryptFn(token) : decryptBearer(token);
5150
5608
  const bearerDataValue = patchQueryDevtoolsTokenPayload({
@@ -5159,29 +5617,61 @@ const withRefreshQuery = (key, config) => {
5159
5617
  }
5160
5618
  return null;
5161
5619
  }
5162
- const tokenLifetimeMs = expiresIn * 1000 - Date.now();
5163
- if (tokenLifetimeMs <= 0) {
5164
- if (!refreshIfExpired)
5165
- return null;
5166
- if (isDevMode()) {
5167
- console.warn('Token is already expired, triggering immediate refresh');
5168
- }
5169
- return 0;
5170
- }
5171
- return Math.max(tokenLifetimeMs - calculateRefreshBuffer(tokenLifetimeMs), 0);
5620
+ return expiresIn * 1000;
5172
5621
  }
5173
5622
  catch {
5174
5623
  return null;
5175
5624
  }
5176
5625
  };
5626
+ /** When this access token is due to be refreshed, or `null` if it cannot be scheduled at all. */
5627
+ const scheduledRefreshDelay = (token) => {
5628
+ const expiresAt = tokenExpiresAt(token);
5629
+ if (expiresAt === null)
5630
+ return null;
5631
+ const tokenLifetimeMs = expiresAt - Date.now();
5632
+ if (tokenLifetimeMs <= 0) {
5633
+ if (!refreshIfExpired)
5634
+ return null;
5635
+ if (isDevMode()) {
5636
+ console.warn('Token is already expired, triggering immediate refresh');
5637
+ }
5638
+ return 0;
5639
+ }
5640
+ return Math.max(tokenLifetimeMs - calculateRefreshBuffer(tokenLifetimeMs), 0);
5641
+ };
5642
+ /**
5643
+ * How long this tab can still work with the access token it holds, or `null` without a readable
5644
+ * expiry. Zero or less means a refresh has to happen now, whichever tab does it.
5645
+ */
5646
+ const msUntilAccessTokenIsStale = () => {
5647
+ const token = context.accessToken();
5648
+ if (!token)
5649
+ return null;
5650
+ const expiresAt = tokenExpiresAt(token);
5651
+ return expiresAt === null ? null : expiresAt - followerTakeoverGraceMs - Date.now();
5652
+ };
5653
+ const isAccessTokenStale = () => {
5654
+ const remainingMs = msUntilAccessTokenIsStale();
5655
+ return remainingMs !== null && remainingMs <= 0;
5656
+ };
5177
5657
  /**
5178
5658
  * The scheduled refresh, re-armed for as long as it keeps declining to spend the refresh token.
5179
5659
  * Without the re-arm a single declined attempt strands the session on a token nothing renews until
5180
5660
  * a request fails with a 401 - by which time the refresh token may be gone too.
5181
5661
  */
5182
5662
  const scheduledRefresh = (dueInMs, attemptsLeft) => (dueInMs <= 0 ? of(0) : timer(dueInMs)).pipe(concatMap(() => {
5183
- const retryInMs = retryScheduledIn(executeRefresh('scheduled'));
5184
- return retryInMs === null || attemptsLeft <= 0 ? EMPTY : scheduledRefresh(retryInMs, attemptsLeft - 1);
5663
+ const attempt = executeRefresh('scheduled');
5664
+ const retryInMs = retryScheduledIn(attempt);
5665
+ if (retryInMs === null)
5666
+ return EMPTY;
5667
+ // Waiting on another tab does not spend the budget. Both outcomes come back at a deadline
5668
+ // rather than on a fixed interval, and both end the moment a token pair arrives, because a
5669
+ // new token restarts the schedule. Spending the budget on them leaves a follower whose
5670
+ // leader never acts with no armed timer at all, which is the one state nothing recovers
5671
+ // from: the tab then holds a dead token and cannot even ask for a new one.
5672
+ if (attempt === 'notLeader' || attempt === 'delegated')
5673
+ return scheduledRefresh(retryInMs, attemptsLeft);
5674
+ return attemptsLeft <= 0 ? EMPTY : scheduledRefresh(retryInMs, attemptsLeft - 1);
5185
5675
  }));
5186
5676
  // A backgrounded tab's `timer()` does not fire on time - the browser throttles it, and a frozen
5187
5677
  // page stops running it altogether while keeping the Web Lock that makes it the leader. So the
@@ -5212,6 +5702,9 @@ const withRefreshQuery = (key, config) => {
5212
5702
  .pipe(switchMap(({ dueInMs }) => (dueInMs === null ? EMPTY : scheduledRefresh(dueInMs, maxRescheduleAttempts))), takeUntilDestroyed())
5213
5703
  .subscribe();
5214
5704
  context.refreshCoordination?.requests$.pipe(takeUntilDestroyed()).subscribe(() => executeRefresh('unauthorized'));
5705
+ context.refreshCoordination?.starts$.pipe(takeUntilDestroyed()).subscribe(() => {
5706
+ hasLeaderAnsweredDelegation = true;
5707
+ });
5215
5708
  const onRefreshFailure = config.onRefreshFailure ??
5216
5709
  (({ error, logout }) => {
5217
5710
  if (!retryableStatusCodes.includes(error.code))
@@ -5233,6 +5726,7 @@ const withRefreshQuery = (key, config) => {
5233
5726
  if (event.type === 'request-success' && event.isSecure) {
5234
5727
  unauthorizedRefreshStreak = 0;
5235
5728
  delegatedRefreshAttempts = 0;
5729
+ hasLeaderAnsweredDelegation = false;
5236
5730
  return;
5237
5731
  }
5238
5732
  if (event.type !== 'request-error' || !event.isSecure || event.error?.status !== 401)
@@ -5405,9 +5899,16 @@ const leaderLockName = (name) => `${LEADER_LOCK_NAMESPACE}:${leaderLockKey(name)
5405
5899
  /**
5406
5900
  * Web Locks has no "someone joined" event, so tabs announce themselves on this channel. Two messages
5407
5901
  * per tab lifetime, in place of the heartbeat this used to run once a second. It also carries a
5408
- * follower's request for a refresh, which is the other thing a tab can only say to the leader.
5902
+ * follower's request for a refresh and the leader's answer that one started, and the two messages the
5903
+ * leadership moves to a visible tab with.
5409
5904
  */
5410
5905
  const presenceChannelName = (name) => `ethlete-auth-leader:${name}`;
5906
+ /**
5907
+ * How long a visible tab waits for the leader to say it is still running before it takes the lock
5908
+ * over. Long enough for a same-origin channel message and its answer on a busy main thread, short
5909
+ * enough that the user does not sit in front of a session nothing can refresh.
5910
+ */
5911
+ const leaderClaimTimeoutMs = 1500;
5411
5912
  const countLeaderRequests = (snapshot, lockName) => {
5412
5913
  const isLeaderLock = (info) => info.name === lockName;
5413
5914
  // Held plus pending is exactly the tab count: every tab requests this one lock, one gets it and the
@@ -5418,9 +5919,22 @@ const countLeaderRequests = (snapshot, lockName) => {
5418
5919
  * Elects the one tab that refreshes the session's tokens, over the
5419
5920
  * [Web Locks API](https://developer.mozilla.org/docs/Web/API/Web_Locks_API).
5420
5921
  *
5421
- * The lock does the whole job: requests queue FIFO, so the tab that has been waiting longest takes
5422
- * over, and a holder that closes, crashes or navigates away has its lock released by the platform.
5423
- * No heartbeat, no timeout to tune, and no window in which two tabs both believe they are the leader.
5922
+ * The lock does most of the job: requests queue FIFO, so a tab that closes, crashes or navigates away
5923
+ * has its lock released by the platform and the next tab in line takes over. No heartbeat, no timeout
5924
+ * to tune, and no window in which two tabs both believe they are the leader.
5925
+ *
5926
+ * What the queue cannot decide is *which* tab should lead. Its order is the order the tabs opened in,
5927
+ * which says nothing about which of them is awake: a hidden tab has its timers throttled to about
5928
+ * once a minute and a frozen one runs none at all, so a session led from one is a session nothing
5929
+ * refreshes, while the tab the user is looking at sits queued behind it. So the leadership follows
5930
+ * the user instead:
5931
+ *
5932
+ * - A tab that becomes hidden while leading gives the lock up and asks again, which puts it behind
5933
+ * whoever else wants it - and hands it straight back if nobody does.
5934
+ * - A tab that becomes visible without leading claims the leadership, and every hidden tab gives way
5935
+ * to it. This is what brings the leadership back to a tab the user returns to.
5936
+ * - A leader that answers neither - it is frozen, or the machine it runs on is asleep - has the lock
5937
+ * taken off it, because nothing it does not run can hand it over.
5424
5938
  *
5425
5939
  * Degrades to "this tab is the leader" without Web Locks - the same single-tab behavior the previous
5426
5940
  * `localStorage` implementation fell back to, and the only safe default: a session that refreshes its
@@ -5430,10 +5944,20 @@ const setupLeaderElection = (options) => {
5430
5944
  const destroyRef = inject(DestroyRef);
5431
5945
  const lockName = leaderLockName(options.name);
5432
5946
  const lockManager = createQueryKeyLockManager(LEADER_LOCK_NAMESPACE);
5433
- const hold = lockManager.hold(leaderLockKey(options.name));
5434
5947
  const instanceCount = signal(1, /* @ts-ignore */
5435
5948
  ...(ngDevMode ? [{ debugName: "instanceCount" }] : /* istanbul ignore next */ []));
5436
5949
  const refreshRequests = new Subject();
5950
+ const refreshStarts = new Subject();
5951
+ let isDestroyed = false;
5952
+ let isParticipating = true;
5953
+ const requestLeadership = (requestOptions) => lockManager.hold(leaderLockKey(options.name), {
5954
+ steal: requestOptions?.steal,
5955
+ onLost: () => onLeadershipTaken(),
5956
+ });
5957
+ const hold = signal(requestLeadership(), /* @ts-ignore */
5958
+ ...(ngDevMode ? [{ debugName: "hold" }] : /* istanbul ignore next */ []));
5959
+ const isLeader = computed(() => hold().isHolder(), /* @ts-ignore */
5960
+ ...(ngDevMode ? [{ debugName: "isLeader" }] : /* istanbul ignore next */ []));
5437
5961
  const noop = () => {
5438
5962
  /* nothing was set up */
5439
5963
  };
@@ -5441,16 +5965,18 @@ const setupLeaderElection = (options) => {
5441
5965
  // and asks nobody else for a refresh.
5442
5966
  if (!lockManager.isSupported) {
5443
5967
  return {
5444
- isLeader: hold.isHolder,
5968
+ isLeader,
5445
5969
  instanceCount: instanceCount.asReadonly(),
5446
5970
  isSupported: false,
5447
5971
  requestRefresh: noop,
5448
5972
  refreshRequests$: refreshRequests.asObservable(),
5973
+ announceRefreshStart: noop,
5974
+ refreshStarts$: refreshStarts.asObservable(),
5449
5975
  cleanup: noop,
5450
5976
  };
5451
5977
  }
5452
5978
  const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(presenceChannelName(options.name));
5453
- let isDestroyed = false;
5979
+ const isPageVisible = () => typeof document === 'undefined' || document.visibilityState === 'visible';
5454
5980
  const recount = async () => {
5455
5981
  const snapshot = await navigator.locks.query();
5456
5982
  if (isDestroyed)
@@ -5459,8 +5985,65 @@ const setupLeaderElection = (options) => {
5459
5985
  // count of zero would say the app is not running in the tab reading it.
5460
5986
  instanceCount.set(Math.max(countLeaderRequests(snapshot, lockName), 1));
5461
5987
  };
5462
- const announce = () => channel?.postMessage({ type: 'presence' });
5463
- const requestRefresh = () => channel?.postMessage({ type: 'refresh-requested' });
5988
+ const post = (message) => channel?.postMessage(message);
5989
+ const announce = () => post({ type: 'presence' });
5990
+ const requestRefresh = () => post({ type: 'refresh-requested' });
5991
+ const announceRefreshStart = () => post({ type: 'refresh-started' });
5992
+ /** Set while this tab has claimed the leadership and is waiting to hear whether the leader is alive. */
5993
+ let claimTimeout;
5994
+ const cancelClaim = () => {
5995
+ clearTimeout(claimTimeout);
5996
+ claimTimeout = undefined;
5997
+ };
5998
+ /** Asks for the lock again, which puts this tab behind every tab already waiting for it. */
5999
+ const requeue = (requestOptions) => {
6000
+ hold().release();
6001
+ hold.set(requestLeadership(requestOptions));
6002
+ };
6003
+ /**
6004
+ * Steps aside for the tabs that want the leadership. What a hidden tab does: its timers are
6005
+ * throttled to about once a minute, so the refresh it is responsible for happens roughly whenever.
6006
+ * It stays in the election, and gets the lock straight back if no other tab wants it.
6007
+ */
6008
+ const yieldLeadership = () => {
6009
+ if (isDestroyed || !isParticipating)
6010
+ return;
6011
+ requeue();
6012
+ void recount();
6013
+ };
6014
+ /**
6015
+ * Takes the lock off the tab holding it, once asking has not worked. The last resort of
6016
+ * {@link claimLeadership}: a frozen leader keeps its lock, answers nothing and refreshes nothing,
6017
+ * and a handover between hidden tabs can land the lock on the next hidden tab in the queue.
6018
+ */
6019
+ const takeLeadershipOver = () => {
6020
+ cancelClaim();
6021
+ if (isDestroyed || !isParticipating || isLeader())
6022
+ return;
6023
+ requeue({ steal: true });
6024
+ announce();
6025
+ void recount();
6026
+ };
6027
+ /**
6028
+ * Asks the tabs ahead of this one to give the leadership up, because the user is looking at this
6029
+ * one. Hidden tabs give way on their own; a leader that says nothing at all has the lock taken off
6030
+ * it once the wait runs out.
6031
+ */
6032
+ const claimLeadership = () => {
6033
+ if (isDestroyed || !isParticipating || !channel || isLeader() || !isPageVisible())
6034
+ return;
6035
+ if (claimTimeout !== undefined)
6036
+ return;
6037
+ claimTimeout = setTimeout(takeLeadershipOver, leaderClaimTimeoutMs);
6038
+ post({ type: 'claim' });
6039
+ };
6040
+ /** Rejoins the queue after another tab took the lock over, rather than leaving the election. */
6041
+ const onLeadershipTaken = () => {
6042
+ if (isDestroyed || !isParticipating)
6043
+ return;
6044
+ hold.set(requestLeadership());
6045
+ void recount();
6046
+ };
5464
6047
  if (channel) {
5465
6048
  channel.onmessage = (event) => {
5466
6049
  const message = event.data;
@@ -5468,39 +6051,134 @@ const setupLeaderElection = (options) => {
5468
6051
  void recount();
5469
6052
  return;
5470
6053
  }
6054
+ if (message?.type === 'refresh-started') {
6055
+ refreshStarts.next();
6056
+ return;
6057
+ }
6058
+ if (message?.type === 'claim') {
6059
+ // The answer goes out before the handover, and from the leader alone: it is the one thing that
6060
+ // tells the claiming tab that a tab which is not answering *is* a tab that is not running.
6061
+ if (isLeader())
6062
+ post({ type: 'leader-alive', isVisible: isPageVisible() });
6063
+ // Every hidden tab gives way, not only the leader: a claim that only moved the lock one place
6064
+ // along would land it on the next hidden tab in the queue.
6065
+ if (!isPageVisible())
6066
+ yieldLeadership();
6067
+ return;
6068
+ }
6069
+ if (message?.type === 'leader-alive') {
6070
+ // One visible tab leading is as good as another, so a claim against one is over. A hidden
6071
+ // leader is giving way instead, and the claim stays armed until it has: the release hands the
6072
+ // lock to the first tab in the queue, which may be another hidden one.
6073
+ if (message.isVisible)
6074
+ cancelClaim();
6075
+ return;
6076
+ }
5471
6077
  // Every tab hears the request; only the one that may spend the refresh token acts on it.
5472
- if (message?.type === 'refresh-requested' && hold.isHolder()) {
6078
+ if (message?.type === 'refresh-requested' && isLeader()) {
5473
6079
  refreshRequests.next();
5474
6080
  }
5475
6081
  };
5476
6082
  }
5477
6083
  effect(() => {
5478
- if (!hold.isHolder())
6084
+ if (!isLeader())
5479
6085
  return;
6086
+ cancelClaim();
5480
6087
  // Becoming the leader means the previous one went away. A tab that closed normally announced it
5481
6088
  // itself, one that crashed did not - so this is the moment to tell the others for free.
5482
6089
  announce();
5483
6090
  void recount();
5484
6091
  });
6092
+ /**
6093
+ * Leaves the election, because this tab is about to stop running. A frozen page keeps its lock while
6094
+ * its timers do not fire, so a leader that stays in would be a leader that refreshes nothing, and
6095
+ * every other tab would sit queued behind it until the user came back. The lock is not requested
6096
+ * again here: the platform would grant it to this page just as well while it is frozen.
6097
+ */
6098
+ const leaveElection = () => {
6099
+ if (isDestroyed || !isParticipating)
6100
+ return;
6101
+ isParticipating = false;
6102
+ cancelClaim();
6103
+ // Release before the goodbye: the tabs that recount on it should no longer see this one queued.
6104
+ hold().release();
6105
+ announce();
6106
+ };
6107
+ /** Takes part again, behind whichever tab took the leadership over in the meantime. */
6108
+ const rejoinElection = () => {
6109
+ if (isDestroyed || isParticipating)
6110
+ return;
6111
+ isParticipating = true;
6112
+ hold.set(requestLeadership());
6113
+ announce();
6114
+ void recount();
6115
+ // A page resumes because the user came back to it, so it is also the tab that should be leading.
6116
+ claimLeadership();
6117
+ };
6118
+ const isRestoredFromCache = (event) => event.persisted === true;
6119
+ const onFreeze = () => leaveElection();
6120
+ const onResume = () => rejoinElection();
6121
+ const onPageHide = (event) => {
6122
+ if (isRestoredFromCache(event))
6123
+ leaveElection();
6124
+ };
6125
+ const onPageShow = (event) => {
6126
+ if (isRestoredFromCache(event))
6127
+ rejoinElection();
6128
+ };
6129
+ const onVisibilityChange = () => {
6130
+ if (isPageVisible()) {
6131
+ claimLeadership();
6132
+ return;
6133
+ }
6134
+ if (isLeader())
6135
+ yieldLeadership();
6136
+ };
6137
+ // `freeze`/`resume` is Chromium's; `pagehide`/`pageshow` with `persisted` is how a page enters and
6138
+ // leaves the back/forward cache, which stops it just as dead in browsers that fire no `freeze`.
6139
+ if (typeof document !== 'undefined') {
6140
+ document.addEventListener('freeze', onFreeze);
6141
+ document.addEventListener('resume', onResume);
6142
+ document.addEventListener('visibilitychange', onVisibilityChange);
6143
+ }
6144
+ if (typeof window !== 'undefined') {
6145
+ window.addEventListener('pagehide', onPageHide);
6146
+ window.addEventListener('pageshow', onPageShow);
6147
+ }
5485
6148
  announce();
5486
6149
  void recount();
6150
+ // A tab that opens while another one sleeps must not wait for a visibility change that already
6151
+ // happened. The claim costs one message and settles itself when the leader answers.
6152
+ claimLeadership();
5487
6153
  const cleanup = () => {
5488
6154
  if (isDestroyed)
5489
6155
  return;
5490
6156
  isDestroyed = true;
5491
- // Release before the goodbye: the tabs that recount on it should no longer see this one queued.
5492
- hold.release();
6157
+ cancelClaim();
6158
+ if (typeof document !== 'undefined') {
6159
+ document.removeEventListener('freeze', onFreeze);
6160
+ document.removeEventListener('resume', onResume);
6161
+ document.removeEventListener('visibilitychange', onVisibilityChange);
6162
+ }
6163
+ if (typeof window !== 'undefined') {
6164
+ window.removeEventListener('pagehide', onPageHide);
6165
+ window.removeEventListener('pageshow', onPageShow);
6166
+ }
6167
+ hold().release();
5493
6168
  announce();
5494
6169
  channel?.close();
5495
6170
  refreshRequests.complete();
6171
+ refreshStarts.complete();
5496
6172
  };
5497
6173
  destroyRef.onDestroy(cleanup);
5498
6174
  return {
5499
- isLeader: hold.isHolder,
6175
+ isLeader,
5500
6176
  instanceCount: instanceCount.asReadonly(),
5501
6177
  isSupported: true,
5502
6178
  requestRefresh,
5503
6179
  refreshRequests$: refreshRequests.asObservable(),
6180
+ announceRefreshStart,
6181
+ refreshStarts$: refreshStarts.asObservable(),
5504
6182
  cleanup,
5505
6183
  };
5506
6184
  };
@@ -5676,10 +6354,38 @@ const setupMultiTabSync = (config, context) => {
5676
6354
  settleAdoption();
5677
6355
  };
5678
6356
  let adoptionTimeout;
6357
+ const requestState = () => channel.postMessage({ type: 'state-request' });
6358
+ /**
6359
+ * A page that was frozen or held in the back/forward cache ran nothing while it was away, so the
6360
+ * token pair it wakes up with is whatever it held when it stopped - the rotations it missed were
6361
+ * broadcast to a tab that was not listening. Asking again is what stops it spending a refresh token
6362
+ * that was already spent, which the server answers with a 401 and the provider with a logout for
6363
+ * every tab.
6364
+ */
6365
+ const resyncAfterResume = () => {
6366
+ if (!syncTokens || !context.accessToken())
6367
+ return;
6368
+ requestState();
6369
+ };
6370
+ const onResume = () => resyncAfterResume();
6371
+ const onPageShow = (event) => {
6372
+ if (event.persisted === true)
6373
+ resyncAfterResume();
6374
+ };
6375
+ if (typeof document !== 'undefined') {
6376
+ document.addEventListener('resume', onResume);
6377
+ }
6378
+ if (typeof window !== 'undefined') {
6379
+ window.addEventListener('pageshow', onPageShow);
6380
+ }
5679
6381
  const cleanup = () => {
5680
6382
  clearTimeout(adoptionTimeout);
5681
6383
  settleAdoption();
5682
6384
  remoteActivity.complete();
6385
+ if (typeof document !== 'undefined')
6386
+ document.removeEventListener('resume', onResume);
6387
+ if (typeof window !== 'undefined')
6388
+ window.removeEventListener('pageshow', onPageShow);
5683
6389
  channel.close();
5684
6390
  };
5685
6391
  destroyRef.onDestroy(cleanup);
@@ -5697,7 +6403,7 @@ const setupMultiTabSync = (config, context) => {
5697
6403
  };
5698
6404
  });
5699
6405
  adoptionTimeout = setTimeout(settleAdoption, sessionAdoptionTimeoutMs);
5700
- channel.postMessage({ type: 'state-request' });
6406
+ requestState();
5701
6407
  return { isPending: () => isAdoptionPending, settled };
5702
6408
  })()
5703
6409
  : undefined;
@@ -5755,7 +6461,8 @@ const SINGLE_TAB = /* @__PURE__ */ (() => {
5755
6461
  /**
5756
6462
  * Keeps one browser session consistent across the user's tabs: a login in one tab logs the others in,
5757
6463
  * a logout logs them out, and only one tab (the leader) performs the automatic token refresh so a
5758
- * single-use refresh token is not spent twice.
6464
+ * single-use refresh token is not spent twice. The leadership follows the tab the user is looking at,
6465
+ * because a hidden tab has its timers throttled and a frozen one runs none at all.
5759
6466
  *
5760
6467
  * Off by default - a single-tab app, a kiosk or an embedded webview ships neither the
5761
6468
  * `BroadcastChannel` sync nor the Web Locks leader election without it. Degrades to "this tab is the
@@ -5820,7 +6527,12 @@ const withBearerAuthMultiTabSync = (config = {}) => {
5820
6527
  return {
5821
6528
  isLeader: () => election.isLeader(),
5822
6529
  leaderElection: instance,
5823
- refreshCoordination: { request: election.requestRefresh, requests$: election.refreshRequests$ },
6530
+ refreshCoordination: {
6531
+ request: election.requestRefresh,
6532
+ requests$: election.refreshRequests$,
6533
+ announceStart: election.announceRefreshStart,
6534
+ starts$: election.refreshStarts$,
6535
+ },
5824
6536
  sessionAdoption: sync.sessionAdoption,
5825
6537
  activityCoordination: sync.activityCoordination,
5826
6538
  };
@@ -7466,7 +8178,7 @@ const armQueryDevtoolsOverrideTransfer = (recorder, ops) => {
7466
8178
 
7467
8179
  // Generated from package.json by tools/scripts/generate-version.js - do not edit.
7468
8180
  /** The version of `@ethlete/query` this build was cut from. */
7469
- const QUERY_VERSION = '6.0.0-next.37';
8181
+ const QUERY_VERSION = '6.0.0-next.39';
7470
8182
 
7471
8183
  /** How deep a schema is expanded before a nested branch is given up on. */
7472
8184
  const MAX_DEPTH = 12;
@@ -8447,6 +9159,12 @@ const applyQueryDevtoolsTokenTtl = (options) => {
8447
9159
  * so the list is capped for the same reason the repository caps its unused entries.
8448
9160
  */
8449
9161
  const MAX_QUERY_DEVTOOLS_TOMBSTONES = 50;
9162
+ /**
9163
+ * How many of its settled items' queries one batch keeps. Counted per batch rather than against
9164
+ * {@link MAX_QUERY_DEVTOOLS_TOMBSTONES}, so a bulk run cannot evict the tombstones the panel is read
9165
+ * for - and low, because the batch's own entry already holds every item's args, response and error.
9166
+ */
9167
+ const MAX_QUERY_BATCH_TOMBSTONES = 20;
8450
9168
  const frozen = (value) => () => value;
8451
9169
  const noop = () => undefined;
8452
9170
  /**
@@ -8668,8 +9386,8 @@ const registerEntry = (registration) => {
8668
9386
  if (index === -1)
8669
9387
  return list;
8670
9388
  const next = list.slice();
8671
- // Only queries leave a tombstone. A stack, sequence, form or auth provider is a container whose
8672
- // interesting state is the queries it owns - each of which tombstones on its own.
9389
+ // Only queries leave a tombstone. A stack, sequence, batch, form or auth provider is a container
9390
+ // whose interesting state is the queries it owns - each of which tombstones on its own.
8673
9391
  if (fullEntry.kind !== 'query') {
8674
9392
  next.splice(index, 1);
8675
9393
  return next;
@@ -8678,16 +9396,39 @@ const registerEntry = (registration) => {
8678
9396
  return capTombstones(next);
8679
9397
  });
8680
9398
  };
8681
- /** Drops the oldest tombstones once there are more than {@link MAX_QUERY_DEVTOOLS_TOMBSTONES} of them. */
9399
+ /** The oldest `count` entries of a bucket, which is what a cap drops when the bucket is over it. */
9400
+ const oldestOver = (bucket, cap) => bucket.length <= cap
9401
+ ? []
9402
+ : bucket.sort((a, b) => (a.destroyedAt ?? 0) - (b.destroyedAt ?? 0)).slice(0, bucket.length - cap);
9403
+ /**
9404
+ * Caps the tombstones the registry holds. A batch's items are counted per batch rather than against the
9405
+ * shared budget: a batch destroys one query per item as it settles, so a single 500-item run would
9406
+ * otherwise fill the whole buffer and evict every tombstone the panel is actually read for - the `401`
9407
+ * that took its component down with it. Each batch keeps its own recent tail instead.
9408
+ */
8682
9409
  const capTombstones = (list) => {
8683
- const excess = list.filter((e) => e.destroyedAt).length - MAX_QUERY_DEVTOOLS_TOMBSTONES;
8684
- if (excess <= 0)
8685
- return list;
8686
- const doomed = new Set(list
8687
- .filter((e) => e.destroyedAt)
8688
- .sort((a, b) => (a.destroyedAt ?? 0) - (b.destroyedAt ?? 0))
8689
- .slice(0, excess));
8690
- return list.filter((e) => !doomed.has(e));
9410
+ const shared = [];
9411
+ const perBatch = new Map();
9412
+ for (const entry of list) {
9413
+ if (!entry.destroyedAt)
9414
+ continue;
9415
+ const batch = entry.meta.batch;
9416
+ if (!batch) {
9417
+ shared.push(entry);
9418
+ continue;
9419
+ }
9420
+ const bucket = perBatch.get(batch);
9421
+ if (bucket)
9422
+ bucket.push(entry);
9423
+ else
9424
+ perBatch.set(batch, [entry]);
9425
+ }
9426
+ const doomed = new Set(oldestOver(shared, MAX_QUERY_DEVTOOLS_TOMBSTONES));
9427
+ for (const bucket of perBatch.values()) {
9428
+ for (const entry of oldestOver(bucket, MAX_QUERY_BATCH_TOMBSTONES))
9429
+ doomed.add(entry);
9430
+ }
9431
+ return doomed.size ? list.filter((e) => !doomed.has(e)) : list;
8691
9432
  };
8692
9433
  /**
8693
9434
  * Forgets destroyed entries the registry is holding - the panel's "clear gone" action. Pass the ids to
@@ -13868,8 +14609,8 @@ const defineQueryForm = (config) => {
13868
14609
  // Feed the URL value into the live model & bound controls; the effect no-ops (model === committed).
13869
14610
  model.set(clone(next));
13870
14611
  };
13871
- const applyFromUrl = (params) => {
13872
- const current = { ...committed() };
14612
+ const applyFromUrl = (params, base = committed()) => {
14613
+ const current = { ...base };
13873
14614
  let changed = false;
13874
14615
  for (const [key, def] of Object.entries(fieldDefs)) {
13875
14616
  const raw = params[paramKey(key)];
@@ -13947,7 +14688,9 @@ const defineQueryForm = (config) => {
13947
14688
  observeOptions = options;
13948
14689
  observing.set(true);
13949
14690
  if (options?.syncOnNavigation !== false) {
13950
- applyFromUrl(route.snapshot.queryParams);
14691
+ // A value written before `observe()` sits in the model only, so the URL merges onto the model
14692
+ // here. Against `committed()` any single URL param would drop that value.
14693
+ applyFromUrl(route.snapshot.queryParams, model());
13951
14694
  }
13952
14695
  // Commit whatever the model holds now (URL-restored or programmatic defaults).
13953
14696
  flush();
@@ -14271,5 +15014,5 @@ const createWebSocketClient = (options) => {
14271
15014
  * Generated bundle index. Do not edit.
14272
15015
  */
14273
15016
 
14274
- export { BasicAuthProvider, BearerAuthFeatureType, BooleanArrayQueryField, 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_DEVTOOLS_TOKEN_TTL_LIMIT, QUERY_PERSISTENCE_STORE_VERSION, QUERY_SYNC_PROTOCOL_VERSION, QUERY_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, applyQueryDevtoolsTokenTtl, applyQueryFeatures, armAllQueryDevtoolsMocks, armQueryDevtoolsMock, armQueryDevtoolsOverrideTransfer, authExtractTokensResponseMissingAccessToken, authExtractTokensResponseMissingRefreshToken, authExtractTokensResponseNotObject, authProviderFeatureUsedMultipleTimes, booleanArrayQueryField, buildQueryCacheKey, buildQueryString, buildRoute, buildTimestampFromSeconds, canOverrideQueryDevtoolsTokenTtl, castQueryCreatorTypes, circularQueryDependency, circularQueryDependencyChecker, clearQueryDevtoolsArmedMocks, clearQueryDevtoolsFaults, clearQueryDevtoolsMockStore, clearQueryDevtoolsOverrideStore, clearQueryDevtoolsStore, clearQueryDevtoolsTokenTtl, clearQueryDevtoolsTombstones, clearRestoredQueryDevtoolsOverrides, collectLeafPaths, collectQueryDevtoolsSchemaComponents, collectQueryFormLinks, computeQueryAuthHeader, computeQueryBody, computeQueryHeaders, computeQueryMethod, computeQueryQueryParams, consumeSuppressQueryStackDevtools, contentfulGqlLikePaginationAdapter, countUnresolvedQueryDevtoolsOverrides, createAuthGuard, createBaseQuery, createBaseQueryCreator, createBearerAuthProvider, createDefaultRetryFn, 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, deleteQueryDevtoolsMock, describeQueryDevtoolsFeatures, detectContentTypeHeader, detectPaginationShape, dynLikePaginationAdapter, effectComputed, emptyQueryArgs, ethletePaginationAdapter, executeUntilSettled, extractExpiresInSeconds, extractFormViolations, extractHtmlErrorMessage, extractQuery, fakePaginationAdapter, filterFailure, filterNull, filterQueryStates, filterSuccess, forEachHeader, formatQueryDevtoolsDuration, generateQueryDevtoolsNumberPreset, generateQueryDevtoolsSampleNumber, generateQueryDevtoolsStringPreset, getBearerAuthProviderName, getDefaultHeaders, getQueryClientName, getQueryFeatureUsage, getResponseUrl, ggLikePaginationAdapter, gql, gqlDataPropertyMissingInResponse, hasHeader, hasQueryDevtoolsOverridesAtPath, htmlErrorPayload, htmlQueryErrorParser, ignoreAutoRefresh, initQueryDevtoolsFaults, initQueryDevtoolsMocks, initQueryDevtoolsOverridePersistence, initQueryDevtoolsSettings, 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, isQueryDevtoolsRequestInterceptionEnabled, isQueryErrorResponse, isQueryStateCancelled, isQueryStateFailure, isQueryStateLoading, isQueryStatePrepared, isQueryStateSuccess, isRequestError, isSymfonyError, isSymfonyFormViolationListError, isSymfonyListError, isSymfonyPagerfantaOutOfRangeError, isUnderInvalidatedUrl, legacyPrepareFallbackInjector, legacyPrepareWithoutInjectionContext, loadQueryDevtoolsSchema, mapToPaginated, mapViolationsToFormErrors, matchesQueryDevtoolsMockPattern, matchesQueryDevtoolsMockQuery, maybeExecute, measureQueryDevtoolsPayload, mergeHeaders, nestedEffect, noteQueryFormRead, numberArrayQueryField, pagedQueryStackNextPageCalledWithoutPreviousPage, pagedQueryStackPageBiggerThanTotalPages, pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage, paginatedEntityValueUpdater, parseAllXhrResponseHeaders, parseHttpErrorCodeToMessageDe, parseHttpErrorCodeToMessageEn, parseHttpErrorCodeToTitleDe, parseHttpErrorCodeToTitleEn, parseQueryDevtoolsOverrideTransfer, parseQueryRoute, patchQueryDevtoolsTokenPayload, pathParamsMissingInRouteFunctionError, provideInfinityQueryResponseDelay, provideLegacyPrepareFallback, provideQueryClientForDevtools, provideQueryDevtools, queryArrayComputed, queryClientFeatureUsedMultipleTimes, queryComputed, queryComputedTillTruthy, queryDevtoolsAbout, queryDevtoolsArmedMocks, queryDevtoolsArmedMocksRestored, queryDevtoolsEntries, queryDevtoolsFaults, queryDevtoolsFaultsRestored, queryDevtoolsFnDetail, queryDevtoolsMockId, queryDevtoolsMocks, queryDevtoolsOverridePersistence, queryDevtoolsRequestPath, queryDevtoolsResponseHistory, queryDevtoolsRestoredOverridesScope, queryDevtoolsSchemaNames, queryDevtoolsSchemaRoutes, queryDevtoolsSchemaState, queryDevtoolsSettings, queryDevtoolsStorage, queryDevtoolsTokenTtls, queryErrorMessage, queryErrorMessages, queryExecute, queryFeatureUsedMultipleTimes, queryField, querySequence, querySequenceAlreadyRunning, queryStackTotalQueriesAndExpectedQueriesMismatch, queryStackWithArgsUsed, queryStackWithResponseUpdateUsed, queryStateAlreadyHasKeyError, queryStateCannotTransformError, queryStateDoesNotContainKeyError, queryStateErrorSignal, queryStateLoadingSignal, queryStateResponseSignal, queryStateSignal, readQueryDevtoolsStore, registerEthleteVersion, registerQueryDevtoolsEntry, registerQueryErrorParser, removeFrom, request, resetExecuteState, resetPageOnError, resolveInvalidationUrl, resolveQueryDevtoolsFault, resolveQueryDevtoolsFaultForAttempt, resolveQueryDevtoolsMock, resolveQueryDevtoolsMockForAttempt, restoredQueryDevtoolsOverrides, runDefaultQueryRetry, runQueryErrorParsers, saveQueryDevtoolsMock, searchQueryField, seedQueryDevtoolsSchemaBody, seedQueryDevtoolsSchemaRoute, serializeBody, serializeQueryDevtoolsOverrideTransfer, setDefaultQueryRetryFn, setQueryDevtoolsAppInfo, setQueryDevtoolsArmedMocksScope, setQueryDevtoolsFault, setQueryDevtoolsFaultResolver, setQueryDevtoolsFaultsScope, setQueryDevtoolsFormLinksFactory, setQueryDevtoolsMockResolver, setQueryDevtoolsOverridePersistence, setQueryDevtoolsOverridesFactory, setQueryDevtoolsOverridesScope, setQueryDevtoolsRegistrar, setQueryDevtoolsResponseHistory, setQueryDevtoolsSchemaLoader, setQueryDevtoolsSettings, setQueryDevtoolsStatsFactory, setQueryDevtoolsTokenPayloadPatcher, setQueryDevtoolsTokenTtl, 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, withLongPolling, withLongPollingUsedOnUnsupportedHttpMethod, withLongPollingUsedWithPolling, withMultiTabSync, withPageResetOnError, withPersistentAuth, withPolling, withPollingUsedOnUnsupportedHttpMethod, withQueryDevtoolsOverridePersistence, withQueryPersistence, withRefreshQuery, withResponseUpdate, withSuccessHandling, withSymfonyErrors, withTokenExpirationWarning, withTokenRevocation, withTracking, wrapAsObservableSignal, wrapQuerySyncMessage, writeQueryDevtoolsStore };
15017
+ export { BasicAuthProvider, BearerAuthFeatureType, BooleanArrayQueryField, 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_BATCH_TOMBSTONES, MAX_QUERY_DEVTOOLS_TOMBSTONES, MAX_UNUSED_ENTRIES, NumberArrayQueryField, ParseHttpErrorCodeToMessageDePipe, ParseHttpErrorCodeToMessageEnPipe, ParseHttpErrorCodeToTitleDePipe, ParseHttpErrorCodeToTitleEnPipe, QUERY_CLIENT_DEVTOOLS_TOKEN, QUERY_DEVTOOLS_FAULT_STATUSES, QUERY_DEVTOOLS_TOKEN_TTL_LIMIT, QUERY_PERSISTENCE_STORE_VERSION, QUERY_SYNC_PROTOCOL_VERSION, QUERY_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, applyQueryDevtoolsTokenTtl, applyQueryFeatures, armAllQueryDevtoolsMocks, armQueryDevtoolsMock, armQueryDevtoolsOverrideTransfer, authExtractTokensResponseMissingAccessToken, authExtractTokensResponseMissingRefreshToken, authExtractTokensResponseNotObject, authProviderFeatureUsedMultipleTimes, booleanArrayQueryField, buildQueryCacheKey, buildQueryString, buildRoute, buildTimestampFromSeconds, canOverrideQueryDevtoolsTokenTtl, castQueryCreatorTypes, circularQueryDependency, circularQueryDependencyChecker, clearQueryDevtoolsArmedMocks, clearQueryDevtoolsFaults, clearQueryDevtoolsMockStore, clearQueryDevtoolsOverrideStore, clearQueryDevtoolsStore, clearQueryDevtoolsTokenTtl, clearQueryDevtoolsTombstones, clearRestoredQueryDevtoolsOverrides, collectLeafPaths, collectQueryDevtoolsSchemaComponents, collectQueryFormLinks, computeQueryAuthHeader, computeQueryBody, computeQueryHeaders, computeQueryMethod, computeQueryQueryParams, consumeSuppressQueryStackDevtools, contentfulGqlLikePaginationAdapter, countUnresolvedQueryDevtoolsOverrides, createAuthGuard, createBaseQuery, createBaseQueryCreator, createBearerAuthProvider, createDefaultRetryFn, createDeleteQuery, createExecuteFn, createGetQuery, createGqlExecuteFn, createGqlMutationViaGet, createGqlMutationViaPost, createGqlQuery, createGqlQueryCreator, createGqlQueryViaGet, createGqlQueryViaPost, createHttpRequest, createIndexedDbQueryPersistenceAdapter, createInfinityQueryConfig, createLegacyQueryCreator, createNoopQueryPersistenceAdapter, createPagedQueryStack, createPatchQuery, createPersistentAuthFeature, createPostQuery, createPutQuery, createQuery, createQueryBatch, createQueryClient, createQueryCollection, createQueryCollectionSignal, createQueryCollectionSubject, createQueryContext, createQueryCreator, createQueryDevtoolsFormLinks, createQueryDevtoolsFormLinksRecorder, createQueryDevtoolsOverrides, createQueryDevtoolsOverridesRecorder, createQueryDevtoolsStats, createQueryDevtoolsStatsRecorder, createQueryErrorResponse, createQueryFeature, createQueryInvalidationFilter, createQueryKeyLockManager, createQueryObject, createQueryPersistenceEngine, createQueryRepository, createQuerySnapshotFn, createQueryStack, createQuerySubmission, createQuerySyncEngine, createQuerySyncTransport, createSecureDeleteQuery, createSecureExecuteFactory, createSecureExecuteFn, createSecureGetQuery, createSecureGqlExecuteFn, createSecureGqlMutationViaGet, createSecureGqlMutationViaPost, createSecureGqlQuery, createSecureGqlQueryCreator, createSecureGqlQueryViaGet, createSecureGqlQueryViaPost, createSecurePatchQuery, createSecurePostQuery, createSecurePutQuery, createSecureQuery, createSecureQueryCreator, createTrackingFeature, createWebSocketClient, currentQueryDevtoolsBatch, dateArrayQueryField, dateQueryField, decryptBearer, deepFreeze, def, defineQueryForm, deleteQueryDevtoolsMock, describeQueryDevtoolsFeatures, detectContentTypeHeader, detectPaginationShape, dynLikePaginationAdapter, effectComputed, emptyQueryArgs, ethletePaginationAdapter, executeUntilSettled, extractExpiresInSeconds, extractFormViolations, extractHtmlErrorMessage, extractQuery, fakePaginationAdapter, filterFailure, filterNull, filterQueryStates, filterSuccess, forEachHeader, formatQueryDevtoolsDuration, generateQueryDevtoolsNumberPreset, generateQueryDevtoolsSampleNumber, generateQueryDevtoolsStringPreset, getBearerAuthProviderName, getDefaultHeaders, getQueryClientName, getQueryFeatureUsage, getResponseUrl, ggLikePaginationAdapter, gql, gqlDataPropertyMissingInResponse, hasHeader, hasQueryDevtoolsOverridesAtPath, htmlErrorPayload, htmlQueryErrorParser, ignoreAutoRefresh, initQueryDevtoolsFaults, initQueryDevtoolsMocks, initQueryDevtoolsOverridePersistence, initQueryDevtoolsSettings, 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, isQueryDevtoolsRequestInterceptionEnabled, isQueryErrorResponse, isQueryStateCancelled, isQueryStateFailure, isQueryStateLoading, isQueryStatePrepared, isQueryStateSuccess, isRequestError, isSymfonyError, isSymfonyFormViolationListError, isSymfonyListError, isSymfonyPagerfantaOutOfRangeError, isUnderInvalidatedUrl, legacyPrepareFallbackInjector, legacyPrepareWithoutInjectionContext, loadQueryDevtoolsSchema, mapToPaginated, mapViolationsToFormErrors, matchesQueryDevtoolsMockPattern, matchesQueryDevtoolsMockQuery, maybeExecute, measureQueryDevtoolsPayload, mergeHeaders, nestedEffect, noteQueryFormRead, numberArrayQueryField, pagedQueryStackNextPageCalledWithoutPreviousPage, pagedQueryStackPageBiggerThanTotalPages, pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage, paginatedEntityValueUpdater, parseAllXhrResponseHeaders, parseHttpErrorCodeToMessageDe, parseHttpErrorCodeToMessageEn, parseHttpErrorCodeToTitleDe, parseHttpErrorCodeToTitleEn, parseQueryDevtoolsOverrideTransfer, parseQueryRoute, patchQueryDevtoolsTokenPayload, pathParamsMissingInRouteFunctionError, provideInfinityQueryResponseDelay, provideLegacyPrepareFallback, provideQueryClientForDevtools, provideQueryDevtools, queryArrayComputed, queryBatchAlreadyRunning, queryBatchWithArgsUsed, queryClientFeatureUsedMultipleTimes, queryComputed, queryComputedTillTruthy, queryDevtoolsAbout, queryDevtoolsArmedMocks, queryDevtoolsArmedMocksRestored, queryDevtoolsEntries, queryDevtoolsFaults, queryDevtoolsFaultsRestored, queryDevtoolsFnDetail, queryDevtoolsMockId, queryDevtoolsMocks, queryDevtoolsOverridePersistence, queryDevtoolsRequestPath, queryDevtoolsResponseHistory, queryDevtoolsRestoredOverridesScope, queryDevtoolsSchemaNames, queryDevtoolsSchemaRoutes, queryDevtoolsSchemaState, queryDevtoolsSettings, queryDevtoolsStorage, queryDevtoolsTokenTtls, queryErrorMessage, queryErrorMessages, queryExecute, queryFeatureUsedMultipleTimes, queryField, querySequence, querySequenceAlreadyRunning, queryStackTotalQueriesAndExpectedQueriesMismatch, queryStackWithArgsUsed, queryStackWithResponseUpdateUsed, queryStateAlreadyHasKeyError, queryStateCannotTransformError, queryStateDoesNotContainKeyError, queryStateErrorSignal, queryStateLoadingSignal, queryStateResponseSignal, queryStateSignal, readQueryDevtoolsStore, registerEthleteVersion, registerQueryDevtoolsEntry, registerQueryErrorParser, removeFrom, request, resetExecuteState, resetPageOnError, resolveInvalidationUrl, resolveQueryDevtoolsFault, resolveQueryDevtoolsFaultForAttempt, resolveQueryDevtoolsMock, resolveQueryDevtoolsMockForAttempt, restoredQueryDevtoolsOverrides, runDefaultQueryRetry, runInQueryDevtoolsBatch, runQueryErrorParsers, saveQueryDevtoolsMock, searchQueryField, seedQueryDevtoolsSchemaBody, seedQueryDevtoolsSchemaRoute, serializeBody, serializeQueryDevtoolsOverrideTransfer, setDefaultQueryRetryFn, setQueryDevtoolsAppInfo, setQueryDevtoolsArmedMocksScope, setQueryDevtoolsFault, setQueryDevtoolsFaultResolver, setQueryDevtoolsFaultsScope, setQueryDevtoolsFormLinksFactory, setQueryDevtoolsMockResolver, setQueryDevtoolsOverridePersistence, setQueryDevtoolsOverridesFactory, setQueryDevtoolsOverridesScope, setQueryDevtoolsRegistrar, setQueryDevtoolsResponseHistory, setQueryDevtoolsSchemaLoader, setQueryDevtoolsSettings, setQueryDevtoolsStatsFactory, setQueryDevtoolsTokenPayloadPatcher, setQueryDevtoolsTokenTtl, 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, withLongPolling, withLongPollingUsedOnUnsupportedHttpMethod, withLongPollingUsedWithPolling, withMultiTabSync, withPageResetOnError, withPersistentAuth, withPolling, withPollingUsedOnUnsupportedHttpMethod, withQueryDevtoolsOverridePersistence, withQueryPersistence, withRefreshQuery, withResponseUpdate, withSuccessHandling, withSymfonyErrors, withTokenExpirationWarning, withTokenRevocation, withTracking, wrapAsObservableSignal, wrapQuerySyncMessage, writeQueryDevtoolsStore };
14275
15018
  //# sourceMappingURL=ethlete-query.mjs.map