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