@mmstack/resource 19.6.5 → 19.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,28 @@ import { type HttpResourceRequest } from '@angular/common/http';
2
2
  import { type Provider, type Signal, type ValueEqualityFn } from '@angular/core';
3
3
  import { type QueryResourceOptions, type QueryResourceRef } from './query-resource';
4
4
  /**
5
- * @internal
6
- * Helper type for inferring the request body type based on the HTTP method.
5
+ * Why a {@link MutationResourceRef.mutateAsync} promise was cancelled — a closed
6
+ * set so consumers can branch on the cause without parsing the message:
7
+ * - `'superseded'`: a newer mutation replaced it (latest-wins).
8
+ * - `'queue-cleared'`: dropped from the queue by `clearQueue()`.
9
+ * - `'queue-key-changed'`: dropped from the queue by a reactive `key` change.
10
+ * - `'destroyed'`: the resource was destroyed while it was pending/in flight.
11
+ * - `'no-request'`: `request()` returned `undefined`, so nothing was sent.
7
12
  */
8
- type NextRequest<TMethod extends HttpResourceRequest['method'], TMutation> = TMethod extends 'DELETE' | 'delete' ? Omit<HttpResourceRequest, 'body' | 'method'> & {
9
- method: TMethod;
10
- } : Omit<HttpResourceRequest, 'body' | 'method'> & {
11
- body: TMutation;
12
- method: TMethod;
13
- };
13
+ export type MutationCancellationReason = 'superseded' | 'queue-cleared' | 'queue-key-changed' | 'destroyed' | 'no-request';
14
+ /**
15
+ * Rejection reason for a {@link MutationResourceRef.mutateAsync} promise whose
16
+ * mutation never completed. The {@link MutationCancelledError.type} discriminant
17
+ * carries the cause ({@link MutationCancellationReason}); the message is a
18
+ * human-readable elaboration of it.
19
+ *
20
+ * Only `mutateAsync` promises reject with this; plain `mutate()` calls have no
21
+ * promise and so produce no (potentially unhandled) rejection.
22
+ */
23
+ export declare class MutationCancelledError extends Error {
24
+ readonly type: MutationCancellationReason;
25
+ constructor(type: MutationCancellationReason, message: string);
26
+ }
14
27
  /**
15
28
  * Object form of the `queue` option. Enabling the queue serializes mutations
16
29
  * into a FIFO that runs one-at-a-time.
@@ -84,14 +97,17 @@ export type MutationResourceOptions<TResult, TRaw = TResult, TMutation = TResult
84
97
  * Cache entries to invalidate after a SUCCESSFUL mutation — the declarative
85
98
  * alternative to calling `injectQueryCache().invalidatePrefix(...)` in `onSuccess`.
86
99
  *
87
- * Each string is a URL prefix matched against auto-generated `GET` cache keys
88
- * (`GET:${url}:...`): `'/api/posts'` invalidates `/api/posts` with any query params,
89
- * plus subpaths like `/api/posts/123` — and all `varyHeaders` variants of each.
100
+ * Each string is a URL prefix matched against the request URL of every cached
101
+ * entry, regardless of HTTP method: `'/api/posts'` invalidates `/api/posts` with
102
+ * any query params, plus subpaths like `/api/posts/123` — and all `varyHeaders`
103
+ * variants of each — across GET/HEAD/OPTIONS/POST or whatever methods you cache.
90
104
  * Note that plain prefix matching also catches sibling paths sharing the prefix
91
105
  * (`/api/posts-archive`); pass `'/api/posts/'` or the exact URL to narrow.
92
106
  *
93
- * Entries keyed by a custom `hash` function follow that function's shape, not the
94
- * auto-key shape invalidate those manually via `injectQueryCache().invalidateWhere`.
107
+ * Keys built by a custom `cache.hash` that merely *prepends* a namespace (e.g. a
108
+ * tenant/`sub` for per-user persistent caches) are still matched the URL is
109
+ * recovered structurally. Keys that abandon the auto shape entirely need an
110
+ * a custom invalidateMatcher (or manual `injectQueryCache().invalidateWhere`).
95
111
  *
96
112
  * The function form receives the mutation result and the mutated value:
97
113
  * ```ts
@@ -99,6 +115,11 @@ export type MutationResourceOptions<TResult, TRaw = TResult, TMutation = TResult
99
115
  * ```
100
116
  */
101
117
  invalidates?: string[] | ((value: NoInfer<TResult>, mutation: NoInfer<TMutation>) => string[]);
118
+ /**
119
+ * override for how {@link MutationResourceOptions.invalidates} URL
120
+ * prefixes map onto cache keys — given a prefix, return a key predicate.
121
+ */
122
+ invalidateMatcher?: (urlPrefix: string) => (key: string) => boolean;
102
123
  equal?: ValueEqualityFn<TMutation>;
103
124
  };
104
125
  /**
@@ -134,6 +155,18 @@ export type MutationResourceRef<TResult, TMutation = TResult, TICTX = void> = Om
134
155
  * @param ctx An optional initial context value that will be passed to the `onMutate` callback.
135
156
  */
136
157
  mutate: (value: TMutation, ctx?: TICTX) => void;
158
+ /**
159
+ * Executes the mutation and returns a `Promise`
160
+ *
161
+ * If the mutation never completes — superseded by a newer `mutate`/`mutateAsync`
162
+ * (latest-wins), dropped from the queue (`clearQueue` / queue `key` change),
163
+ * abandoned on `destroy()`, or its `request()` returned `undefined` — the
164
+ * promise rejects with a {@link MutationCancelledError}.
165
+ *
166
+ * @param value The mutation value (usually the request body).
167
+ * @param ctx An optional initial context value that will be passed to the `onMutate` callback.
168
+ */
169
+ mutateAsync: (value: TMutation, ctx?: TICTX) => Promise<TResult>;
137
170
  /**
138
171
  * A signal that holds the current mutation request, or `null` if no mutation is in progress.
139
172
  * This can be useful for tracking the state of the mutation or for displaying loading indicators.
@@ -194,5 +227,9 @@ export type MutationResourceRef<TResult, TMutation = TResult, TICTX = void> = Om
194
227
  * );
195
228
  * ```
196
229
  */
197
- export declare function mutationResource<TResult, TRaw = TResult, TMutation = TResult, TCTX = void, TICTX = TCTX, TMethod extends HttpResourceRequest['method'] = HttpResourceRequest['method']>(request: (params: TMutation) => Omit<NextRequest<TMethod, TMutation>, 'body'> | undefined | void, options0?: MutationResourceOptions<TResult, TRaw, TMutation, TCTX, TICTX>): MutationResourceRef<TResult, TMutation, TICTX>;
198
- export {};
230
+ export declare function mutationResource<TResult, TRaw = TResult, TMutation = TResult, TCTX = void, TICTX = TCTX>(request: (params: TMutation) => (Omit<HttpResourceRequest, 'body' | 'method'> & {
231
+ method: 'DELETE' | 'delete';
232
+ }) | undefined | void, options0?: MutationResourceOptions<TResult, TRaw, TMutation, TCTX, TICTX>): MutationResourceRef<TResult, TMutation, TICTX>;
233
+ export declare function mutationResource<TResult, TRaw = TResult, TMutation = TResult, TCTX = void, TICTX = TCTX>(request: (params: TMutation) => (Omit<HttpResourceRequest, 'body'> & {
234
+ body: TMutation;
235
+ }) | undefined | void, options0?: MutationResourceOptions<TResult, TRaw, TMutation, TCTX, TICTX>): MutationResourceRef<TResult, TMutation, TICTX>;
@@ -1,6 +1,6 @@
1
1
  import { HttpResponse } from '@angular/common/http';
2
- import { Injector, type Provider, type Signal } from '@angular/core';
3
- import { CacheDB } from './persistence';
2
+ import { type Injector, type Provider, type Signal } from '@angular/core';
3
+ import { type CacheDB } from './persistence';
4
4
  /**
5
5
  * Options for configuring the Least Recently Used (LRU) cache cleanup strategy.
6
6
  * @internal
@@ -72,6 +72,8 @@ export declare class Cache<T> {
72
72
  private hydrated;
73
73
  /** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
74
74
  private readonly hydrationTombstones;
75
+ /** Dev-only: ensures the "foreign keys, no matcher" hint in invalidateUrlPrefix fires at most once. */
76
+ private warnedForeignKeys;
75
77
  private readonly hitCount;
76
78
  private readonly missCount;
77
79
  /**
@@ -181,6 +183,35 @@ export declare class Cache<T> {
181
183
  * cache.invalidatePrefix('GET https://api.example.com/posts');
182
184
  */
183
185
  invalidatePrefix(prefix: string): number;
186
+ /**
187
+ * Invalidates every cache entry whose *request URL* starts with `urlPrefix`,
188
+ * regardless of HTTP method. This is the engine behind `mutationResource`'s
189
+ * `invalidates` option: `'/api/posts'` clears `/api/posts` with any query
190
+ * params, subpaths like `/api/posts/123`, and all `varyHeaders` variants —
191
+ * across GET/HEAD/OPTIONS/POST or any other cached method. Returns the number
192
+ * of entries removed.
193
+ *
194
+ * Unlike {@link invalidatePrefix} (which matches the raw key from its start),
195
+ * this extracts the URL field from the auto-generated key shape, so it is not
196
+ * fooled by the leading method token nor by a namespace a custom `cache.hash`
197
+ * prepends (e.g. `tenant:…`). Plain prefix matching still catches siblings
198
+ * sharing the prefix (`/api/posts-archive`) — pass `'/api/posts/'` to narrow.
199
+ *
200
+ * Keys produced by a custom `hash` that don't follow the auto shape won't be
201
+ * matched by the default; pass `match` to describe how a URL prefix maps onto
202
+ * your key format. In dev mode, if a default-matcher call removes nothing and
203
+ * every cached key is foreign-shaped, this logs a one-time hint pointing at the
204
+ * `match` escape hatch (a likely sign of a custom `hash` with no matcher wired up).
205
+ *
206
+ * @param urlPrefix - URL prefix to match.
207
+ * @param match - Optional custom matcher: given the prefix, returns a key predicate.
208
+ *
209
+ * @example
210
+ * cache.invalidateUrlPrefix('/api/posts');
211
+ * // custom key scheme:
212
+ * cache.invalidateUrlPrefix('/api/posts', (p) => (k) => k.includes(`|url=${p}`));
213
+ */
214
+ invalidateUrlPrefix(urlPrefix: string, match?: (urlPrefix: string) => (key: string) => boolean): number;
184
215
  /**
185
216
  * Invalidates every cache entry whose key matches the predicate. Use for
186
217
  * arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
@@ -1,4 +1,4 @@
1
- import { CacheEntry } from './cache';
1
+ import { type CacheEntry } from './cache';
2
2
  type StoredEntry<T> = Omit<CacheEntry<T>, 'timeout'>;
3
3
  export type CacheDB<T> = {
4
4
  getAll: () => Promise<StoredEntry<T>[]>;
@@ -7,11 +7,34 @@ type HashableRequest = {
7
7
  body?: unknown;
8
8
  headers?: HttpResourceRequest['headers'] | HttpRequest<unknown>['headers'];
9
9
  };
10
+ /**
11
+ * Top-level field separator for auto-generated cache keys. ASCII Unit Separator
12
+ * (`\x1f`) is deliberately content-rare: it never occurs in HTTP method tokens,
13
+ * URLs, or `encodeURIComponent`/digest output (params, vary headers, body hash),
14
+ * so the structural layout stays unambiguous even when a custom `cache.hash`
15
+ * *prepends* a namespace with ordinary chars (e.g. `tenant:${hashRequest(req)}`).
16
+ * Survives `JSON.stringify` (IndexedDB persistence) and `structuredClone`
17
+ * (cross-tab broadcast) intact.
18
+ */
19
+ export declare const KEY_DELIMITER = "\u001F";
20
+ /**
21
+ * Recovers the URL portion of an auto-generated cache key — the segment between
22
+ * the 1st and 2nd {@link KEY_DELIMITER} (the key shape is
23
+ * `method␟url␟responseType[␟…]`). Returns `null` when the key has no delimiter
24
+ * (e.g. produced by a custom `hash` that doesn't follow this shape).
25
+ *
26
+ * A namespace prepended with non-delimiter chars collapses into segment 0
27
+ * (`tenant:GET`), so the URL remains segment 1 — method-agnostic and
28
+ * namespacing-tolerant by construction.
29
+ */
30
+ export declare function extractUrlFromKey(key: string): string | null;
10
31
  /**
11
32
  * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
12
33
  * `HttpRequest` and `HttpResourceRequest`).
13
34
  *
14
- * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}][:${vary}]`
35
+ * Key composition: `${method}␟${url}␟${responseType}[␟${params}][␟${body}][␟${vary}]`,
36
+ * where `␟` is {@link KEY_DELIMITER} (ASCII Unit Separator) — a content-rare top-level
37
+ * separator. Sub-fields inside `params`/`vary` keep their own `&`/`=` delimiters.
15
38
  * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
16
39
  * - Query params are sorted alphabetically and URL-encoded for stability.
17
40
  * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmstack/resource",
3
- "version": "19.6.5",
3
+ "version": "19.7.0",
4
4
  "keywords": [
5
5
  "angular",
6
6
  "signals",