@ethlete/query 6.0.0-next.19 → 6.0.0-next.20

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,14 +1,14 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, isDevMode, makeEnvironmentProviders, DestroyRef, InjectionToken, inject, Injector, EnvironmentInjector, ElementRef, ErrorHandler, createEnvironmentInjector, effect, untracked, linkedSignal, computed, runInInjectionContext, assertInInjectionContext, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, input, Pipe, ViewEncapsulation, Component, resource, DOCUMENT, NgZone } from '@angular/core';
2
+ import { signal, isDevMode, makeEnvironmentProviders, DestroyRef, InjectionToken, inject, Injector, EnvironmentInjector, ElementRef, ErrorHandler, createEnvironmentInjector, effect, untracked, linkedSignal, computed, runInInjectionContext, assertInInjectionContext, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, input, Pipe, ViewEncapsulation, Component, resource, PLATFORM_ID, DOCUMENT, NgZone } from '@angular/core';
3
3
  import { createStaticProvider, RuntimeError, clamp, createDestroy, computedTillTruthy, getCookie, setCookie, deleteCookie, createProvider, nextFrame, randomId, createRootProvider, isObject, injectRoute, getDomain, injectQueryParamChanges, injectQueryParams, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
4
4
  import { filter, Subject, Subscription, Observable, tap, retry, throwError, timer, catchError, takeUntil, startWith, pairwise, switchMap, of, BehaviorSubject, fromEvent, take, ReplaySubject, map, shareReplay, interval, skip, takeWhile, combineLatest, EMPTY, withLatestFrom, debounceTime, firstValueFrom, merge, throttleTime } from 'rxjs';
5
5
  import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
6
6
  import { HttpClient, HttpEventType, HttpErrorResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http';
7
7
  import { getActiveConsumer, setActiveConsumer } from '@angular/core/primitives/signals';
8
- import { NgClass, AsyncPipe, JsonPipe } from '@angular/common';
8
+ import { NgClass, AsyncPipe, JsonPipe, isPlatformBrowser } from '@angular/common';
9
9
  import * as i1 from '@angular/forms';
10
10
  import { FormControl, ReactiveFormsModule, FormGroup } from '@angular/forms';
11
- import { validateAsync } from '@angular/forms/signals';
11
+ import { validateAsync, form } from '@angular/forms/signals';
12
12
  import { Router, ActivatedRoute } from '@angular/router';
13
13
  import { io } from 'socket.io-client';
14
14
 
@@ -318,6 +318,8 @@ const QueryRuntimeErrorCode = {
318
318
  CIRCULAR_QUERY_DEPENDENCY: 800,
319
319
  // Query Sequence
320
320
  QUERY_SEQUENCE_ALREADY_RUNNING: 900,
321
+ // Legacy interop
322
+ LEGACY_PREPARE_WITHOUT_INJECTION_CONTEXT: 950,
321
323
  };
322
324
  const queryFeatureUsedMultipleTimes = (type) => {
323
325
  return new RuntimeError(QueryRuntimeErrorCode.QUERY_FEATURE_USED_MULTIPLE_TIMES, `The query feature "${type}()" was used multiple times.`);
@@ -386,10 +388,53 @@ const invalidStateInsideSecureExecuteFactory = () => {
386
388
  const circularQueryDependency = () => {
387
389
  return new RuntimeError(QueryRuntimeErrorCode.CIRCULAR_QUERY_DEPENDENCY, `Query was executed more than 5 times in less than 100ms. This is usually a sign of a circular dependency.`);
388
390
  };
391
+ const legacyPrepareWithoutInjectionContext = (creatorName) => {
392
+ const label = creatorName ? `"${creatorName}"` : 'A legacy query creator';
393
+ return new RuntimeError(QueryRuntimeErrorCode.LEGACY_PREPARE_WITHOUT_INJECTION_CONTEXT, `${label}.prepare() was called outside of an injection context. Unlike the v2 client, the legacy interop layer needs an injector to build the underlying query.\n\n` +
394
+ `Capture one where a context does exist (field initializer, constructor) and pass it along:\n\n` +
395
+ ` private injector = inject(Injector);\n` +
396
+ ` search = (term: string) => myQuery.prepare({ queryParams: { term }, injector: this.injector });\n\n` +
397
+ `Prefer an injector that outlives the call site. A component injector passed from a callback that can still fire after the component is gone leaves prepare() with a destroyed injector, which returns an inert query instead of executing.`);
398
+ };
389
399
  const querySequenceAlreadyRunning = () => {
390
400
  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.`);
391
401
  };
392
402
 
403
+ const isClassValidatorError = (error) => {
404
+ return typeof error === 'object' && error !== null && 'statusCode' in error && 'message' in error && 'error' in error;
405
+ };
406
+ const isSymfonyError = (error) => {
407
+ if (!error || typeof error !== 'object') {
408
+ return false;
409
+ }
410
+ const symfonyError = error;
411
+ return (symfonyError.class !== undefined &&
412
+ symfonyError.detail !== undefined &&
413
+ symfonyError.status !== undefined &&
414
+ symfonyError.title !== undefined &&
415
+ symfonyError.trace !== undefined &&
416
+ symfonyError.type !== undefined);
417
+ };
418
+ const isSymfonyPagerfantaOutOfRangeError = (error) => {
419
+ if (!isSymfonyError(error)) {
420
+ return false;
421
+ }
422
+ return error.class.startsWith('Pagerfanta') && error.class.endsWith('OutOfRangeCurrentPageException');
423
+ };
424
+ const isSymfonyFormViolationListError = (error) => {
425
+ return typeof error === 'object' && error !== null && 'violations' in error;
426
+ };
427
+ const isSymfonyListError = (error) => {
428
+ return (!!error &&
429
+ typeof error === 'object' &&
430
+ Array.isArray(error) &&
431
+ !!error.length &&
432
+ typeof error[0] === 'object' &&
433
+ 'message' in error[0] &&
434
+ 'propertyPath' in error[0] &&
435
+ 'invalidValue' in error[0]);
436
+ };
437
+
393
438
  /**
394
439
  * Returning this inside a withArgs feature will reset the query args to null.
395
440
  * This will also pause polling and auto refresh until new args are set.
@@ -413,6 +458,7 @@ const QueryFeatureType = {
413
458
  WITH_POLLING: 'WITH_POLLING',
414
459
  WITH_AUTO_REFRESH: 'WITH_AUTO_REFRESH',
415
460
  WITH_RESPONSE_UPDATE: 'WITH_RESPONSE_UPDATE',
461
+ WITH_PAGE_RESET_ON_ERROR: 'WITH_PAGE_RESET_ON_ERROR',
416
462
  };
417
463
  const createQueryFeature = (config) => {
418
464
  return config;
@@ -578,6 +624,59 @@ const withResponseUpdate = (options) => {
578
624
  },
579
625
  });
580
626
  };
627
+ /**
628
+ * Whether an error means the requested page is out of range: HTTP `416`, or a
629
+ * `500` carrying a Symfony/Pagerfanta out-of-range detail (the dev-mode shape).
630
+ * Exported so it can also be used as the predicate of a `withErrorHandling` handler.
631
+ */
632
+ const isPageOutOfRangeError = (error) => {
633
+ if (error.code === 416)
634
+ return true;
635
+ return error.code === 500 && isSymfonyPagerfantaOutOfRangeError(error.raw.error);
636
+ };
637
+ /**
638
+ * A query feature that resets the page when the current page becomes out of range
639
+ * — e.g. after a filter shrinks the result set below the current page. It reacts
640
+ * to the query's error events and resets the page source; the normal reactive
641
+ * re-execution then runs with the corrected page.
642
+ *
643
+ * Because query args are reactively sourced, this fixes the *source* (a signal or
644
+ * query form field) rather than patching args directly — so the change sticks.
645
+ *
646
+ * @example
647
+ * // signal-driven page
648
+ * const users = getUsers(
649
+ * withArgs(() => ({ queryParams: { page: page() } })),
650
+ * withPageResetOnError({ page }),
651
+ * );
652
+ *
653
+ * @example
654
+ * // query-form-driven page
655
+ * const users = getUsers(
656
+ * withArgs(() => ({ queryParams: qf.value() })),
657
+ * withPageResetOnError({ reset: () => qf.resetFieldToDefault('page') }),
658
+ * );
659
+ */
660
+ const withPageResetOnError = (options) => {
661
+ const matches = options.when ?? isPageOutOfRangeError;
662
+ return createQueryFeature({
663
+ type: QueryFeatureType.WITH_PAGE_RESET_ON_ERROR,
664
+ fn: (context) => {
665
+ context.state.events$
666
+ .pipe(filter((event) => event.type === 'error'), takeUntilDestroyed(context.deps.destroyRef))
667
+ .subscribe((event) => {
668
+ if (!matches(event.error))
669
+ return;
670
+ if (options.reset) {
671
+ options.reset();
672
+ }
673
+ else {
674
+ options.page.set(options.resetTo ?? 1);
675
+ }
676
+ });
677
+ },
678
+ });
679
+ };
581
680
 
582
681
  const setupQueryState = (options) => {
583
682
  const request = signal(null, /* @ts-ignore */
@@ -963,6 +1062,19 @@ const createExecuteFn = (executeOptions) => {
963
1062
  };
964
1063
 
965
1064
  /* eslint-disable @typescript-eslint/no-explicit-any */
1065
+ /**
1066
+ * The empty request-args object, typed to satisfy `RequestArgs<TArgs>` for a query that takes no
1067
+ * arguments — its `QueryArgs` is just `{ response: T }`, so `RequestArgs` resolves to `{}`.
1068
+ *
1069
+ * Only needed in generic code, where `TArgs` is still a type parameter and TS therefore cannot see
1070
+ * that `{}` is the whole of it. Against a concrete query, omit the argument instead: both
1071
+ * `query.execute()` and the auth registry's `execute()` already take none.
1072
+ *
1073
+ * @example
1074
+ * const runAnything = <TArgs extends QueryArgs>(query: Query<TArgs>) =>
1075
+ * query.execute({ args: emptyQueryArgs<TArgs>() });
1076
+ */
1077
+ const emptyQueryArgs = () => ({});
966
1078
  const createQuery = (options) => createBaseQuery({
967
1079
  creator: options.creator,
968
1080
  creatorInternals: options.creatorInternals,
@@ -1012,41 +1124,6 @@ const createBaseQueryCreator = (config) => {
1012
1124
  return queryCreator;
1013
1125
  };
1014
1126
 
1015
- const isClassValidatorError = (error) => {
1016
- return typeof error === 'object' && error !== null && 'statusCode' in error && 'message' in error && 'error' in error;
1017
- };
1018
- const isSymfonyError = (error) => {
1019
- if (!error || typeof error !== 'object') {
1020
- return false;
1021
- }
1022
- const symfonyError = error;
1023
- return (symfonyError.class !== undefined &&
1024
- symfonyError.detail !== undefined &&
1025
- symfonyError.status !== undefined &&
1026
- symfonyError.title !== undefined &&
1027
- symfonyError.trace !== undefined &&
1028
- symfonyError.type !== undefined);
1029
- };
1030
- const isSymfonyPagerfantaOutOfRangeError = (error) => {
1031
- if (!isSymfonyError(error)) {
1032
- return false;
1033
- }
1034
- return error.class.startsWith('Pagerfanta') && error.class.endsWith('OutOfRangeCurrentPageException');
1035
- };
1036
- const isSymfonyFormViolationListError = (error) => {
1037
- return typeof error === 'object' && error !== null && 'violations' in error;
1038
- };
1039
- const isSymfonyListError = (error) => {
1040
- return (!!error &&
1041
- typeof error === 'object' &&
1042
- Array.isArray(error) &&
1043
- !!error.length &&
1044
- typeof error[0] === 'object' &&
1045
- 'message' in error[0] &&
1046
- 'propertyPath' in error[0] &&
1047
- 'invalidValue' in error[0]);
1048
- };
1049
-
1050
1127
  const buildErrorMessage = (code, message) => `[@ethlete\\query:${code}] ${message}`;
1051
1128
  class QueryError extends Error {
1052
1129
  constructor(code, message, data) {
@@ -1647,6 +1724,31 @@ const shouldRetryRequest = (options) => {
1647
1724
  const isQueryErrorResponse = (error) => {
1648
1725
  return typeof error === 'object' && error !== null && 'raw' in error && 'retryState' in error && 'isList' in error;
1649
1726
  };
1727
+ /**
1728
+ * Every message carried by an error, flattened into a plain list — one entry for a single error,
1729
+ * one per violation for a list (a form response with several field violations), empty for `null`.
1730
+ *
1731
+ * The single/list split exists because that is how APIs answer; UI almost never wants to branch on
1732
+ * it. Reach for this when rendering, and keep {@link QueryErrorResponse} for anything that needs
1733
+ * the status code or the raw response.
1734
+ *
1735
+ * @example
1736
+ * ```html
1737
+ * @for (message of queryErrorMessages(myQuery.error()); track message) {
1738
+ * <p class="error">{{ message }}</p>
1739
+ * }
1740
+ * ```
1741
+ */
1742
+ const queryErrorMessages = (error) => {
1743
+ if (!error)
1744
+ return [];
1745
+ return error.isList ? error.errors.map((item) => item.message) : [error.error.message];
1746
+ };
1747
+ /**
1748
+ * The first message of an error, or `null` when there is none — the single-line counterpart to
1749
+ * {@link queryErrorMessages}, for a toast or a form-field hint.
1750
+ */
1751
+ const queryErrorMessage = (error) => queryErrorMessages(error)[0] ?? null;
1650
1752
  const createQueryErrorResponse = (error) => {
1651
1753
  let err = error instanceof HttpErrorResponse ? error : null;
1652
1754
  if (!err) {
@@ -1739,8 +1841,21 @@ const createHttpRequest = (options) => {
1739
1841
  const expiresInTs = expiresIn();
1740
1842
  return expiresInTs === null || expiresInTs < Date.now();
1741
1843
  };
1844
+ // Resolved per execution rather than once at creation, so a client whose headers are a function
1845
+ // reading a signal (a preview token, a tenant id) sees the current value on every re-run.
1846
+ const resolveHeaders = () => {
1847
+ const clientHeaders = typeof options.clientHeaders === 'function' ? options.clientHeaders() : options.clientHeaders;
1848
+ const argHeaders = typeof args?.headers === 'function' ? args.headers() : args?.headers;
1849
+ if (!clientHeaders)
1850
+ return argHeaders;
1851
+ if (!argHeaders)
1852
+ return clientHeaders;
1853
+ // `set` replaces every value of that name, so a per-request header fully overrides the
1854
+ // client-level one instead of being appended next to it.
1855
+ return argHeaders.keys().reduce((merged, key) => merged.set(key, argHeaders.getAll(key) ?? []), clientHeaders);
1856
+ };
1742
1857
  const createStream = () => {
1743
- const headers = typeof args?.headers === 'function' ? args.headers() : args?.headers;
1858
+ const headers = resolveHeaders();
1744
1859
  return dependencies.httpClient
1745
1860
  .request(options.method, options.fullPath, {
1746
1861
  observe: 'events',
@@ -1766,7 +1881,7 @@ const createHttpRequest = (options) => {
1766
1881
  }));
1767
1882
  };
1768
1883
  const execute = (options) => {
1769
- if (loading() || (!isStale() && options?.allowCache)) {
1884
+ if (!options?.force && (loading() || (!isStale() && options?.allowCache))) {
1770
1885
  // Do not execute if there is already a request in progress or caching is allowed
1771
1886
  return false;
1772
1887
  }
@@ -2879,6 +2994,12 @@ const createSecureExecuteFactory = (options) => {
2879
2994
  executeOptions: { deps: options.deps, state: options.state },
2880
2995
  });
2881
2996
  };
2997
+ // A logout tears down the secure entries in the repository, but the query object holding one keeps
2998
+ // its own `response`/`error` signals — so without this a component still mounted after logout goes
2999
+ // on rendering the previous user's data until something calls `reset()` by hand.
3000
+ options.deps.client.repository.events$
3001
+ .pipe(filter((event) => event.type === 'unbind-all-secure'), takeUntilDestroyed(options.deps.destroyRef))
3002
+ .subscribe(() => reset());
2882
3003
  const error = (query) => {
2883
3004
  const state = options.state;
2884
3005
  state.error.set(query.error());
@@ -3196,10 +3317,18 @@ class LegacyQuery {
3196
3317
  get _isInMockMode() {
3197
3318
  return false;
3198
3319
  }
3199
- constructor(newQuery, _arguments, entity) {
3320
+ constructor(newQuery, _arguments, entity,
3321
+ /**
3322
+ * Wraps an inert query — see `createInertQuery`. Skips everything that needs a live injector,
3323
+ * so the wrapper is safe to build during teardown.
3324
+ *
3325
+ * @internal
3326
+ */
3327
+ _isInert = false) {
3200
3328
  this.newQuery = newQuery;
3201
3329
  this._arguments = _arguments;
3202
3330
  this.entity = entity;
3331
+ this._isInert = _isInert;
3203
3332
  /**
3204
3333
  * @internal
3205
3334
  */
@@ -3208,9 +3337,31 @@ class LegacyQuery {
3208
3337
  * @internal
3209
3338
  */
3210
3339
  this._currentPollConfig = null;
3340
+ /**
3341
+ * `null` for an inert query — there is no live injector to own an effect.
3342
+ *
3343
+ * @internal
3344
+ */
3345
+ this.storeSyncEffect = null;
3346
+ /**
3347
+ * @internal
3348
+ */
3349
+ this._dependents = {};
3350
+ /**
3351
+ * @internal
3352
+ */
3353
+ this._dependentsChanged$ = new ReplaySubject();
3211
3354
  /**
3212
3355
  * @internal
3213
3356
  */
3357
+ this._isPollingPaused = false;
3358
+ this._isDestroyed = false;
3359
+ if (this._isInert) {
3360
+ this.state$ = of(transformExecStateToQueryState(null));
3361
+ this._isDestroyed = true;
3362
+ return;
3363
+ }
3364
+ this.state$ = toObservable(this.newQuery.executionState, { injector: this.newQuery.subtle.injector }).pipe(map((execState) => transformExecStateToQueryState(execState)), switchMap((s) => this._transformState(s)), shareReplay({ bufferSize: 1, refCount: true }));
3214
3365
  this.storeSyncEffect = effect(() => {
3215
3366
  const res = this.newQuery.response();
3216
3367
  untracked(() => {
@@ -3226,27 +3377,28 @@ class LegacyQuery {
3226
3377
  });
3227
3378
  }, /* @ts-ignore */
3228
3379
  ...(ngDevMode ? [{ debugName: "storeSyncEffect" }] : /* istanbul ignore next */ []));
3229
- /**
3230
- * @internal
3231
- */
3232
- this._dependents = {};
3233
- /**
3234
- * @internal
3235
- */
3236
- this._dependentsChanged$ = new ReplaySubject();
3237
- /**
3238
- * @internal
3239
- */
3240
- this._isPollingPaused = false;
3241
- this.state$ = toObservable(this.newQuery.executionState, { injector: this.newQuery.subtle.injector }).pipe(map((execState) => transformExecStateToQueryState(execState)), switchMap((s) => this._transformState(s)), shareReplay({ bufferSize: 1, refCount: true }));
3242
- this.newQuery.subtle.destroyRef.onDestroy(() => this.destroy());
3380
+ // Tear down only what this wrapper owns. The underlying query is already being destroyed when this fires, so
3381
+ // destroying it again would call `R3Injector.destroy()` on an injector that is mid-teardown, which throws
3382
+ // NG0205 ("Injector has already been destroyed") from inside the view's cleanup phase.
3383
+ this.newQuery.subtle.destroyRef.onDestroy(() => this._teardown());
3243
3384
  }
3244
- destroy() {
3385
+ _teardown() {
3386
+ if (this._isDestroyed) {
3387
+ return;
3388
+ }
3389
+ this._isDestroyed = true;
3245
3390
  this._pollingSubscription?.unsubscribe();
3246
3391
  this._pollingSubscription = null;
3247
3392
  this._dependentsChanged$.complete();
3393
+ this.storeSyncEffect?.destroy();
3394
+ }
3395
+ destroy() {
3396
+ if (this._isDestroyed) {
3397
+ return;
3398
+ }
3399
+ this._teardown();
3400
+ // Re-enters `_teardown` through the query's `destroyRef`, which the `_isDestroyed` guard absorbs.
3248
3401
  this.newQuery.subtle.destroy();
3249
- this.storeSyncEffect.destroy();
3250
3402
  }
3251
3403
  execute(options = {}) {
3252
3404
  untracked(() => this.newQuery.execute({ args: this._arguments, options: { allowCache: options.skipCache !== true } }));
@@ -3260,7 +3412,8 @@ class LegacyQuery {
3260
3412
  return this;
3261
3413
  }
3262
3414
  poll(config) {
3263
- if (this._pollingSubscription) {
3415
+ // An inert or already destroyed query has nothing to poll, and the interval would outlive it.
3416
+ if (this._pollingSubscription || this._isDestroyed) {
3264
3417
  return this;
3265
3418
  }
3266
3419
  this._currentPollConfig = config;
@@ -3345,11 +3498,89 @@ class LegacyQuery {
3345
3498
  }
3346
3499
  }
3347
3500
 
3501
+ /**
3502
+ * A query that never executes and never leaves its initial state.
3503
+ *
3504
+ * `LegacyQueryCreator.prepare()` hands one back when the injector it was given is already destroyed
3505
+ * — the shape v2 call sites still fire into during teardown (a debounced search resolving after the
3506
+ * user navigated away, an RxJS callback on a stream that has not been unsubscribed yet). v2 had no
3507
+ * injector at all there, so the same code used to be harmless; building a real query would now
3508
+ * throw NG0205 from inside the view's cleanup phase, with no hint about which query caused it.
3509
+ *
3510
+ * Everything reads as "prepared but never executed": `execute()` and friends are no-ops, signals
3511
+ * stay `null`, observables complete immediately. Nothing here touches Angular's DI, which is the
3512
+ * point — there is no live injector left to bind to.
3513
+ */
3514
+ const createInertQuery = () => {
3515
+ const inertSignal = (value) => Object.assign(signal(value), { asObservable: () => of(value) });
3516
+ const noop = () => {
3517
+ /* the query is inert — there is nothing to execute, reset or destroy */
3518
+ };
3519
+ const base = {
3520
+ args: inertSignal(null),
3521
+ response: inertSignal(null),
3522
+ latestHttpEvent: inertSignal(null),
3523
+ loading: inertSignal(null),
3524
+ error: inertSignal(null),
3525
+ lastTimeExecutedAt: inertSignal(null),
3526
+ triggeredBy: inertSignal(null),
3527
+ id: inertSignal(null),
3528
+ executionState: inertSignal(null),
3529
+ };
3530
+ const createSnapshot = () => ({ ...base, isAlive: inertSignal(false) });
3531
+ // A DestroyRef that is already destroyed: callbacks registered on it are dropped rather than
3532
+ // stored, so nothing keeps this object (or its registrant) alive.
3533
+ const destroyRef = {
3534
+ destroyed: true,
3535
+ onDestroy: () => noop,
3536
+ };
3537
+ return {
3538
+ ...base,
3539
+ execute: noop,
3540
+ createSnapshot,
3541
+ reset: noop,
3542
+ asReadonly: () => ({ ...base, createSnapshot }),
3543
+ subtle: {
3544
+ destroy: noop,
3545
+ setResponse: noop,
3546
+ setLoading: noop,
3547
+ setError: noop,
3548
+ request: inertSignal(null),
3549
+ destroyRef,
3550
+ injector: Injector.NULL,
3551
+ },
3552
+ };
3553
+ };
3554
+
3555
+ /**
3556
+ * Whether an injector can still be used. A destroyed `R3Injector` throws on `get()` rather than
3557
+ * answering, so the throw itself is the answer; a node injector hands back a `DestroyRef` that
3558
+ * reports its own state.
3559
+ */
3560
+ const isInjectorUsable = (injector) => {
3561
+ try {
3562
+ return injector.get(DestroyRef, null, { optional: true })?.destroyed !== true;
3563
+ }
3564
+ catch {
3565
+ return false;
3566
+ }
3567
+ };
3348
3568
  class LegacyQueryCreator {
3349
3569
  constructor(options) {
3350
3570
  this.options = options;
3351
3571
  this.prepare = (args) => {
3352
- const injector = args?.injector ?? inject(Injector);
3572
+ // v2's `prepare()` needed no injection context, so plenty of call sites are plain callbacks.
3573
+ // Angular's raw NG0203 names neither the query nor the call site, which makes those a bisection
3574
+ // exercise — replace it with an error that does.
3575
+ const injector = args?.injector ??
3576
+ (() => {
3577
+ try {
3578
+ return inject(Injector);
3579
+ }
3580
+ catch {
3581
+ throw legacyPrepareWithoutInjectionContext(this.options.name);
3582
+ }
3583
+ })();
3353
3584
  let headers = new HttpHeaders();
3354
3585
  if (args?.headers) {
3355
3586
  Object.entries(args.headers).forEach(([key, value]) => {
@@ -3364,6 +3595,17 @@ class LegacyQueryCreator {
3364
3595
  ...(args?.queryParams ? { queryParams: args.queryParams } : {}),
3365
3596
  headers,
3366
3597
  };
3598
+ // A captured component injector routinely outlives its component here: a debounced search
3599
+ // resolving after navigation, an RxJS callback on a stream torn down a tick later. That is not
3600
+ // a programming error the way it would be in new code — v2 had no injector to destroy — so it
3601
+ // yields a query that does nothing instead of an NG0205 raised from the view's cleanup phase.
3602
+ if (!isInjectorUsable(injector)) {
3603
+ if (isDevMode()) {
3604
+ console.warn(`${this.options.name ? `"${this.options.name}"` : 'A legacy query'}.prepare() was called with a destroyed injector, so an inert query was returned. ` +
3605
+ `This usually means a callback outlived the component whose injector it captured — guard the call site with DestroyRef.onDestroy, or pass an injector that outlives it.`);
3606
+ }
3607
+ return new LegacyQuery(createInertQuery(), queryArgs, this.options.entity, true);
3608
+ }
3367
3609
  return runInInjectionContext(injector, () => {
3368
3610
  return untracked(() => {
3369
3611
  const newQuery = this.options.creator({
@@ -5453,6 +5695,14 @@ const validateWithV2Query = (path, config) => {
5453
5695
  };
5454
5696
 
5455
5697
  const generateUuid = () => randomId();
5698
+ /** @see CreateQueryClientConfigOptions.keepUnusedFor */
5699
+ const DEFAULT_KEEP_UNUSED_FOR = 300_000;
5700
+ /**
5701
+ * Hard cap on entries kept without consumers, per client. Retention is opportunistic, so a runaway
5702
+ * count (a search-as-you-type query produces a new cache key per keystroke, and each one goes through
5703
+ * `unbind`) must never grow unbounded — the least recently orphaned entries are dropped first.
5704
+ */
5705
+ const MAX_UNUSED_ENTRIES = 50;
5456
5706
  const createQueryRepository = (config) => {
5457
5707
  const cache = new Map();
5458
5708
  const eventsSubject = new Subject();
@@ -5461,6 +5711,11 @@ const createQueryRepository = (config) => {
5461
5711
  const cacheVersion = signal(0, /* @ts-ignore */
5462
5712
  ...(ngDevMode ? [{ debugName: "cacheVersion" }] : /* istanbul ignore next */ []));
5463
5713
  const bumpCacheVersion = () => cacheVersion.update((v) => v + 1);
5714
+ const resolveKeepUnusedFor = (creatorOptions, shouldCache) => {
5715
+ if (config.retentionEnabled === false || !shouldCache)
5716
+ return 0;
5717
+ return Math.max(0, creatorOptions?.keepUnusedFor ?? config.keepUnusedFor ?? DEFAULT_KEEP_UNUSED_FOR);
5718
+ };
5464
5719
  const request = (options) => {
5465
5720
  const { args, creatorOptions, runQueryOptions } = options;
5466
5721
  const shouldCache = creatorOptions?.subtle?.useQueryRepositoryCache === false
@@ -5486,6 +5741,7 @@ const createQueryRepository = (config) => {
5486
5741
  })
5487
5742
  : false;
5488
5743
  const trackingKey = cacheKey || generateUuid();
5744
+ const keepUnusedFor = resolveKeepUnusedFor(creatorOptions, shouldCache);
5489
5745
  const previousKey = options.previousKey;
5490
5746
  if (cacheKey !== previousKey && previousKey) {
5491
5747
  unbind(previousKey, options.consumerDestroyRef);
@@ -5493,7 +5749,10 @@ const createQueryRepository = (config) => {
5493
5749
  if (shouldCache && cacheKey) {
5494
5750
  const cacheEntry = cache.get(cacheKey);
5495
5751
  if (cacheEntry) {
5496
- bind(cacheKey, options.consumerDestroyRef, cacheEntry.request, options.isSecure ?? false, true);
5752
+ // The entry may have been sitting out its `keepUnusedFor` window — a consumer binding again
5753
+ // makes it live, so the pending eviction must go.
5754
+ cancelEviction(cacheEntry);
5755
+ bind(cacheKey, options.consumerDestroyRef, cacheEntry.request, options.isSecure ?? false, true, keepUnusedFor);
5497
5756
  if (!runQueryOptions?.allowCache || cacheEntry.request.isStale()) {
5498
5757
  cacheEntry.request.execute({ allowCache: runQueryOptions?.allowCache });
5499
5758
  }
@@ -5506,13 +5765,43 @@ const createQueryRepository = (config) => {
5506
5765
  method: options.method,
5507
5766
  dependencies: config.dependencies,
5508
5767
  clientOptions: creatorOptions,
5768
+ clientHeaders: config.headers,
5509
5769
  cacheAdapter: config.cacheAdapter,
5510
5770
  retryFn: options.retryFn ?? config.retryFn,
5511
5771
  });
5512
5772
  request.execute();
5513
- bind(trackingKey, options.consumerDestroyRef, request, options.isSecure ?? false, shouldCache);
5773
+ bind(trackingKey, options.consumerDestroyRef, request, options.isSecure ?? false, shouldCache, keepUnusedFor);
5514
5774
  return { key: trackingKey, request };
5515
5775
  };
5776
+ /** Tears an entry down for good, whether it currently has consumers or not. */
5777
+ const destroyEntry = (key, cacheEntry) => {
5778
+ clearTimeout(cacheEntry.evictTimer);
5779
+ cacheEntry.request.destroy();
5780
+ cacheEntry.eventSubscription?.unsubscribe();
5781
+ cache.delete(key);
5782
+ };
5783
+ const cancelEviction = (cacheEntry) => {
5784
+ if (cacheEntry.evictTimer === undefined)
5785
+ return;
5786
+ clearTimeout(cacheEntry.evictTimer);
5787
+ cacheEntry.evictTimer = undefined;
5788
+ cacheEntry.unusedSince = undefined;
5789
+ };
5790
+ /** Drops the least recently orphaned unused entries until the cap is respected again. */
5791
+ const enforceUnusedEntryLimit = () => {
5792
+ const unused = Array.from(cache.entries()).filter(([, entry]) => entry.consumers.size === 0);
5793
+ if (unused.length <= MAX_UNUSED_ENTRIES)
5794
+ return;
5795
+ unused.sort(([, a], [, b]) => (a.unusedSince ?? 0) - (b.unusedSince ?? 0));
5796
+ for (const [key, entry] of unused.slice(0, unused.length - MAX_UNUSED_ENTRIES)) {
5797
+ destroyEntry(key, entry);
5798
+ }
5799
+ };
5800
+ const retain = (key, cacheEntry) => {
5801
+ cacheEntry.unusedSince = Date.now();
5802
+ cacheEntry.evictTimer = setTimeout(() => evict(key), cacheEntry.keepUnusedFor);
5803
+ enforceUnusedEntryLimit();
5804
+ };
5516
5805
  const unbind = (key, consumerDestroyRef) => {
5517
5806
  if (key === null)
5518
5807
  return false;
@@ -5521,26 +5810,41 @@ const createQueryRepository = (config) => {
5521
5810
  return false;
5522
5811
  cacheEntry.consumers.delete(consumerDestroyRef);
5523
5812
  if (cacheEntry.consumers.size === 0) {
5524
- cacheEntry.request.destroy();
5525
- cacheEntry.eventSubscription?.unsubscribe();
5526
- cache.delete(key);
5813
+ // Only data is worth keeping around: an entry that never produced a response has nothing to
5814
+ // hand back to a returning consumer, so it is aborted immediately as it always was.
5815
+ if (cacheEntry.keepUnusedFor > 0 && cacheEntry.request.response() !== null) {
5816
+ retain(key, cacheEntry);
5817
+ }
5818
+ else {
5819
+ destroyEntry(key, cacheEntry);
5820
+ }
5527
5821
  }
5528
5822
  bumpCacheVersion();
5529
5823
  return true;
5530
5824
  };
5531
5825
  const unbindAllSecure = () => {
5532
5826
  for (const [key, cacheEntry] of cache.entries()) {
5533
- if (cacheEntry.isSecure) {
5534
- for (const consumerDestroyRef of cacheEntry.consumers.keys()) {
5535
- unbind(key, consumerDestroyRef);
5536
- }
5537
- cacheEntry.eventSubscription?.unsubscribe();
5538
- cache.delete(key);
5539
- }
5827
+ if (!cacheEntry.isSecure)
5828
+ continue;
5829
+ // Force the teardown instead of routing through `unbind`: a logged out session must not leave a
5830
+ // retained response body behind waiting out its `keepUnusedFor` window.
5831
+ cacheEntry.consumers.clear();
5832
+ destroyEntry(key, cacheEntry);
5540
5833
  }
5541
5834
  bumpCacheVersion();
5835
+ eventsSubject.next({ type: 'unbind-all-secure' });
5836
+ };
5837
+ const refreshInUse = () => {
5838
+ for (const cacheEntry of cache.values()) {
5839
+ if (cacheEntry.consumers.size === 0)
5840
+ continue;
5841
+ // Re-firing a mutation would be a side effect nobody asked for, so only reads are refreshed.
5842
+ if (!shouldCacheQuery(cacheEntry.request.method))
5843
+ continue;
5844
+ cacheEntry.request.execute({ force: true });
5845
+ }
5542
5846
  };
5543
- const bind = (key, consumerDestroyRef, request, isSecure, allowReuse) => {
5847
+ const bind = (key, consumerDestroyRef, request, isSecure, allowReuse, keepUnusedFor) => {
5544
5848
  const destroyListener = consumerDestroyRef.onDestroy(() => unbind(key, consumerDestroyRef));
5545
5849
  const cacheEntry = cache.get(key);
5546
5850
  if (cacheEntry && allowReuse) {
@@ -5567,6 +5871,7 @@ const createQueryRepository = (config) => {
5567
5871
  request,
5568
5872
  isSecure,
5569
5873
  eventSubscription,
5874
+ keepUnusedFor,
5570
5875
  });
5571
5876
  }
5572
5877
  bumpCacheVersion();
@@ -5575,21 +5880,21 @@ const createQueryRepository = (config) => {
5575
5880
  key,
5576
5881
  isSecure: entry.isSecure,
5577
5882
  consumerCount: entry.consumers.size,
5883
+ isUnused: entry.consumers.size === 0,
5578
5884
  request: entry.request,
5579
5885
  }));
5580
5886
  const evict = (key) => {
5581
5887
  const entry = cache.get(key);
5582
5888
  if (!entry)
5583
5889
  return;
5584
- entry.request.destroy();
5585
- entry.eventSubscription?.unsubscribe();
5586
- cache.delete(key);
5890
+ destroyEntry(key, entry);
5587
5891
  bumpCacheVersion();
5588
5892
  };
5589
5893
  const repository = {
5590
5894
  request,
5591
5895
  unbind,
5592
5896
  unbindAllSecure,
5897
+ refreshInUse,
5593
5898
  events$: eventsSubject.asObservable(),
5594
5899
  subtle: {
5595
5900
  cacheEntries,
@@ -5606,11 +5911,15 @@ const createQueryClient = (options) => createRootProvider(() => {
5606
5911
  const injector = inject(Injector);
5607
5912
  const repository = createQueryRepository({
5608
5913
  ...options,
5914
+ // Retaining unused entries on the server would pin response bodies (and a pending timer) inside
5915
+ // a per-request injector for the whole window, so retention is browser only.
5916
+ retentionEnabled: isPlatformBrowser(inject(PLATFORM_ID)),
5609
5917
  dependencies: { httpClient, ngErrorHandler, injector },
5610
5918
  });
5611
5919
  const client = {
5612
5920
  repository,
5613
5921
  baseUrl: options.baseUrl,
5922
+ refreshQueriesInUse: () => repository.refreshInUse(),
5614
5923
  };
5615
5924
  return client;
5616
5925
  }, {
@@ -6453,6 +6762,7 @@ const createBearerAuthProviderImpl = (config) => {
6453
6762
  latestExecutedQuery: latestExecutedQuery.asReadonly(),
6454
6763
  latestNonInternalQuery: latestNonInternalQuery.asReadonly(),
6455
6764
  executionState: executionState.asReadonly(),
6765
+ setTokens,
6456
6766
  logout,
6457
6767
  afterTokenRefresh$,
6458
6768
  };
@@ -7579,7 +7889,7 @@ class DateArrayQueryField {
7579
7889
  };
7580
7890
  }
7581
7891
  }
7582
- const IGNORED_FILTER_COUNT_FIELDS = ['page', 'skip', 'take', 'limit', 'sort', 'sortBy', 'sortOrder', 'query', 'search'];
7892
+ const IGNORED_FILTER_COUNT_FIELDS$1 = ['page', 'skip', 'take', 'limit', 'sort', 'sortBy', 'sortOrder', 'query', 'search'];
7583
7893
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7584
7894
  class QueryForm {
7585
7895
  get _formValue() {
@@ -7654,7 +7964,7 @@ class QueryForm {
7654
7964
  */
7655
7965
  this.activeFilterCount$ = this.changes$.pipe(map(({ currentValue }) => Object.entries(currentValue)
7656
7966
  .map(([key, value]) => {
7657
- if (IGNORED_FILTER_COUNT_FIELDS.includes(key) || this._fields[key]?.data.skipInFilterCount) {
7967
+ if (IGNORED_FILTER_COUNT_FIELDS$1.includes(key) || this._fields[key]?.data.skipInFilterCount) {
7658
7968
  return true;
7659
7969
  }
7660
7970
  return this._isDefaultValue(key, value);
@@ -8020,6 +8330,433 @@ class QueryForm {
8020
8330
  }
8021
8331
  }
8022
8332
 
8333
+ /** URL sentinel for an explicit `null` value (a bare empty param would be ambiguous). */
8334
+ const ET_NULL_VALUE = 'ET_NULL__';
8335
+ /**
8336
+ * Fields excluded from `activeFilterCount` by default — pagination, sorting and
8337
+ * search are navigation state, not filters.
8338
+ */
8339
+ const IGNORED_FILTER_COUNT_FIELDS = [
8340
+ 'page',
8341
+ 'skip',
8342
+ 'take',
8343
+ 'limit',
8344
+ 'sort',
8345
+ 'sortBy',
8346
+ 'sortOrder',
8347
+ 'query',
8348
+ 'search',
8349
+ ];
8350
+ const resolveDefault = (def) => {
8351
+ const { defaultValue } = def;
8352
+ return typeof defaultValue === 'function' ? defaultValue() : (defaultValue ?? null);
8353
+ };
8354
+ const buildDefaults = (fields) => {
8355
+ const out = {};
8356
+ for (const [key, def] of Object.entries(fields)) {
8357
+ out[key] = resolveDefault(def);
8358
+ }
8359
+ return out;
8360
+ };
8361
+ const changedKeysBetween = (previous, current) => Object.keys(current).filter((key) => !equal(previous?.[key], current[key]));
8362
+ /** Best-effort URL string → value coercion, mirroring the auto-transform of the legacy QueryForm. */
8363
+ const autoCoerce = (raw, defaultValue) => {
8364
+ if (raw === ET_NULL_VALUE)
8365
+ return null;
8366
+ if (typeof raw !== 'string')
8367
+ return raw;
8368
+ const defaultIsNumber = typeof defaultValue === 'number';
8369
+ const looksNumeric = raw.trim() === raw && !raw.startsWith('0') && !raw.endsWith('.') && !isNaN(Number(raw));
8370
+ if (defaultIsNumber || (looksNumeric && raw !== '')) {
8371
+ return transformToNumber(raw);
8372
+ }
8373
+ if (raw === 'true' || raw === 'false') {
8374
+ return transformToBoolean(raw);
8375
+ }
8376
+ return raw;
8377
+ };
8378
+ const computeFilterCount = (fields, value, defaults) => {
8379
+ let count = 0;
8380
+ for (const [key, def] of Object.entries(fields)) {
8381
+ if (IGNORED_FILTER_COUNT_FIELDS.includes(key) || def.skipInFilterCount)
8382
+ continue;
8383
+ if (!equal(value[key], defaults[key]))
8384
+ count++;
8385
+ }
8386
+ return count;
8387
+ };
8388
+ const createBranch = (fields, initial, injector) => {
8389
+ const defaults = buildDefaults(fields);
8390
+ const model = signal(clone(initial), /* @ts-ignore */
8391
+ ...(ngDevMode ? [{ debugName: "model" }] : /* istanbul ignore next */ []));
8392
+ const tree = form(model, { injector });
8393
+ return {
8394
+ fields: tree,
8395
+ value: model.asReadonly(),
8396
+ activeFilterCount: computed(() => computeFilterCount(fields, model(), defaults)),
8397
+ setValue: (value) => model.set(clone(value)),
8398
+ patchValue: (value) => model.update((cur) => ({ ...cur, ...value })),
8399
+ resetFieldToDefault: (key) => model.update((cur) => ({ ...cur, [key]: defaults[key] })),
8400
+ resetAllFieldsToDefault: () => model.set(clone(defaults)),
8401
+ };
8402
+ };
8403
+ /**
8404
+ * A signals-first query form: binds on-screen controls to typed fields, keeps a
8405
+ * debounced committed value, syncs to the URL query params, and resets dependent
8406
+ * fields via the `isResetBy` graph — all as signals.
8407
+ *
8408
+ * @example
8409
+ * const qf = createQueryForm({
8410
+ * fields: {
8411
+ * search: searchQueryField(),
8412
+ * sort: sortQueryField(),
8413
+ * page: queryField<number>({ defaultValue: 1, isResetBy: ['search', 'sort'] }),
8414
+ * },
8415
+ * }).observe();
8416
+ * // template: <input etInput [formField]="qf.fields.search" />
8417
+ */
8418
+ class QueryFormSignals {
8419
+ constructor(config) {
8420
+ this._router = inject(Router);
8421
+ this._route = inject(ActivatedRoute);
8422
+ this._injector = inject(Injector);
8423
+ this._previous = signal(null, /* @ts-ignore */
8424
+ ...(ngDevMode ? [{ debugName: "_previous" }] : /* istanbul ignore next */ []));
8425
+ this._observing = signal(false, /* @ts-ignore */
8426
+ ...(ngDevMode ? [{ debugName: "_observing" }] : /* istanbul ignore next */ []));
8427
+ this._queryParamChanges = injectQueryParamChanges();
8428
+ this._pendingTimer = null;
8429
+ this._skipNextResets = false;
8430
+ assertInInjectionContext(QueryFormSignals);
8431
+ this._fields = config.fields;
8432
+ this._prefix = config.queryParamPrefix;
8433
+ this._defaults = buildDefaults(config.fields);
8434
+ this.defaultValue = clone(this._defaults);
8435
+ const initial = clone(this._defaults);
8436
+ this._model = signal(initial, /* @ts-ignore */
8437
+ ...(ngDevMode ? [{ debugName: "_model" }] : /* istanbul ignore next */ []));
8438
+ this._committed = signal(clone(initial), /* @ts-ignore */
8439
+ ...(ngDevMode ? [{ debugName: "_committed" }] : /* istanbul ignore next */ []));
8440
+ this.fields = form(this._model);
8441
+ this.value = this._committed.asReadonly();
8442
+ this.previousValue = this._previous.asReadonly();
8443
+ this.changes = computed(() => ({ previousValue: this._previous(), currentValue: this._committed() }), /* @ts-ignore */
8444
+ ...(ngDevMode ? [{ debugName: "changes" }] : /* istanbul ignore next */ []));
8445
+ this.activeFilterCount = computed(() => computeFilterCount(this._fields, this._committed(), this._defaults), /* @ts-ignore */
8446
+ ...(ngDevMode ? [{ debugName: "activeFilterCount" }] : /* istanbul ignore next */ []));
8447
+ // React to live control edits: schedule a debounced commit.
8448
+ effect(() => {
8449
+ const live = this._model();
8450
+ untracked(() => this._onLiveChange(live));
8451
+ });
8452
+ // React to navigation (back/forward, external links): apply URL → form immediately.
8453
+ effect(() => {
8454
+ const changes = this._queryParamChanges();
8455
+ untracked(() => {
8456
+ if (!this._observing() || this._observeOptions?.syncOnNavigation === false)
8457
+ return;
8458
+ this._applyFromUrl(changes);
8459
+ });
8460
+ });
8461
+ inject(DestroyRef).onDestroy(() => this._cleanup());
8462
+ }
8463
+ observe(options) {
8464
+ if (this._observing()) {
8465
+ if (isDevMode()) {
8466
+ console.warn('QueryFormSignals.observe() was called more than once. Ignoring the extra call.');
8467
+ }
8468
+ return this;
8469
+ }
8470
+ this._observeOptions = options;
8471
+ this._observing.set(true);
8472
+ if (options?.syncOnNavigation !== false) {
8473
+ this._applyFromUrl(this._route.snapshot.queryParams);
8474
+ }
8475
+ // Commit whatever the model holds now (URL-restored or programmatic defaults).
8476
+ this._flush();
8477
+ return this;
8478
+ }
8479
+ unobserve() {
8480
+ this._cleanup();
8481
+ }
8482
+ setValue(value, options) {
8483
+ if (options?.skipResets)
8484
+ this._skipNextResets = true;
8485
+ this._model.set(clone(value));
8486
+ }
8487
+ patchValue(value, options) {
8488
+ if (options?.skipResets)
8489
+ this._skipNextResets = true;
8490
+ this._model.update((cur) => ({ ...cur, ...value }));
8491
+ }
8492
+ resetFieldToDefault(key, options) {
8493
+ if (options?.skipResets)
8494
+ this._skipNextResets = true;
8495
+ this._model.update((cur) => ({ ...cur, [key]: this._defaults[key] }));
8496
+ }
8497
+ resetFieldsToDefault(keys, options) {
8498
+ if (options?.skipResets)
8499
+ this._skipNextResets = true;
8500
+ this._model.update((cur) => {
8501
+ const next = { ...cur };
8502
+ for (const key of keys) {
8503
+ next[key] = this._defaults[key];
8504
+ }
8505
+ return next;
8506
+ });
8507
+ }
8508
+ resetAllFieldsToDefault(options) {
8509
+ const skip = new Set((options?.skipFields ?? []).map((key) => key));
8510
+ const keys = Object.keys(this._fields).filter((key) => !skip.has(key));
8511
+ this.resetFieldsToDefault(keys, options);
8512
+ }
8513
+ /** Create a detached editor over the same fields, seeded from the current committed value. */
8514
+ branch() {
8515
+ return createBranch(this._fields, clone(this._committed()), this._injector);
8516
+ }
8517
+ _onLiveChange(live) {
8518
+ if (!this._observing())
8519
+ return;
8520
+ if (equal(live, this._committed()))
8521
+ return;
8522
+ const changedKeys = changedKeysBetween(this._committed(), live);
8523
+ const debounceMs = this._resolveDebounce(changedKeys, live);
8524
+ this._clearTimer();
8525
+ if (debounceMs === null) {
8526
+ this._flush();
8527
+ }
8528
+ else {
8529
+ this._pendingTimer = setTimeout(() => {
8530
+ this._pendingTimer = null;
8531
+ this._flush();
8532
+ }, debounceMs);
8533
+ }
8534
+ }
8535
+ /** Shortest-debounce-wins; `null` means commit immediately. */
8536
+ _resolveDebounce(changedKeys, live) {
8537
+ if (!changedKeys.length)
8538
+ return null;
8539
+ const times = [];
8540
+ for (const key of changedKeys) {
8541
+ const def = this._fields[key];
8542
+ if (!def)
8543
+ return null;
8544
+ if (def.disableDebounceIfFalsy && !live[key])
8545
+ return null;
8546
+ if (def.debounce === undefined)
8547
+ return null;
8548
+ times.push(def.debounce);
8549
+ }
8550
+ return Math.min(...times);
8551
+ }
8552
+ _flush() {
8553
+ this._clearTimer();
8554
+ const live = this._model();
8555
+ const previous = this._committed();
8556
+ if (equal(live, previous)) {
8557
+ this._skipNextResets = false;
8558
+ return;
8559
+ }
8560
+ const changedKeys = changedKeysBetween(previous, live);
8561
+ const next = this._skipNextResets ? { ...live } : this._applyResets(live, changedKeys);
8562
+ this._skipNextResets = false;
8563
+ this._previous.set(clone(previous));
8564
+ this._committed.set(clone(next));
8565
+ // Reflect any reset overrides back into the bound controls. The effect re-runs
8566
+ // but no-ops because the model now equals the committed value.
8567
+ if (!equal(next, live)) {
8568
+ this._model.set(clone(next));
8569
+ }
8570
+ if (this._observing() && this._observeOptions?.writeToQueryParams !== false) {
8571
+ this._writeToUrl(next);
8572
+ }
8573
+ }
8574
+ _applyResets(live, changedKeys) {
8575
+ const next = { ...live };
8576
+ for (const [key, def] of Object.entries(this._fields)) {
8577
+ const resets = def.isResetBy;
8578
+ if (!resets?.length)
8579
+ continue;
8580
+ const triggered = resets.some((resetKey) => {
8581
+ if (!(resetKey in this._fields)) {
8582
+ if (isDevMode()) {
8583
+ console.warn(`QueryFormSignals: isResetBy references unknown field "${resetKey}". Is it a typo?`);
8584
+ }
8585
+ return false;
8586
+ }
8587
+ return changedKeys.includes(resetKey);
8588
+ });
8589
+ if (!triggered)
8590
+ continue;
8591
+ const defaultValue = this._defaults[key];
8592
+ if (!equal(next[key], defaultValue)) {
8593
+ next[key] = defaultValue;
8594
+ }
8595
+ }
8596
+ return next;
8597
+ }
8598
+ _applyFromUrl(params) {
8599
+ const current = { ...this._committed() };
8600
+ let changed = false;
8601
+ for (const [key, def] of Object.entries(this._fields)) {
8602
+ const raw = params[this._paramKey(key)];
8603
+ if (raw === undefined)
8604
+ continue;
8605
+ const value = raw === ET_PROPERTY_REMOVED ? this._defaults[key] : this._deserialize(def, raw);
8606
+ if (!equal(current[key], value)) {
8607
+ current[key] = value;
8608
+ changed = true;
8609
+ }
8610
+ }
8611
+ if (!changed)
8612
+ return;
8613
+ this._commitFromUrl(current);
8614
+ }
8615
+ _commitFromUrl(next) {
8616
+ this._clearTimer();
8617
+ this._previous.set(clone(this._committed()));
8618
+ this._committed.set(clone(next));
8619
+ // Feed the URL value into the live model & bound controls; the effect no-ops (model === committed).
8620
+ this._model.set(clone(next));
8621
+ }
8622
+ _deserialize(def, raw) {
8623
+ if (def.queryParamToValue)
8624
+ return def.queryParamToValue(raw);
8625
+ if (def.skipAutoTransform)
8626
+ return raw;
8627
+ return autoCoerce(raw, resolveDefault(def));
8628
+ }
8629
+ _writeToUrl(value) {
8630
+ const queryParams = {};
8631
+ for (const [key, def] of Object.entries(this._fields)) {
8632
+ const paramKey = this._paramKey(key);
8633
+ const fieldValue = value[key];
8634
+ const isDefault = equal(fieldValue, this._defaults[key]);
8635
+ const writeToUrl = def.appendToUrl !== false;
8636
+ const writeDefault = def.appendDefaultValueToUrl === true;
8637
+ if (!writeToUrl || (isDefault && !writeDefault)) {
8638
+ queryParams[paramKey] = undefined;
8639
+ }
8640
+ else if (def.valueToQueryParam) {
8641
+ queryParams[paramKey] = def.valueToQueryParam(fieldValue);
8642
+ }
8643
+ else {
8644
+ queryParams[paramKey] = fieldValue === null ? ET_NULL_VALUE : fieldValue;
8645
+ }
8646
+ }
8647
+ queueMicrotask(() => {
8648
+ this._router.navigate([], {
8649
+ queryParams,
8650
+ queryParamsHandling: 'merge',
8651
+ replaceUrl: this._observeOptions?.replaceUrl,
8652
+ });
8653
+ });
8654
+ }
8655
+ _paramKey(key) {
8656
+ const prefix = this._prefix;
8657
+ if (!prefix)
8658
+ return key;
8659
+ return `${typeof prefix === 'string' ? prefix : prefix()}-${key}`;
8660
+ }
8661
+ _clearTimer() {
8662
+ if (this._pendingTimer !== null) {
8663
+ clearTimeout(this._pendingTimer);
8664
+ this._pendingTimer = null;
8665
+ }
8666
+ }
8667
+ _cleanup() {
8668
+ if (!this._observing())
8669
+ return;
8670
+ this._observing.set(false);
8671
+ this._clearTimer();
8672
+ if (this._observeOptions?.writeToQueryParams === false)
8673
+ return;
8674
+ const queryParams = {};
8675
+ for (const key of Object.keys(this._fields)) {
8676
+ queryParams[this._paramKey(key)] = undefined;
8677
+ }
8678
+ queueMicrotask(() => {
8679
+ this._router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl: true });
8680
+ });
8681
+ }
8682
+ }
8683
+ /**
8684
+ * Create a {@link QueryFormSignals}. Call `.observe()` to start syncing with the
8685
+ * URL. Must be called in an injection context.
8686
+ */
8687
+ const createQueryForm = (config) => new QueryFormSignals(config);
8688
+
8689
+ const normalizeConfig = (config) => {
8690
+ if (!config)
8691
+ return {};
8692
+ const { isResetBy, ...rest } = config;
8693
+ return {
8694
+ ...rest,
8695
+ isResetBy: isResetBy === undefined ? undefined : Array.isArray(isResetBy) ? isResetBy : [isResetBy],
8696
+ };
8697
+ };
8698
+ /**
8699
+ * A generic query-form field. Defaults to `null` and, unless a transform is
8700
+ * given, relies on the auto-coercion when reading back from the URL.
8701
+ *
8702
+ * @example
8703
+ * queryField<number>({ defaultValue: 1, isResetBy: ['search'] })
8704
+ */
8705
+ const queryField = (config) => ({
8706
+ defaultValue: null,
8707
+ ...normalizeConfig(config),
8708
+ });
8709
+ /**
8710
+ * A debounced free-text search field. Debounces typing by 300ms but applies
8711
+ * clearing immediately (`disableDebounceIfFalsy`).
8712
+ */
8713
+ const searchQueryField = (config) => ({
8714
+ defaultValue: null,
8715
+ debounce: 300,
8716
+ disableDebounceIfFalsy: true,
8717
+ ...normalizeConfig(config),
8718
+ });
8719
+ /**
8720
+ * A sort field serialized as `"active:direction"` (e.g. `"name:asc"`). Matches
8721
+ * the wire format the table system's URL adapter uses, so the two interoperate.
8722
+ */
8723
+ const sortQueryField = (config) => ({
8724
+ defaultValue: null,
8725
+ queryParamToValue: transformToSort,
8726
+ valueToQueryParam: transformToSortQueryParam,
8727
+ ...normalizeConfig(config),
8728
+ });
8729
+ /** A field holding a list of strings. */
8730
+ const stringArrayQueryField = (config) => ({
8731
+ defaultValue: null,
8732
+ queryParamToValue: transformToStringArray,
8733
+ ...normalizeConfig(config),
8734
+ });
8735
+ /** A field holding a list of numbers. */
8736
+ const numberArrayQueryField = (config) => ({
8737
+ defaultValue: null,
8738
+ queryParamToValue: transformToNumberArray,
8739
+ ...normalizeConfig(config),
8740
+ });
8741
+ /** A field holding a list of booleans. */
8742
+ const booleanArrayQueryField = (config) => ({
8743
+ defaultValue: null,
8744
+ queryParamToValue: transformToBooleanArray,
8745
+ ...normalizeConfig(config),
8746
+ });
8747
+ /** A single date field (expects an ISO/`Date`-parseable string in the URL). */
8748
+ const dateQueryField = (config) => ({
8749
+ defaultValue: null,
8750
+ queryParamToValue: transformToDate,
8751
+ ...normalizeConfig(config),
8752
+ });
8753
+ /** A field holding a list of dates. */
8754
+ const dateArrayQueryField = (config) => ({
8755
+ defaultValue: null,
8756
+ queryParamToValue: transformToDateArray,
8757
+ ...normalizeConfig(config),
8758
+ });
8759
+
8023
8760
  // codes 1000-1999
8024
8761
  const WebSocketRuntimeErrorCode = {
8025
8762
  // Web Socket Client
@@ -8178,5 +8915,5 @@ const createWebSocketClient = (options) => {
8178
8915
  * Generated bundle index. Do not edit.
8179
8916
  */
8180
8917
 
8181
- export { BasicAuthProvider, BearerAuthFeatureType, BooleanArrayQueryField, CLEAR_QUERY_ARGS, CustomHeaderAuthProvider, DateArrayQueryField, DateQueryField, EntityStore, INFINITY_QUERY_TOKEN, InfinityQuery, InfinityQueryDirective, InfinityQueryTriggerDirective, LegacyQuery, LegacyQueryCreator, NumberArrayQueryField, ParseHttpErrorCodeToMessageDePipe, ParseHttpErrorCodeToMessageEnPipe, ParseHttpErrorCodeToTitleDePipe, ParseHttpErrorCodeToTitleEnPipe, QUERY_CLIENT_DEVTOOLS_TOKEN, QueryDevtoolsComponent, QueryDirective, QueryError, 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, applyQueryFeatures, authExtractTokensResponseMissingAccessToken, authExtractTokensResponseMissingRefreshToken, authExtractTokensResponseNotObject, authProviderFeatureUsedMultipleTimes, buildErrorMessage, buildQueryCacheKey, buildQueryString, buildRoute, buildTimestampFromSeconds, castQueryCreatorTypes, circularQueryDependency, circularQueryDependencyChecker, computeQueryAuthHeader, computeQueryBody, computeQueryHeaders, computeQueryMethod, computeQueryQueryParams, consumeSuppressQueryStackDevtools, contentfulGqlLikePaginationAdapter, createBaseQuery, createBaseQueryCreator, createBearerAuthProvider, createDeleteQuery, createExecuteFn, createGetQuery, createGqlExecuteFn, createGqlMutationViaGet, createGqlMutationViaPost, createGqlQuery, createGqlQueryCreator, createGqlQueryViaGet, createGqlQueryViaPost, createHttpRequest, createInfinityQueryConfig, createLegacyQueryCreator, createPagedQueryStack, createPatchQuery, createPersistentAuthFeature, createPostQuery, createPutQuery, createQuery, createQueryClient, createQueryCollection, createQueryCollectionSignal, createQueryCollectionSubject, createQueryContext, createQueryCreator, createQueryErrorResponse, createQueryFeature, createQueryObject, createQueryRepository, createQuerySnapshotFn, createQueryStack, createSecureDeleteQuery, createSecureExecuteFactory, createSecureExecuteFn, createSecureGetQuery, createSecureGqlExecuteFn, createSecureGqlMutationViaGet, createSecureGqlMutationViaPost, createSecureGqlQuery, createSecureGqlQueryCreator, createSecureGqlQueryViaGet, createSecureGqlQueryViaPost, createSecurePatchQuery, createSecurePostQuery, createSecurePutQuery, createSecureQuery, createSecureQueryCreator, createTrackingFeature, createWebSocketClient, decryptBearer, deepFreeze, def, detectContentTypeHeader, dynLikePaginationAdapter, effectComputed, ethletePaginationAdapter, executeUntilSettled, extractExpiresInSeconds, extractFormViolations, extractQuery, fakePaginationAdapter, filterFailure, filterNull, filterQueryStates, filterSuccess, forEachHeader, getDefaultHeaders, getQueryClientName, getQueryFeatureUsage, getResponseUrl, ggLikePaginationAdapter, gql, gqlDataPropertyMissingInResponse, hasHeader, ignoreAutoRefresh, injectInfinityQueryResponseDelay, injectQueryContext, insertFrom, invalidBaseRouteError, invalidBodyError, invalidRouteError, invalidStateInsideSecureExecuteFactory, isAutoRefresh, isBasicAuthProvider, isBearerAuthProvider, isClassValidatorError, isCreateGqlQueryOptions, isCustomHeaderAuthProvider, isEmptyString, isGqlQueryConfig, isLegacyQuery, isNaN$1 as isNaN, isQuery, isQueryCollection, isQueryDevtoolsEnabled, isQueryErrorResponse, isQueryStateCancelled, isQueryStateFailure, isQueryStateLoading, isQueryStatePrepared, isQueryStateSuccess, isRequestError, isSymfonyError, isSymfonyFormViolationListError, isSymfonyListError, isSymfonyPagerfantaOutOfRangeError, mapToPaginated, mapViolationsToFormErrors, maybeExecute, mergeHeaders, nestedEffect, pagedQueryStackNextPageCalledWithoutPreviousPage, pagedQueryStackPageBiggerThanTotalPages, pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage, paginatedEntityValueUpdater, parseAllXhrResponseHeaders, parseHttpErrorCodeToMessageDe, parseHttpErrorCodeToMessageEn, parseHttpErrorCodeToTitleDe, parseHttpErrorCodeToTitleEn, pathParamsMissingInRouteFunctionError, provideInfinityQueryResponseDelay, provideQueryClientForDevtools, provideQueryDevtools, queryArrayComputed, queryComputed, queryComputedTillTruthy, queryDevtoolsEntries, queryExecute, queryFeatureUsedMultipleTimes, querySequence, querySequenceAlreadyRunning, queryStackTotalQueriesAndExpectedQueriesMismatch, queryStackWithArgsUsed, queryStackWithResponseUpdateUsed, queryStateAlreadyHasKeyError, queryStateCannotTransformError, queryStateDoesNotContainKeyError, queryStateErrorSignal, queryStateLoadingSignal, queryStateResponseSignal, queryStateSignal, registerQueryDevtoolsEntry, removeFrom, request, resetExecuteState, resetPageOnError, serializeBody, setupQueryDependencies, setupQueryExecuteState, setupQueryState, shouldAutoExecuteGqlQuery, shouldAutoExecuteQuery, shouldCacheQuery, shouldRetryRequest, silenceMissingWithArgsFeatureErrorUsedButWithArgsPresent, skipPaginationPageParamCalculator, splitQueryConfig, stringifyQueryRoute, suppressNextQueryStackDevtools, switchQueryCollectionState, switchQueryState, takeUntilResponse, toQuerySignal, toQuerySubject, tokensNotAvailableInsideAuthAndExec, transformArrayResponse, transformExecStateToQueryState, transformGql, transformMethod, transformPaginatedResponse, transformToBoolean, transformToBooleanArray, transformToDate, transformToDateArray, transformToNumber, transformToNumberArray, transformToSort, transformToSortQueryParam, transformToString, transformToStringArray, uncacheableRequestHasAllowCacheParam, uncacheableRequestHasCacheKeyParam, v2BuildQueryCacheKey, v2ExtractExpiresInSeconds, v2ShouldCacheQuery, v2ShouldRetryRequest, validateWithQuery, validateWithV2Query, withArgs, withArgsQueryFeatureMissingButRouteIsFunction, withAuthenticationQuery, withAutoRefresh, withAutoRefreshUsedInManualQuery, withAutoRefreshUsedOnUnsupportedHttpMethod, withErrorHandling, withInactivityLogout, withLogging, withPersistentAuth, withPolling, withPollingUsedOnUnsupportedHttpMethod, withRefreshQuery, withResponseUpdate, withSuccessHandling, withTokenExpirationWarning, withTokenRevocation, withTracking, wrapAsObservableSignal };
8918
+ export { BasicAuthProvider, BearerAuthFeatureType, BooleanArrayQueryField, CLEAR_QUERY_ARGS, CustomHeaderAuthProvider, DEFAULT_KEEP_UNUSED_FOR, DateArrayQueryField, DateQueryField, 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, QueryDevtoolsComponent, QueryDirective, QueryError, 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, applyQueryFeatures, authExtractTokensResponseMissingAccessToken, authExtractTokensResponseMissingRefreshToken, authExtractTokensResponseNotObject, authProviderFeatureUsedMultipleTimes, booleanArrayQueryField, buildErrorMessage, buildQueryCacheKey, buildQueryString, buildRoute, buildTimestampFromSeconds, castQueryCreatorTypes, circularQueryDependency, circularQueryDependencyChecker, computeQueryAuthHeader, computeQueryBody, computeQueryHeaders, computeQueryMethod, computeQueryQueryParams, consumeSuppressQueryStackDevtools, contentfulGqlLikePaginationAdapter, createBaseQuery, createBaseQueryCreator, createBearerAuthProvider, createDeleteQuery, createExecuteFn, createGetQuery, createGqlExecuteFn, createGqlMutationViaGet, createGqlMutationViaPost, createGqlQuery, createGqlQueryCreator, createGqlQueryViaGet, createGqlQueryViaPost, createHttpRequest, createInfinityQueryConfig, createLegacyQueryCreator, createPagedQueryStack, createPatchQuery, createPersistentAuthFeature, createPostQuery, createPutQuery, createQuery, createQueryClient, createQueryCollection, createQueryCollectionSignal, createQueryCollectionSubject, createQueryContext, createQueryCreator, createQueryErrorResponse, createQueryFeature, createQueryForm, createQueryObject, createQueryRepository, createQuerySnapshotFn, createQueryStack, createSecureDeleteQuery, createSecureExecuteFactory, createSecureExecuteFn, createSecureGetQuery, createSecureGqlExecuteFn, createSecureGqlMutationViaGet, createSecureGqlMutationViaPost, createSecureGqlQuery, createSecureGqlQueryCreator, createSecureGqlQueryViaGet, createSecureGqlQueryViaPost, createSecurePatchQuery, createSecurePostQuery, createSecurePutQuery, createSecureQuery, createSecureQueryCreator, createTrackingFeature, createWebSocketClient, dateArrayQueryField, dateQueryField, decryptBearer, deepFreeze, def, detectContentTypeHeader, dynLikePaginationAdapter, effectComputed, emptyQueryArgs, ethletePaginationAdapter, executeUntilSettled, extractExpiresInSeconds, extractFormViolations, extractQuery, fakePaginationAdapter, filterFailure, filterNull, filterQueryStates, filterSuccess, forEachHeader, getDefaultHeaders, getQueryClientName, getQueryFeatureUsage, getResponseUrl, ggLikePaginationAdapter, gql, gqlDataPropertyMissingInResponse, hasHeader, ignoreAutoRefresh, injectInfinityQueryResponseDelay, injectQueryContext, insertFrom, invalidBaseRouteError, invalidBodyError, invalidRouteError, invalidStateInsideSecureExecuteFactory, isAutoRefresh, isBasicAuthProvider, isBearerAuthProvider, isClassValidatorError, isCreateGqlQueryOptions, isCustomHeaderAuthProvider, isEmptyString, isGqlQueryConfig, isLegacyQuery, isNaN$1 as isNaN, isPageOutOfRangeError, isQuery, isQueryCollection, isQueryDevtoolsEnabled, isQueryErrorResponse, isQueryStateCancelled, isQueryStateFailure, isQueryStateLoading, isQueryStatePrepared, isQueryStateSuccess, isRequestError, isSymfonyError, isSymfonyFormViolationListError, isSymfonyListError, isSymfonyPagerfantaOutOfRangeError, legacyPrepareWithoutInjectionContext, mapToPaginated, mapViolationsToFormErrors, maybeExecute, mergeHeaders, nestedEffect, numberArrayQueryField, pagedQueryStackNextPageCalledWithoutPreviousPage, pagedQueryStackPageBiggerThanTotalPages, pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage, paginatedEntityValueUpdater, parseAllXhrResponseHeaders, parseHttpErrorCodeToMessageDe, parseHttpErrorCodeToMessageEn, parseHttpErrorCodeToTitleDe, parseHttpErrorCodeToTitleEn, pathParamsMissingInRouteFunctionError, provideInfinityQueryResponseDelay, provideQueryClientForDevtools, provideQueryDevtools, queryArrayComputed, queryComputed, queryComputedTillTruthy, queryDevtoolsEntries, queryErrorMessage, queryErrorMessages, queryExecute, queryFeatureUsedMultipleTimes, queryField, querySequence, querySequenceAlreadyRunning, queryStackTotalQueriesAndExpectedQueriesMismatch, queryStackWithArgsUsed, queryStackWithResponseUpdateUsed, queryStateAlreadyHasKeyError, queryStateCannotTransformError, queryStateDoesNotContainKeyError, queryStateErrorSignal, queryStateLoadingSignal, queryStateResponseSignal, queryStateSignal, registerQueryDevtoolsEntry, removeFrom, request, resetExecuteState, resetPageOnError, searchQueryField, serializeBody, setupQueryDependencies, setupQueryExecuteState, setupQueryState, shouldAutoExecuteGqlQuery, shouldAutoExecuteQuery, shouldCacheQuery, shouldRetryRequest, silenceMissingWithArgsFeatureErrorUsedButWithArgsPresent, skipPaginationPageParamCalculator, sortQueryField, splitQueryConfig, stringArrayQueryField, stringifyQueryRoute, suppressNextQueryStackDevtools, switchQueryCollectionState, switchQueryState, takeUntilResponse, toQuerySignal, toQuerySubject, tokensNotAvailableInsideAuthAndExec, transformArrayResponse, transformExecStateToQueryState, transformGql, transformMethod, transformPaginatedResponse, transformToBoolean, transformToBooleanArray, transformToDate, transformToDateArray, transformToNumber, transformToNumberArray, transformToSort, transformToSortQueryParam, transformToString, transformToStringArray, uncacheableRequestHasAllowCacheParam, uncacheableRequestHasCacheKeyParam, v2BuildQueryCacheKey, v2ExtractExpiresInSeconds, v2ShouldCacheQuery, v2ShouldRetryRequest, validateWithQuery, validateWithV2Query, withArgs, withArgsQueryFeatureMissingButRouteIsFunction, withAuthenticationQuery, withAutoRefresh, withAutoRefreshUsedInManualQuery, withAutoRefreshUsedOnUnsupportedHttpMethod, withErrorHandling, withInactivityLogout, withLogging, withPageResetOnError, withPersistentAuth, withPolling, withPollingUsedOnUnsupportedHttpMethod, withRefreshQuery, withResponseUpdate, withSuccessHandling, withTokenExpirationWarning, withTokenRevocation, withTracking, wrapAsObservableSignal };
8182
8919
  //# sourceMappingURL=ethlete-query.mjs.map