ngx-signal-query 1.0.0-dev.1

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.
@@ -0,0 +1,1003 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, Injectable, InjectionToken, inject, makeEnvironmentProviders, assertInInjectionContext, Injector, runInInjectionContext, computed, untracked, effect, DestroyRef } from '@angular/core';
3
+ import { defer, from, take, retry, timer, throwIfEmpty } from 'rxjs';
4
+
5
+ class Cache {
6
+ #entries = signal([], ...(ngDevMode ? [{ debugName: "#entries" }] : []));
7
+ // Reactive snapshot of all entries; updated on add/remove/clear so that
8
+ // findAll() (and its computed consumers like isFetching) react to the
9
+ // collection. Protected: only subclasses read it, never external code.
10
+ entries = this.#entries.asReadonly();
11
+ #entriesMap = new Map();
12
+ getAll() {
13
+ return Array.from(this.#entriesMap.values());
14
+ }
15
+ clear() {
16
+ this.#entriesMap.clear();
17
+ this.#sync();
18
+ }
19
+ addEntry(key, entry) {
20
+ this.#entriesMap.set(key, entry);
21
+ this.#sync();
22
+ return entry;
23
+ }
24
+ getEntry(key) {
25
+ return this.#entriesMap.get(key);
26
+ }
27
+ removeEntry(key) {
28
+ const removed = this.#entriesMap.delete(key);
29
+ if (removed)
30
+ this.#sync();
31
+ return removed;
32
+ }
33
+ #sync() {
34
+ this.#entries.set(Array.from(this.#entriesMap.values()));
35
+ }
36
+ }
37
+
38
+ // Resolves an updater: calls it with the previous value if it's a function,
39
+ // otherwise uses it as the value directly.
40
+ function functionalUpdate(updater, input) {
41
+ return typeof updater === 'function'
42
+ ? updater(input)
43
+ : updater;
44
+ }
45
+ // True when `filter` is a (deep) prefix of `key`, e.g. ['app'] matches
46
+ // ['app', 1]. Used to invalidate/find groups of queries by partial key.
47
+ function partialMatchKey(key, filter) {
48
+ return partialDeepEqual(key, filter);
49
+ }
50
+ function partialDeepEqual(a, b) {
51
+ if (a === b)
52
+ return true;
53
+ if (typeof a !== typeof b)
54
+ return false;
55
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
56
+ return Object.keys(b).every((key) => partialDeepEqual(a[key], b[key]));
57
+ }
58
+ return false;
59
+ }
60
+ function hashKey(key) {
61
+ return JSON.stringify(key, (_, value) => isPlainObject(value)
62
+ ? Object.keys(value)
63
+ .sort()
64
+ .reduce((result, k) => {
65
+ result[k] = value[k];
66
+ return result;
67
+ }, {})
68
+ : value);
69
+ }
70
+ // Copied from: https://github.com/jonschlinkert/is-plain-object
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ function isPlainObject(o) {
73
+ if (!hasObjectPrototype(o)) {
74
+ return false;
75
+ }
76
+ // If has no constructor
77
+ const ctor = o.constructor;
78
+ if (ctor === undefined) {
79
+ return true;
80
+ }
81
+ // If has modified prototype
82
+ const prot = ctor.prototype;
83
+ if (!hasObjectPrototype(prot)) {
84
+ return false;
85
+ }
86
+ // If constructor does not have an Object-specific method
87
+ if (!Object.prototype.hasOwnProperty.call(prot, 'isPrototypeOf')) {
88
+ return false;
89
+ }
90
+ // Handles Objects created by Object.create(<arbitrary prototype>)
91
+ if (Object.getPrototypeOf(o) !== Object.prototype) {
92
+ return false;
93
+ }
94
+ // Most likely a plain Object
95
+ return true;
96
+ }
97
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
+ function hasObjectPrototype(o) {
99
+ return Object.prototype.toString.call(o) === '[object Object]';
100
+ }
101
+
102
+ // Exponential backoff capped at 30s, matching TanStack's default.
103
+ function defaultRetryDelay(failureCount) {
104
+ return Math.min(1000 * 2 ** failureCount, 30000);
105
+ }
106
+ function shouldRetry(retry, failureCount, error) {
107
+ if (typeof retry === 'function')
108
+ return retry(failureCount, error);
109
+ if (typeof retry === 'number')
110
+ return failureCount < retry;
111
+ return retry;
112
+ }
113
+ function resolveRetryDelay(retryDelay, failureCount, error) {
114
+ return typeof retryDelay === 'function'
115
+ ? retryDelay(failureCount, error)
116
+ : retryDelay;
117
+ }
118
+
119
+ const DEFAULT_GC_TIME = 5 * 60 * 1000;
120
+ class Query {
121
+ key;
122
+ queryHash;
123
+ #state = signal({
124
+ data: undefined,
125
+ status: 'pending',
126
+ error: null,
127
+ isFetching: false,
128
+ isInvalidated: false,
129
+ failureCount: 0,
130
+ failureReason: null,
131
+ updatedAt: 0,
132
+ }, ...(ngDevMode ? [{ debugName: "#state" }] : []));
133
+ // `state` must follow `#state`: a public field can't precede the private
134
+ // field it reads during initialization (field init order).
135
+ // eslint-disable-next-line @typescript-eslint/member-ordering
136
+ state = this.#state.asReadonly();
137
+ #subscription = null;
138
+ #observers = 0;
139
+ #gcTime = DEFAULT_GC_TIME;
140
+ #gcTimer = null;
141
+ #cache;
142
+ constructor(key, queryHash, cache) {
143
+ this.key = key;
144
+ this.queryHash = queryHash;
145
+ this.#cache = cache;
146
+ }
147
+ get observerCount() {
148
+ return this.#observers;
149
+ }
150
+ setGcTime(ms) {
151
+ this.#gcTime = ms;
152
+ }
153
+ addObserver() {
154
+ this.#observers++;
155
+ this.#clearGcTimer();
156
+ }
157
+ removeObserver() {
158
+ if (this.#observers === 0)
159
+ return;
160
+ this.#observers--;
161
+ // No observers left: cancel any in-flight fetch (nobody is waiting for it)
162
+ // and schedule gc to dispose the query if no observer returns.
163
+ if (this.#observers === 0) {
164
+ this.cancel();
165
+ this.#scheduleGc();
166
+ }
167
+ }
168
+ fetch(queryFn, retry$1 = 0, retryDelay = defaultRetryDelay, cancelRefetch = false) {
169
+ if (this.#subscription && !this.#subscription.closed) {
170
+ // Already in-flight: dedupe unless the caller explicitly wants a fresh
171
+ // fetch (e.g. invalidate/refetch) — then cancel the old one and restart.
172
+ if (!cancelRefetch)
173
+ return;
174
+ this.cancel();
175
+ }
176
+ this.#state.update((state) => ({
177
+ ...state,
178
+ isFetching: true,
179
+ failureCount: 0,
180
+ failureReason: null,
181
+ }));
182
+ // defer + from: normalize Observable/Promise and re-invoke queryFn on each
183
+ // retry (a Promise is one-shot, so retry must produce a fresh one).
184
+ this.#subscription = defer(() => from(queryFn()))
185
+ .pipe(take(1), retry({
186
+ delay: (error, retryCount) => {
187
+ // retryCount (1-based) is the number of failures so far; the retry
188
+ // predicate/delay take a 0-based attempt index (0 = first retry).
189
+ this.#state.update((state) => ({
190
+ ...state,
191
+ failureCount: retryCount,
192
+ failureReason: error,
193
+ }));
194
+ const attemptIndex = retryCount - 1;
195
+ if (!shouldRetry(retry$1, attemptIndex, error))
196
+ throw error;
197
+ return timer(resolveRetryDelay(retryDelay, attemptIndex, error));
198
+ },
199
+ }), throwIfEmpty(() => new Error('Query function completed without emitting a value')))
200
+ .subscribe({
201
+ next: (data) => this.#state.set({
202
+ data,
203
+ status: 'success',
204
+ error: null,
205
+ isFetching: false,
206
+ isInvalidated: false,
207
+ failureCount: 0,
208
+ failureReason: null,
209
+ updatedAt: Date.now(),
210
+ }),
211
+ error: (err) => this.#state.update((state) => ({
212
+ ...state,
213
+ status: 'error',
214
+ error: err,
215
+ isFetching: false,
216
+ })),
217
+ });
218
+ }
219
+ setData(data, updatedAt = Date.now()) {
220
+ this.#state.update((state) => ({
221
+ ...state,
222
+ data,
223
+ status: 'success',
224
+ error: null,
225
+ isInvalidated: false,
226
+ failureCount: 0,
227
+ failureReason: null,
228
+ updatedAt,
229
+ }));
230
+ // Keep an orphaned query (no observers) alive for another gcTime so a
231
+ // setQueryData write isn't collected before anyone subscribes.
232
+ if (this.#observers === 0)
233
+ this.#scheduleGc();
234
+ }
235
+ invalidate() {
236
+ this.#state.update((state) => ({ ...state, isInvalidated: true }));
237
+ }
238
+ shouldFetch(staleTime) {
239
+ const state = this.state();
240
+ return (state.status !== 'success' ||
241
+ state.isInvalidated ||
242
+ this.#isStale(staleTime));
243
+ }
244
+ cancel() {
245
+ this.#subscription?.unsubscribe();
246
+ this.#subscription = null;
247
+ // The fetch is no longer running; clear the in-flight flag so isFetching
248
+ // doesn't stay stuck true after a cancellation.
249
+ if (this.state().isFetching) {
250
+ this.#state.update((state) => ({ ...state, isFetching: false }));
251
+ }
252
+ }
253
+ destroy() {
254
+ this.cancel();
255
+ this.#clearGcTimer();
256
+ }
257
+ #isStale(staleTime) {
258
+ return Date.now() - this.state().updatedAt > staleTime;
259
+ }
260
+ #scheduleGc() {
261
+ this.#clearGcTimer();
262
+ this.#gcTimer = setTimeout(() => {
263
+ this.#gcTimer = null;
264
+ this.#cache.remove(this);
265
+ }, this.#gcTime);
266
+ }
267
+ #clearGcTimer() {
268
+ if (this.#gcTimer === null)
269
+ return;
270
+ clearTimeout(this.#gcTimer);
271
+ this.#gcTimer = null;
272
+ }
273
+ }
274
+
275
+ class QueryCache extends Cache {
276
+ getOrCreate(key) {
277
+ const queryHash = hashKey(key);
278
+ const exist = this.getEntry(queryHash);
279
+ if (exist)
280
+ return exist;
281
+ return this.addEntry(queryHash, new Query(key, queryHash, this));
282
+ }
283
+ get(key) {
284
+ return this.getEntry(hashKey(key));
285
+ }
286
+ findAll(filters = {}) {
287
+ const { queryKey, exact } = filters;
288
+ // Reads the reactive `entries()` so findAll() works inside computed().
289
+ const all = this.entries();
290
+ if (!queryKey)
291
+ return all;
292
+ if (exact) {
293
+ const queryHash = hashKey(queryKey);
294
+ return all.filter((query) => query.queryHash === queryHash);
295
+ }
296
+ return all.filter((query) => partialMatchKey(query.key, queryKey));
297
+ }
298
+ remove(query) {
299
+ // Guard on identity, not just the hash: a stale instance (already removed
300
+ // and recreated under the same key) must not evict the current query.
301
+ if (this.getEntry(query.queryHash) === query) {
302
+ this.removeEntry(query.queryHash);
303
+ }
304
+ }
305
+ clear() {
306
+ this.getAll().forEach((query) => query.destroy());
307
+ super.clear();
308
+ }
309
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: QueryCache, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
310
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: QueryCache });
311
+ }
312
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: QueryCache, decorators: [{
313
+ type: Injectable
314
+ }] });
315
+
316
+ function getInitialState() {
317
+ return {
318
+ status: 'idle',
319
+ data: undefined,
320
+ error: null,
321
+ variables: undefined,
322
+ context: undefined,
323
+ failureCount: 0,
324
+ failureReason: null,
325
+ submittedAt: 0,
326
+ };
327
+ }
328
+ class Mutation {
329
+ mutationId;
330
+ #state = signal(getInitialState(), ...(ngDevMode ? [{ debugName: "#state" }] : []));
331
+ // `state` must follow `#state`: a public field can't precede the private
332
+ // field it reads during initialization (field init order).
333
+ // eslint-disable-next-line @typescript-eslint/member-ordering
334
+ state = this.#state.asReadonly();
335
+ #subscription = null;
336
+ #options;
337
+ constructor(mutationId, options) {
338
+ this.mutationId = mutationId;
339
+ this.#options = options;
340
+ }
341
+ execute(variables) {
342
+ const context = this.#options.onMutate?.(variables);
343
+ // Mutations default to no retry (not idempotent — a retried POST could
344
+ // create a duplicate); opt in explicitly via options.retry.
345
+ const retry$1 = this.#options.retry ?? 0;
346
+ const retryDelay = this.#options.retryDelay ?? defaultRetryDelay;
347
+ this.#state.set({
348
+ status: 'pending',
349
+ data: undefined,
350
+ error: null,
351
+ variables,
352
+ context,
353
+ failureCount: 0,
354
+ failureReason: null,
355
+ submittedAt: Date.now(),
356
+ });
357
+ // defer + from: normalize Observable/Promise and re-invoke mutationFn on
358
+ // each retry (a Promise is one-shot, so retry must produce a fresh one).
359
+ this.#subscription = defer(() => from(this.#options.mutationFn(variables)))
360
+ .pipe(take(1), retry({
361
+ delay: (error, retryCount) => {
362
+ this.#state.update((state) => ({
363
+ ...state,
364
+ failureCount: retryCount,
365
+ failureReason: error,
366
+ }));
367
+ const attemptIndex = retryCount - 1;
368
+ if (!shouldRetry(retry$1, attemptIndex, error))
369
+ throw error;
370
+ return timer(resolveRetryDelay(retryDelay, attemptIndex, error));
371
+ },
372
+ }), throwIfEmpty(() => new Error('Mutation function completed without emitting a value')))
373
+ .subscribe({
374
+ next: (data) => {
375
+ this.#state.update((state) => ({ ...state, status: 'success', data }));
376
+ this.#options.onSuccess?.(data, variables, context);
377
+ this.#options.onSettled?.(data, null, variables, context);
378
+ },
379
+ error: (error) => {
380
+ this.#state.update((state) => ({ ...state, status: 'error', error }));
381
+ this.#options.onError?.(error, variables, context);
382
+ this.#options.onSettled?.(undefined, error, variables, context);
383
+ },
384
+ });
385
+ }
386
+ reset() {
387
+ this.cancel();
388
+ this.#state.set(getInitialState());
389
+ }
390
+ cancel() {
391
+ this.#subscription?.unsubscribe();
392
+ this.#subscription = null;
393
+ }
394
+ }
395
+
396
+ class MutationCache extends Cache {
397
+ #lastId = 0;
398
+ build(options) {
399
+ const mutation = new Mutation(++this.#lastId, options);
400
+ this.addEntry(String(mutation.mutationId), mutation);
401
+ return mutation;
402
+ }
403
+ findAll(filters = {}) {
404
+ // Reads the reactive `entries()` so findAll() works inside computed().
405
+ const all = this.entries();
406
+ if (!filters.status)
407
+ return all;
408
+ return all.filter((mutation) => mutation.state().status === filters.status);
409
+ }
410
+ remove(mutation) {
411
+ this.removeEntry(String(mutation.mutationId));
412
+ }
413
+ clear() {
414
+ this.getAll().forEach((mutation) => mutation.cancel());
415
+ super.clear();
416
+ }
417
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: MutationCache, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
418
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: MutationCache });
419
+ }
420
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: MutationCache, decorators: [{
421
+ type: Injectable
422
+ }] });
423
+
424
+ const QUERY_CLIENT_CONFIG = new InjectionToken('QUERY_CLIENT_CONFIG');
425
+
426
+ /**
427
+ * Central registry and cache for queries and mutations.
428
+ *
429
+ * Provided by {@link provideQueryClient} and retrieved with
430
+ * {@link injectQueryClient}. Offers imperative cache access — reading and
431
+ * writing data, and invalidating, cancelling, or removing queries — that
432
+ * complements the reactive {@link injectQuery} / {@link injectMutation} APIs.
433
+ */
434
+ class QueryClient {
435
+ #cache = inject(QueryCache);
436
+ #mutationCache = inject(MutationCache);
437
+ #config = inject(QUERY_CLIENT_CONFIG, { optional: true }) ?? {};
438
+ /** Returns the underlying query cache. Advanced/internal use. */
439
+ getQueryCache() {
440
+ return this.#cache;
441
+ }
442
+ /** Returns the underlying mutation cache. Advanced/internal use. */
443
+ getMutationCache() {
444
+ return this.#mutationCache;
445
+ }
446
+ /**
447
+ * Merges per-query options with the configured defaults, filling in
448
+ * `staleTime`, `gcTime`, `retry`, and `retryDelay`. Used internally by
449
+ * {@link injectQuery}.
450
+ */
451
+ defaultQueryOptions(options) {
452
+ const defaults = this.#config.defaultOptions?.queries;
453
+ return {
454
+ ...options,
455
+ staleTime: options.staleTime ?? defaults?.staleTime ?? 0,
456
+ gcTime: options.gcTime ?? defaults?.gcTime,
457
+ retry: options.retry ?? defaults?.retry ?? 3,
458
+ retryDelay: options.retryDelay ?? defaults?.retryDelay ?? defaultRetryDelay,
459
+ };
460
+ }
461
+ /**
462
+ * Imperatively fetches and caches a query, unless fresh data already exists
463
+ * (governed by `staleTime`). Prefer {@link injectQuery} in components; use
464
+ * this for prefetching outside the reactive flow.
465
+ *
466
+ * @param key - The query key to fetch and cache under.
467
+ * @param queryFn - Function returning the data as an `Observable` or `Promise`.
468
+ * @param options - Fetch tuning (`staleTime`, `retry`, `retryDelay`,
469
+ * `cancelRefetch`).
470
+ */
471
+ fetchQuery(key, queryFn, options = {}) {
472
+ const defaults = this.#config.defaultOptions?.queries;
473
+ const staleTime = options.staleTime ?? defaults?.staleTime ?? 0;
474
+ const retry = options.retry ?? defaults?.retry ?? 3;
475
+ const retryDelay = options.retryDelay ?? defaults?.retryDelay ?? defaultRetryDelay;
476
+ const query = this.#cache.getOrCreate(key);
477
+ if (query.shouldFetch(staleTime)) {
478
+ query.fetch(queryFn, retry, retryDelay, options.cancelRefetch);
479
+ }
480
+ }
481
+ /**
482
+ * Reads the current cached data for a query key, or `undefined` if the query
483
+ * is not cached yet.
484
+ *
485
+ * @param key - The query key to read.
486
+ * @returns The cached data, or `undefined`.
487
+ */
488
+ getQueryData(key) {
489
+ return this.#cache.get(key)?.state().data;
490
+ }
491
+ /**
492
+ * Writes data into the cache for a query key, creating the entry if needed.
493
+ * The `updater` may be a value or a function of the previous data; returning
494
+ * `undefined` from the function is a no-op. Useful for optimistic updates.
495
+ *
496
+ * @param key - The query key to write.
497
+ * @param updater - The new data, or a function `(prev) => next`.
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * client.setQueryData<Todo[]>(['todos'], (prev = []) => [...prev, newTodo])
502
+ * ```
503
+ */
504
+ setQueryData(key, updater) {
505
+ const query = this.#cache.getOrCreate(key);
506
+ const data = functionalUpdate(updater, query.state().data);
507
+ // Matches TanStack: an updater returning undefined is a no-op.
508
+ if (data === undefined)
509
+ return;
510
+ query.setData(data);
511
+ }
512
+ /**
513
+ * Marks matching queries as stale and triggers a refetch for those that are
514
+ * actively observed. Commonly called after a mutation succeeds.
515
+ *
516
+ * @param filters - Which queries to invalidate; omit to invalidate all.
517
+ *
518
+ * @example
519
+ * ```ts
520
+ * client.invalidateQueries({ queryKey: ['todos'] })
521
+ * ```
522
+ */
523
+ invalidateQueries(filters) {
524
+ this.#cache.findAll(filters).forEach((query) => query.invalidate());
525
+ }
526
+ /**
527
+ * Cancels any in-flight fetches for matching queries.
528
+ *
529
+ * @param filters - Which queries to cancel; omit to cancel all.
530
+ */
531
+ cancelQueries(filters) {
532
+ this.#cache.findAll(filters).forEach((query) => query.cancel());
533
+ }
534
+ /**
535
+ * Removes matching queries from the cache entirely, discarding their data.
536
+ *
537
+ * @param filters - Which queries to remove; omit to remove all.
538
+ */
539
+ removeQueries(filters) {
540
+ this.#cache.findAll(filters).forEach((query) => {
541
+ query.destroy();
542
+ this.#cache.remove(query);
543
+ });
544
+ }
545
+ /**
546
+ * Returns the number of matching queries currently fetching. For a reactive
547
+ * count, prefer {@link injectIsFetching}.
548
+ *
549
+ * @param filters - Which queries to count; omit to count all.
550
+ */
551
+ isFetching(filters) {
552
+ return this.#cache
553
+ .findAll(filters)
554
+ .filter((query) => query.state().isFetching).length;
555
+ }
556
+ /**
557
+ * Returns the number of mutations currently pending. For a reactive count,
558
+ * prefer {@link injectIsMutating}.
559
+ */
560
+ isMutating() {
561
+ return this.#mutationCache.findAll({ status: 'pending' }).length;
562
+ }
563
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: QueryClient, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
564
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: QueryClient });
565
+ }
566
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.21", ngImport: i0, type: QueryClient, decorators: [{
567
+ type: Injectable
568
+ }] });
569
+
570
+ /** Discriminator identifying each kind of {@link QueryClientFeature}. */
571
+ var QueryClientFeatureKind;
572
+ (function (QueryClientFeatureKind) {
573
+ QueryClientFeatureKind[QueryClientFeatureKind["DefaultOptions"] = 0] = "DefaultOptions";
574
+ })(QueryClientFeatureKind || (QueryClientFeatureKind = {}));
575
+ function queryClientFeature(kind, providers) {
576
+ return { ɵkind: kind, ɵproviders: providers };
577
+ }
578
+
579
+ /**
580
+ * Registers the {@link QueryClient} and its caches for the application.
581
+ *
582
+ * Call once at the app root (in `ApplicationConfig.providers` or a root
583
+ * `bootstrapApplication`). Pass features such as {@link withDefaultOptions} to
584
+ * configure defaults. Registering the same feature twice throws.
585
+ *
586
+ * @param features - Optional {@link QueryClientFeature}s (e.g.
587
+ * {@link withDefaultOptions}).
588
+ * @returns Environment providers to add to the app's provider list.
589
+ *
590
+ * @example
591
+ * ```ts
592
+ * export const appConfig: ApplicationConfig = {
593
+ * providers: [
594
+ * provideHttpClient(),
595
+ * provideQueryClient(
596
+ * withDefaultOptions({ queries: { staleTime: 30_000 } }),
597
+ * ),
598
+ * ],
599
+ * }
600
+ * ```
601
+ */
602
+ function provideQueryClient(...features) {
603
+ const seenKinds = new Set();
604
+ for (const feature of features) {
605
+ if (seenKinds.has(feature.ɵkind)) {
606
+ throw new Error(`provideQueryClient: feature "${QueryClientFeatureKind[feature.ɵkind]}" is registered more than once`);
607
+ }
608
+ seenKinds.add(feature.ɵkind);
609
+ }
610
+ return makeEnvironmentProviders([
611
+ QueryCache,
612
+ MutationCache,
613
+ QueryClient,
614
+ ...features.flatMap((feature) => feature.ɵproviders),
615
+ ]);
616
+ }
617
+
618
+ /**
619
+ * Returns the application's {@link QueryClient} for imperative cache access.
620
+ *
621
+ * Use it to read or write cached data (`getQueryData`, `setQueryData`) and to
622
+ * invalidate, cancel, or remove queries — typically from mutation hooks or
623
+ * event handlers. Requires {@link provideQueryClient} to be registered.
624
+ *
625
+ * Must run in an injection context, or be given an explicit `injector`.
626
+ *
627
+ * @param options - Optional `injector` to use outside an injection context.
628
+ * @returns The shared {@link QueryClient} instance.
629
+ *
630
+ * @example
631
+ * ```ts
632
+ * const client = injectQueryClient()
633
+ * client.setQueryData<Todo[]>(['todos'], (prev = []) => [...prev, newTodo])
634
+ * client.invalidateQueries({ queryKey: ['todos'] })
635
+ * ```
636
+ */
637
+ function injectQueryClient(options) {
638
+ if (!options?.injector)
639
+ assertInInjectionContext(injectQueryClient);
640
+ return options?.injector?.get(QueryClient) ?? inject(QueryClient);
641
+ }
642
+
643
+ /**
644
+ * Runs a cached, reactive query and exposes its state as signals.
645
+ *
646
+ * The query is keyed by `queryKey`: calls with the same key share a single
647
+ * cache entry and in-flight request (deduplication). `optionsFn` is read in a
648
+ * reactive context, so when a value it depends on changes (e.g. a route or
649
+ * input signal in the key), the query automatically switches to the new key
650
+ * and fetches. The query is bound to the current injection context and cleans
651
+ * up its cache observer when that context is destroyed.
652
+ *
653
+ * Must run in an injection context, or be given an explicit `injector`.
654
+ *
655
+ * @typeParam TData - Type of the data resolved by `queryFn`.
656
+ * @typeParam TError - Type of the error thrown by `queryFn`.
657
+ * @param optionsFn - Factory returning the {@link QueryOptions}. Re-evaluated
658
+ * reactively, so reading signals inside it makes the query re-run on change.
659
+ * @param options - Optional `injector` to use outside an injection context.
660
+ * @returns A {@link QueryResult} of signals (`data`, `status`, `error`,
661
+ * `isLoading`, `isPending`, …) plus a `refetch()` that forces a fresh fetch.
662
+ *
663
+ * @example
664
+ * ```ts
665
+ * @Component({
666
+ * template: `
667
+ * @if (todo.isPending()) { <p>Loading…</p> }
668
+ * @if (todo.data(); as data) { <p>{{ data.title }}</p> }
669
+ * `,
670
+ * })
671
+ * class TodoComponent {
672
+ * readonly id = input.required<number>()
673
+ * private readonly http = inject(HttpClient)
674
+ *
675
+ * // Refetches automatically whenever `id()` changes.
676
+ * readonly todo = injectQuery(() => ({
677
+ * queryKey: ['todo', this.id()],
678
+ * queryFn: () => this.http.get<Todo>(`/api/todos/${this.id()}`),
679
+ * }))
680
+ * }
681
+ * ```
682
+ */
683
+ function injectQuery(optionsFn, options) {
684
+ if (!options?.injector)
685
+ assertInInjectionContext(injectQuery);
686
+ const injector = options?.injector ?? inject(Injector);
687
+ return runInInjectionContext(injector, () => {
688
+ const client = inject(QueryClient);
689
+ const cache = client.getQueryCache();
690
+ // Single source of truth for defaulted options; resolves config defaults
691
+ // (staleTime, gcTime) once instead of scattering the logic across effects.
692
+ const defaultedOptions = computed(() => client.defaultQueryOptions(optionsFn()), ...(ngDevMode ? [{ debugName: "defaultedOptions" }] : []));
693
+ // Seed a fresh query (status 'pending', no data yet) with initialData so
694
+ // it renders immediately as 'success'. updatedAt defaults to 0 → stale →
695
+ // background refetch.
696
+ const applyInitialData = (q) => {
697
+ const { initialData, initialDataUpdatedAt } = untracked(defaultedOptions);
698
+ if (initialData === undefined || q.state().status !== 'pending')
699
+ return;
700
+ const data = typeof initialData === 'function'
701
+ ? initialData()
702
+ : initialData;
703
+ const updatedAt = typeof initialDataUpdatedAt === 'function'
704
+ ? initialDataUpdatedAt()
705
+ : (initialDataUpdatedAt ?? 0);
706
+ q.setData(data, updatedAt);
707
+ };
708
+ // getOrCreate mutates the cache (a side effect), so it must not run inside
709
+ // a computed. Resolve the query in a signal: seed it synchronously and
710
+ // update it from an effect whenever the key changes.
711
+ const seed = cache.getOrCreate(untracked(defaultedOptions).queryKey);
712
+ applyInitialData(seed);
713
+ const query = signal(seed, ...(ngDevMode ? [{ debugName: "query" }] : []));
714
+ effect(() => {
715
+ const key = defaultedOptions().queryKey;
716
+ const q = untracked(() => cache.getOrCreate(key));
717
+ untracked(() => applyInitialData(q));
718
+ query.set(q);
719
+ });
720
+ // Memoized: only emits when the flag itself flips, so ordinary data
721
+ // updates don't wake the fetch effect (no refetch loop).
722
+ const isInvalidated = computed(() => query().state().isInvalidated, ...(ngDevMode ? [{ debugName: "isInvalidated" }] : []));
723
+ effect((cleanup) => {
724
+ const q = query();
725
+ const { gcTime } = untracked(defaultedOptions);
726
+ if (gcTime !== undefined) {
727
+ q.setGcTime(gcTime);
728
+ }
729
+ q.addObserver();
730
+ cleanup(() => q.removeObserver());
731
+ });
732
+ effect(() => {
733
+ const { queryKey, queryFn, staleTime, retry, retryDelay, enabled } = defaultedOptions();
734
+ // Track invalidation so invalidateQueries() re-triggers a refetch.
735
+ // When invalidated, cancel any in-flight fetch and start a fresh one
736
+ // (otherwise the stale in-flight result would clear isInvalidated).
737
+ const invalidated = isInvalidated();
738
+ if (enabled === false)
739
+ return;
740
+ untracked(() => client.fetchQuery(queryKey, queryFn, {
741
+ staleTime,
742
+ retry,
743
+ retryDelay,
744
+ cancelRefetch: invalidated,
745
+ }));
746
+ });
747
+ // Polling: refetch on an interval, independent of staleTime (staleTime: 0
748
+ // forces the fetch). The function form is reactive — reading state() makes
749
+ // the effect re-run when data changes, so returning false stops polling.
750
+ effect((onCleanup) => {
751
+ const { queryKey, queryFn, retry, retryDelay, refetchInterval, enabled } = defaultedOptions();
752
+ if (enabled === false)
753
+ return;
754
+ const interval = typeof refetchInterval === 'function'
755
+ ? refetchInterval({ state: query().state() })
756
+ : refetchInterval;
757
+ if (!interval)
758
+ return;
759
+ const id = setInterval(() => {
760
+ client.fetchQuery(queryKey, queryFn, {
761
+ staleTime: 0,
762
+ retry,
763
+ retryDelay,
764
+ });
765
+ }, interval);
766
+ onCleanup(() => clearInterval(id));
767
+ });
768
+ return {
769
+ data: computed(() => query().state().data),
770
+ status: computed(() => query().state().status),
771
+ error: computed(() => query().state().error),
772
+ isFetching: computed(() => query().state().isFetching),
773
+ // First fetch in-flight: fetching with no resolved data yet.
774
+ isLoading: computed(() => {
775
+ const state = query().state();
776
+ return state.isFetching && state.status === 'pending';
777
+ }),
778
+ isPending: computed(() => query().state().status === 'pending'),
779
+ isSuccess: computed(() => query().state().status === 'success'),
780
+ isError: computed(() => query().state().status === 'error'),
781
+ failureCount: computed(() => query().state().failureCount),
782
+ failureReason: computed(() => query().state().failureReason),
783
+ // Force a fresh fetch regardless of staleTime, cancelling any in-flight
784
+ // request (explicit user intent — get current data).
785
+ refetch: () => {
786
+ const { queryKey, queryFn, retry, retryDelay } = untracked(defaultedOptions);
787
+ client.fetchQuery(queryKey, queryFn, {
788
+ staleTime: 0,
789
+ retry,
790
+ retryDelay,
791
+ cancelRefetch: true,
792
+ });
793
+ },
794
+ };
795
+ });
796
+ }
797
+
798
+ /**
799
+ * Creates a mutation for imperative writes (create/update/delete) and exposes
800
+ * its state as signals.
801
+ *
802
+ * Unlike queries, a mutation does not run on its own — call `mutate(variables)`
803
+ * to trigger `mutationFn`. The `onMutate` / `onSuccess` / `onError` /
804
+ * `onSettled` lifecycle hooks make optimistic updates and cache invalidation
805
+ * straightforward. Mutations do not retry by default (a retried write is not
806
+ * idempotent); opt in via `options.retry`. Bound to the current injection
807
+ * context and cancelled when that context is destroyed.
808
+ *
809
+ * Must run in an injection context, or be given an explicit `injector`.
810
+ *
811
+ * @typeParam TData - Type of the data resolved by `mutationFn`.
812
+ * @typeParam TError - Type of the error thrown by `mutationFn`.
813
+ * @typeParam TVariables - Type of the argument passed to `mutate()`.
814
+ * @typeParam TContext - Type returned by `onMutate`, passed to later hooks.
815
+ * @param optionsFn - Factory returning the {@link MutationOptions}.
816
+ * @param options - Optional `injector` to use outside an injection context.
817
+ * @returns A {@link MutationResult}: `mutate()`, `reset()`, and state signals
818
+ * (`status`, `data`, `error`, `isPending`, …).
819
+ *
820
+ * @example
821
+ * ```ts
822
+ * const client = injectQueryClient()
823
+ *
824
+ * const addTodo = injectMutation(() => ({
825
+ * mutationFn: (title: string) => http.post<Todo>('/api/todos', { title }),
826
+ * onSuccess: () => client.invalidateQueries({ queryKey: ['todos'] }),
827
+ * }))
828
+ *
829
+ * // <button (click)="addTodo.mutate('Buy milk')" [disabled]="addTodo.isPending()">Add</button>
830
+ * ```
831
+ */
832
+ function injectMutation(optionsFn, options) {
833
+ if (!options?.injector)
834
+ assertInInjectionContext(injectMutation);
835
+ const injector = options?.injector ?? inject(Injector);
836
+ return runInInjectionContext(injector, () => {
837
+ const cache = inject(QueryClient).getMutationCache();
838
+ const mutation = cache.build(optionsFn());
839
+ inject(DestroyRef).onDestroy(() => {
840
+ mutation.cancel();
841
+ cache.remove(mutation);
842
+ });
843
+ return {
844
+ mutate: (variables) => mutation.execute(variables),
845
+ reset: () => mutation.reset(),
846
+ data: computed(() => mutation.state().data),
847
+ error: computed(() => mutation.state().error),
848
+ variables: computed(() => mutation.state().variables),
849
+ status: computed(() => mutation.state().status),
850
+ isIdle: computed(() => mutation.state().status === 'idle'),
851
+ isPending: computed(() => mutation.state().status === 'pending'),
852
+ isSuccess: computed(() => mutation.state().status === 'success'),
853
+ isError: computed(() => mutation.state().status === 'error'),
854
+ failureCount: computed(() => mutation.state().failureCount),
855
+ failureReason: computed(() => mutation.state().failureReason),
856
+ };
857
+ });
858
+ }
859
+
860
+ /**
861
+ * Returns a signal with the number of queries currently fetching — useful for a
862
+ * global loading indicator.
863
+ *
864
+ * Pass {@link QueryFilters} to count only matching queries; omit them to count
865
+ * every fetching query in the cache.
866
+ *
867
+ * Must run in an injection context, or be given an explicit `injector`.
868
+ *
869
+ * @param filters - Optional filters (e.g. `{ queryKey: ['todos'] }`) to narrow
870
+ * the count.
871
+ * @param options - Optional `injector` to use outside an injection context.
872
+ * @returns A `Signal<number>` of active fetches.
873
+ *
874
+ * @example
875
+ * ```ts
876
+ * readonly isFetching = injectIsFetching()
877
+ * // <app-spinner *ngIf="isFetching() > 0" />
878
+ * ```
879
+ */
880
+ function injectIsFetching(filters, options) {
881
+ if (!options?.injector)
882
+ assertInInjectionContext(injectIsFetching);
883
+ const client = options?.injector?.get(QueryClient) ?? inject(QueryClient);
884
+ const cache = client.getQueryCache();
885
+ return computed(() => cache.findAll(filters).filter((query) => query.state().isFetching).length);
886
+ }
887
+
888
+ /**
889
+ * Returns a signal with the number of mutations currently pending — useful for
890
+ * a global "saving…" indicator.
891
+ *
892
+ * Must run in an injection context, or be given an explicit `injector`.
893
+ *
894
+ * @param options - Optional `injector` to use outside an injection context.
895
+ * @returns A `Signal<number>` of in-flight mutations.
896
+ *
897
+ * @example
898
+ * ```ts
899
+ * readonly isMutating = injectIsMutating()
900
+ * // <p *ngIf="isMutating() > 0">Saving…</p>
901
+ * ```
902
+ */
903
+ function injectIsMutating(options) {
904
+ if (!options?.injector)
905
+ assertInInjectionContext(injectIsMutating);
906
+ const client = options?.injector?.get(QueryClient) ?? inject(QueryClient);
907
+ const cache = client.getMutationCache();
908
+ return computed(() => cache.findAll({ status: 'pending' }).length);
909
+ }
910
+
911
+ /**
912
+ * Identity helper that defines a typed, reusable set of {@link QueryOptions}.
913
+ *
914
+ * Returns the options unchanged at runtime, but anchors type inference so the
915
+ * same definition can be shared across {@link injectQuery},
916
+ * {@link QueryClient.setQueryData}, and friends without re-declaring generics.
917
+ * Keeping query definitions in a service (rather than inline) makes them easy
918
+ * to reuse and unit-test.
919
+ *
920
+ * @typeParam TData - Type of the data resolved by `queryFn`.
921
+ * @typeParam TError - Type of the error thrown by `queryFn`.
922
+ * @param options - The {@link QueryOptions} to define.
923
+ * @returns The same `options`, typed.
924
+ *
925
+ * @example
926
+ * ```ts
927
+ * @Injectable({ providedIn: 'root' })
928
+ * class TodoQueries {
929
+ * private readonly http = inject(HttpClient)
930
+ *
931
+ * todos() {
932
+ * return queryOptions({
933
+ * queryKey: ['todos'],
934
+ * queryFn: () => this.http.get<Todo[]>('/api/todos'),
935
+ * })
936
+ * }
937
+ * }
938
+ *
939
+ * // const todos = injectQuery(() => inject(TodoQueries).todos())
940
+ * ```
941
+ */
942
+ function queryOptions(options) {
943
+ return options;
944
+ }
945
+
946
+ /**
947
+ * Identity helper that defines a typed, reusable set of {@link MutationOptions}.
948
+ *
949
+ * Returns the options unchanged at runtime; its job is to anchor type inference
950
+ * (notably linking `TContext` returned by `onMutate` to the later hooks) so a
951
+ * mutation can be defined once in a service and passed to {@link injectMutation}.
952
+ *
953
+ * @typeParam TData - Type of the data resolved by `mutationFn`.
954
+ * @typeParam TError - Type of the error thrown by `mutationFn`.
955
+ * @typeParam TVariables - Type of the argument passed to `mutate()`.
956
+ * @typeParam TContext - Type returned by `onMutate`, passed to later hooks.
957
+ * @param options - The {@link MutationOptions} to define.
958
+ * @returns The same `options`, typed.
959
+ *
960
+ * @example
961
+ * ```ts
962
+ * addTodo() {
963
+ * return mutationOptions({
964
+ * mutationFn: (title: string) => this.http.post<Todo>('/api/todos', { title }),
965
+ * onSuccess: () => this.client.invalidateQueries({ queryKey: ['todos'] }),
966
+ * })
967
+ * }
968
+ * ```
969
+ */
970
+ function mutationOptions(options) {
971
+ return options;
972
+ }
973
+
974
+ /**
975
+ * Feature for {@link provideQueryClient} that sets application-wide default
976
+ * query options. Per-query options always take precedence over these defaults.
977
+ *
978
+ * @param options - The {@link DefaultOptions} to apply.
979
+ * @returns A {@link QueryClientFeature} to pass to {@link provideQueryClient}.
980
+ *
981
+ * @example
982
+ * ```ts
983
+ * provideQueryClient(
984
+ * withDefaultOptions({ queries: { staleTime: 60_000, retry: 1 } }),
985
+ * )
986
+ * ```
987
+ */
988
+ function withDefaultOptions(options) {
989
+ return queryClientFeature(QueryClientFeatureKind.DefaultOptions, [
990
+ { provide: QUERY_CLIENT_CONFIG, useValue: { defaultOptions: options } },
991
+ ]);
992
+ }
993
+
994
+ /*
995
+ * Public API Surface of ngx-query
996
+ */
997
+
998
+ /**
999
+ * Generated bundle index. Do not edit.
1000
+ */
1001
+
1002
+ export { QueryClient, QueryClientFeatureKind, injectIsFetching, injectIsMutating, injectMutation, injectQuery, injectQueryClient, mutationOptions, provideQueryClient, queryOptions, withDefaultOptions };
1003
+ //# sourceMappingURL=ngx-signal-query.mjs.map