lazypock 0.2.0 → 0.4.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.
- package/README.md +135 -0
- package/dist/index.cjs +509 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +284 -1
- package/dist/index.d.ts +284 -1
- package/dist/index.global.js +507 -10
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +507 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cache.ts +314 -0
- package/src/collection.ts +71 -5
- package/src/collections.ts +41 -5
- package/src/http.ts +190 -0
- package/src/index.ts +5 -0
- package/src/lazypock.ts +144 -0
- package/src/types.ts +23 -0
package/src/http.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { ApiError, type Method, type RequestOptions } from "./types";
|
|
5
5
|
import type { AuthStore } from "./auth";
|
|
6
|
+
import type { CacheStore } from "./cache";
|
|
7
|
+
import { resolveCacheDirective } from "./cache";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Low-level HTTP client wrapping `fetch` with automatic auth token injection.
|
|
@@ -20,6 +22,10 @@ export class HttpClient {
|
|
|
20
22
|
private baseUrl: string;
|
|
21
23
|
private authStore: AuthStore;
|
|
22
24
|
private defaultFetch: typeof globalThis.fetch;
|
|
25
|
+
/** Optional query cache store (wired when the client enables caching). */
|
|
26
|
+
private cache?: CacheStore;
|
|
27
|
+
/** Master switch resolved from CacheConfig.enabled. */
|
|
28
|
+
private cacheEnabled = false;
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Abort controllers for in-flight requests, keyed by their cancellation key
|
|
@@ -28,6 +34,14 @@ export class HttpClient {
|
|
|
28
34
|
*/
|
|
29
35
|
private cancelControllers: Record<string, AbortController> = {};
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* In-flight request promises, keyed by cancellation key. When auto-cancellation
|
|
39
|
+
* would abort a pending duplicate, the newer request instead awaits the same
|
|
40
|
+
* promise — single-flight coalescing (no duplicate network request, no
|
|
41
|
+
* spurious abort rejection for the caller).
|
|
42
|
+
*/
|
|
43
|
+
private inflight: Record<string, Promise<unknown>> = {};
|
|
44
|
+
|
|
31
45
|
/** Global toggle for the auto-cancellation behaviour (default: on). */
|
|
32
46
|
private enableAutoCancellation = true;
|
|
33
47
|
|
|
@@ -41,6 +55,20 @@ export class HttpClient {
|
|
|
41
55
|
this.defaultFetch = globalThis.fetch.bind(globalThis);
|
|
42
56
|
}
|
|
43
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Attach a cache store + master switch.
|
|
60
|
+
* Called by the client constructor when cache config is present.
|
|
61
|
+
*/
|
|
62
|
+
setCache(cache: CacheStore, enabled: boolean): void {
|
|
63
|
+
this.cache = cache;
|
|
64
|
+
this.cacheEnabled = enabled;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether the global cache flag is on (requests opt in/out individually too). */
|
|
68
|
+
get cacheIsEnabled(): boolean {
|
|
69
|
+
return this.cacheEnabled;
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
private async refreshAuth(): Promise<{
|
|
45
73
|
token: string;
|
|
46
74
|
record: Record<string, unknown>;
|
|
@@ -125,6 +153,16 @@ export class HttpClient {
|
|
|
125
153
|
* @throws {ApiError} On non-2xx responses or when the request is aborted
|
|
126
154
|
* (aborted requests throw an `ApiError` with `isAbort === true`).
|
|
127
155
|
*/
|
|
156
|
+
/** Invalidate a namespace (collection name). No-op when cache is off. */
|
|
157
|
+
invalidateCache(namespace: string): void {
|
|
158
|
+
this.cache?.invalidate(namespace);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Current cache statistics (hits/misses/entries), or null when disabled. */
|
|
162
|
+
cacheStats(): { hits: number; misses: number; entries: number } | null {
|
|
163
|
+
return this.cache ? this.cache.stats() : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
128
166
|
async request<T = unknown>(
|
|
129
167
|
method: Method,
|
|
130
168
|
path: string,
|
|
@@ -136,6 +174,28 @@ export class HttpClient {
|
|
|
136
174
|
await this.refreshAuth();
|
|
137
175
|
}
|
|
138
176
|
|
|
177
|
+
// ── Cache resolution (read path) ────────────────────────────────
|
|
178
|
+
// Only cacheable reads (GET) participate. Cache keys are scoped by auth
|
|
179
|
+
// token so user A's cached list can never leak to user B (or anonymous).
|
|
180
|
+
//
|
|
181
|
+
// Effective caching for this request:
|
|
182
|
+
// - per-request { cache } / { ttl } present → use it (true enables,
|
|
183
|
+
// false bypasses, number/object sets TTL)
|
|
184
|
+
// - otherwise → fall back to the global cache.enabled flag
|
|
185
|
+
const cacheDirective = resolveCacheDirective(options);
|
|
186
|
+
const wantCache =
|
|
187
|
+
cacheDirective !== null
|
|
188
|
+
? cacheDirective.enabled
|
|
189
|
+
: this.cacheEnabled;
|
|
190
|
+
const cacheKey =
|
|
191
|
+
method === "GET" && this.cache && wantCache
|
|
192
|
+
? this.cacheKeyFor(method, path, options?.params)
|
|
193
|
+
: null;
|
|
194
|
+
if (cacheKey !== null) {
|
|
195
|
+
const hit = await this.cache?.get(cacheKey);
|
|
196
|
+
if (hit !== undefined) return hit as T;
|
|
197
|
+
}
|
|
198
|
+
|
|
139
199
|
// Resolve the auto-cancellation key (PocketBase `requestKey` semantics):
|
|
140
200
|
// - options.requestKey null → disabled for this request
|
|
141
201
|
// - options.requestKey string → use it verbatim
|
|
@@ -148,6 +208,16 @@ export class HttpClient {
|
|
|
148
208
|
: options.requestKey;
|
|
149
209
|
if (options?.autoCancel === false) requestKey = null;
|
|
150
210
|
|
|
211
|
+
// Single-flight coalescing: when the same requestKey is already in-flight
|
|
212
|
+
// and the caller opted in, reuse that promise instead of firing a duplicate
|
|
213
|
+
// request (no abort rejection for either caller).
|
|
214
|
+
if (options?.singleFlight && requestKey !== null) {
|
|
215
|
+
const pending = this.inflight[requestKey];
|
|
216
|
+
if (pending !== undefined) {
|
|
217
|
+
return pending as Promise<T | null>;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
151
221
|
// Wire a fresh AbortController for this request, merging any caller signal.
|
|
152
222
|
// When auto-cancellation is enabled, the previous pending request sharing
|
|
153
223
|
// our key is aborted first (only the last duplicate executes).
|
|
@@ -169,6 +239,52 @@ export class HttpClient {
|
|
|
169
239
|
}
|
|
170
240
|
const signal = controller?.signal ?? externalSignal;
|
|
171
241
|
|
|
242
|
+
// Register the in-flight promise so later single-flight callers reuse it.
|
|
243
|
+
// The promise is created from an inner async fn that performs the request
|
|
244
|
+
// and clears itself from the inflight map on settle.
|
|
245
|
+
const perform = async (): Promise<T | null> => {
|
|
246
|
+
try {
|
|
247
|
+
return await this.doRequest(
|
|
248
|
+
method,
|
|
249
|
+
path,
|
|
250
|
+
body,
|
|
251
|
+
options,
|
|
252
|
+
signal,
|
|
253
|
+
requestKey,
|
|
254
|
+
controller,
|
|
255
|
+
cacheKey,
|
|
256
|
+
cacheDirective,
|
|
257
|
+
);
|
|
258
|
+
} finally {
|
|
259
|
+
if (requestKey !== null) {
|
|
260
|
+
if (this.inflight[requestKey] === promise) {
|
|
261
|
+
delete this.inflight[requestKey];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const promise = perform();
|
|
267
|
+
if (requestKey !== null) {
|
|
268
|
+
this.inflight[requestKey] = promise;
|
|
269
|
+
}
|
|
270
|
+
return promise;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Execute the actual HTTP request (fetch + parse + cache). Called by {@link request}
|
|
275
|
+
* as the inner in-flight unit so single-flight callers can reuse the promise.
|
|
276
|
+
*/
|
|
277
|
+
private async doRequest<T = unknown>(
|
|
278
|
+
method: Method,
|
|
279
|
+
path: string,
|
|
280
|
+
body: unknown,
|
|
281
|
+
options: RequestOptions | undefined,
|
|
282
|
+
signal: AbortSignal | null | undefined,
|
|
283
|
+
requestKey: string | null,
|
|
284
|
+
controller: AbortController | null,
|
|
285
|
+
cacheKey: string | null,
|
|
286
|
+
cacheDirective: ReturnType<typeof resolveCacheDirective>,
|
|
287
|
+
): Promise<T | null> {
|
|
172
288
|
let url = this.baseUrl + path;
|
|
173
289
|
if (options?.params) {
|
|
174
290
|
const qs = new URLSearchParams(options.params).toString();
|
|
@@ -253,9 +369,83 @@ export class HttpClient {
|
|
|
253
369
|
);
|
|
254
370
|
}
|
|
255
371
|
|
|
372
|
+
// ── Cache store (read path) ─────────────────────────────────────
|
|
373
|
+
// Persist successful GET payloads when the directive wants caching.
|
|
374
|
+
if (cacheKey !== null && this.cache) {
|
|
375
|
+
const namespace = this.namespaceFromPath(path);
|
|
376
|
+
const ttl = cacheDirective?.ttl;
|
|
377
|
+
const tags = this.cacheTagsFor(path, namespace);
|
|
378
|
+
this.cache.set(
|
|
379
|
+
cacheKey,
|
|
380
|
+
data,
|
|
381
|
+
ttl,
|
|
382
|
+
namespace ?? undefined,
|
|
383
|
+
tags,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Cache invalidation (write path) ────────────────────────────
|
|
388
|
+
// Mutations invalidate the affected collection's cached entries so
|
|
389
|
+
// subsequent reads don't serve stale lists. The current collection is
|
|
390
|
+
// always invalidated; `options.invalidate` adds extra namespaces.
|
|
391
|
+
if (method !== "GET" && this.cache) {
|
|
392
|
+
const namespaces = new Set<string>();
|
|
393
|
+
const ns = this.namespaceFromPath(path);
|
|
394
|
+
if (ns) namespaces.add(ns);
|
|
395
|
+
for (const extra of options?.invalidate ?? []) {
|
|
396
|
+
if (extra) namespaces.add(extra);
|
|
397
|
+
}
|
|
398
|
+
for (const nsName of namespaces) this.cache.invalidate(nsName);
|
|
399
|
+
}
|
|
400
|
+
|
|
256
401
|
return data as T;
|
|
257
402
|
}
|
|
258
403
|
|
|
404
|
+
// ── Cache key/namespace helpers ──
|
|
405
|
+
|
|
406
|
+
/** Build a token-scoped cache key: `METHOD path|token-hash|params`. */
|
|
407
|
+
private cacheKeyFor(
|
|
408
|
+
method: Method,
|
|
409
|
+
path: string,
|
|
410
|
+
params?: Record<string, string>,
|
|
411
|
+
): string {
|
|
412
|
+
const token = this.authStore.token || "anon";
|
|
413
|
+
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
|
414
|
+
return `${method} ${path}${qs}|${token}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Best-effort namespace (collection name) from a REST path. */
|
|
418
|
+
private namespaceFromPath(path: string): string | undefined {
|
|
419
|
+
// /posts/abc-123 → posts ; /collections/xyz → collections
|
|
420
|
+
// strip any query string first (/posts?page=1 → /posts)
|
|
421
|
+
const clean = path.split("?")[0];
|
|
422
|
+
const parts = clean.split("/").filter(Boolean);
|
|
423
|
+
if (parts.length === 0) return undefined;
|
|
424
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
425
|
+
return parts[0];
|
|
426
|
+
}
|
|
427
|
+
return parts[0];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Semantic prefix tags for `deleteByPrefix`, derived from the REST shape:
|
|
432
|
+
* - `/{collection}?...` → `getList:{collection}`
|
|
433
|
+
* - `/{collection}/{id}` → `getOne:{collection}`
|
|
434
|
+
* - `/collections?...` / `/collections/{id}` → `collections:getList` / `collections:getOne`
|
|
435
|
+
*/
|
|
436
|
+
private cacheTagsFor(path: string, namespace: string | undefined): string[] {
|
|
437
|
+
if (!namespace) return [];
|
|
438
|
+
const clean = path.split("?")[0];
|
|
439
|
+
const parts = clean.split("/").filter(Boolean);
|
|
440
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
441
|
+
const op = parts.length >= 2 ? "getOne" : "getList";
|
|
442
|
+
return [`${namespace}:${op}`];
|
|
443
|
+
}
|
|
444
|
+
// /posts (list) vs /posts/{id} (one)
|
|
445
|
+
const op = parts.length >= 2 ? "getOne" : "getList";
|
|
446
|
+
return [`${op}:${namespace}`];
|
|
447
|
+
}
|
|
448
|
+
|
|
259
449
|
/**
|
|
260
450
|
* HTTP GET.
|
|
261
451
|
* @param path URL path.
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,8 @@ export {
|
|
|
23
23
|
fieldTypeScriptType,
|
|
24
24
|
fieldTypeKind,
|
|
25
25
|
schemaFieldType,
|
|
26
|
+
CacheStore,
|
|
27
|
+
resolveCacheDirective,
|
|
26
28
|
} from "./lazypock";
|
|
27
29
|
export { TypedClient, createClient } from "./client";
|
|
28
30
|
|
|
@@ -38,6 +40,9 @@ export type {
|
|
|
38
40
|
SystemFields,
|
|
39
41
|
RequestOptions,
|
|
40
42
|
FileRecord,
|
|
43
|
+
// cache
|
|
44
|
+
CacheConfig,
|
|
45
|
+
CacheRequestOptions,
|
|
41
46
|
// schema
|
|
42
47
|
CollectionSchema,
|
|
43
48
|
SchemaField,
|
package/src/lazypock.ts
CHANGED
|
@@ -32,6 +32,8 @@ import { CollectionsService } from "./collections";
|
|
|
32
32
|
import type { CollectionSchema, SchemaField } from "./schema";
|
|
33
33
|
import { generateTypes, collectionTypeName } from "./codegen";
|
|
34
34
|
import { fieldTypeScriptType, fieldTypeKind, schemaFieldType } from "./typegen";
|
|
35
|
+
import { CacheStore, type CacheConfig, type CacheRequestOptions } from "./cache";
|
|
36
|
+
import { resolveCacheDirective } from "./cache";
|
|
35
37
|
|
|
36
38
|
export {
|
|
37
39
|
AuthStore,
|
|
@@ -65,6 +67,25 @@ export type {
|
|
|
65
67
|
CollectionSchema,
|
|
66
68
|
SchemaField,
|
|
67
69
|
};
|
|
70
|
+
export type { CacheConfig, CacheRequestOptions };
|
|
71
|
+
export { CacheStore, resolveCacheDirective };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Callable cache namespace: `client.cache(config)` configures, and
|
|
75
|
+
* `client.cache.deleteByPrefix(...)` etc. manage cached entries.
|
|
76
|
+
*/
|
|
77
|
+
export interface CacheController {
|
|
78
|
+
/** Configure the query cache at runtime. */
|
|
79
|
+
(config?: CacheConfig): LazypockClient;
|
|
80
|
+
/** Delete every entry whose key starts with `prefix` (e.g. `getList:posts`). */
|
|
81
|
+
deleteByPrefix(prefix: string): void;
|
|
82
|
+
/** Invalidate a collection's cached entries (alias of invalidateCache). */
|
|
83
|
+
invalidate(namespace: string): void;
|
|
84
|
+
/** Drop every cached entry. */
|
|
85
|
+
clear(): void;
|
|
86
|
+
/** Cache hit/miss/entry stats, or null when never configured. */
|
|
87
|
+
stats(): { hits: number; misses: number; entries: number } | null;
|
|
88
|
+
}
|
|
68
89
|
|
|
69
90
|
/** Options for constructing a {@link LazypockClient}. */
|
|
70
91
|
export interface LazypockClientOptions {
|
|
@@ -76,6 +97,24 @@ export interface LazypockClientOptions {
|
|
|
76
97
|
authStore?: AuthStore;
|
|
77
98
|
/** Real-time service for Phoenix Channel WebSocket subscriptions */
|
|
78
99
|
realtime?: RealtimeService;
|
|
100
|
+
/**
|
|
101
|
+
* Query cache configuration. Disabled by default.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* const client = createClient({
|
|
105
|
+
* baseUrl: '...',
|
|
106
|
+
* cache: {
|
|
107
|
+
* enabled: true,
|
|
108
|
+
* defaultTTL: 30_000,
|
|
109
|
+
* store: myStorage, // optional persistence (same interface as auth)
|
|
110
|
+
* },
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* When enabled, readable GETs are cached. Requests can opt out via
|
|
115
|
+
* `{ cache: false }`, or opt in with a custom TTL via `{ ttl: ms }`.
|
|
116
|
+
*/
|
|
117
|
+
cache?: CacheConfig;
|
|
79
118
|
/**
|
|
80
119
|
* Optional schema types for generating typed services at runtime.
|
|
81
120
|
* When provided, `collection()` returns a service whose create/update
|
|
@@ -110,6 +149,9 @@ export class LazypockClient {
|
|
|
110
149
|
readonly files: FilesService;
|
|
111
150
|
private collectionCache = new Map<string, CollectionService>();
|
|
112
151
|
private schemaByName?: Map<string, CollectionSchema>;
|
|
152
|
+
private cacheStore?: CacheStore;
|
|
153
|
+
/** Namespace → realtime unsubscribe; used for realtime-driven invalidation. */
|
|
154
|
+
private realtimeInvalidators = new Map<string, () => void>();
|
|
113
155
|
|
|
114
156
|
/**
|
|
115
157
|
* Create a new Lazypock client.
|
|
@@ -127,6 +169,14 @@ export class LazypockClient {
|
|
|
127
169
|
}
|
|
128
170
|
this.files = new FilesService(this.http);
|
|
129
171
|
this.collections = new CollectionsService(this.http, this.realtime);
|
|
172
|
+
if (options.cache) {
|
|
173
|
+
this.cacheStore = new CacheStore({
|
|
174
|
+
defaultTTL: options.cache.defaultTTL,
|
|
175
|
+
store: options.cache.store,
|
|
176
|
+
maxEntries: options.cache.maxEntries,
|
|
177
|
+
});
|
|
178
|
+
this.http.setCache(this.cacheStore, options.cache.enabled ?? false);
|
|
179
|
+
}
|
|
130
180
|
if (options.types?.schemas) {
|
|
131
181
|
this.schemaByName = new Map(
|
|
132
182
|
options.types.schemas.map((s) => [s.name, s]),
|
|
@@ -215,6 +265,100 @@ export class LazypockClient {
|
|
|
215
265
|
return this;
|
|
216
266
|
}
|
|
217
267
|
|
|
268
|
+
// ── Query cache (opt-in by default; opt-out per request) ──
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Configure the query cache at runtime (also a namespace for cache
|
|
272
|
+
* management methods).
|
|
273
|
+
*
|
|
274
|
+
* ```ts
|
|
275
|
+
* client.cache({ enabled: true, defaultTTL: 30_000 });
|
|
276
|
+
* client.cache.deleteByPrefix('getList:posts'); // all list caches for posts
|
|
277
|
+
* client.cache.deleteByPrefix('getOne:posts'); // all one-record caches
|
|
278
|
+
* ```
|
|
279
|
+
*
|
|
280
|
+
* When enabled, GET requests cache their payload; mutations invalidate the
|
|
281
|
+
* affected collection automatically. Individual requests can opt out with
|
|
282
|
+
* `{ cache: false }` or override the TTL with `{ ttl: ms }`.
|
|
283
|
+
*/
|
|
284
|
+
readonly cache: CacheController = Object.assign(
|
|
285
|
+
((config?: CacheConfig) => {
|
|
286
|
+
if (!this.cacheStore) {
|
|
287
|
+
// Lazy-create so `.cache({ enabled: true })` works even when the
|
|
288
|
+
// constructor wasn't given cache config.
|
|
289
|
+
this.cacheStore = new CacheStore({
|
|
290
|
+
defaultTTL: config?.defaultTTL,
|
|
291
|
+
store: config?.store,
|
|
292
|
+
maxEntries: config?.maxEntries,
|
|
293
|
+
});
|
|
294
|
+
this.http.setCache(this.cacheStore, config?.enabled ?? true);
|
|
295
|
+
} else {
|
|
296
|
+
if (config?.enabled !== undefined) {
|
|
297
|
+
this.http.setCache(this.cacheStore, config.enabled);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return this;
|
|
301
|
+
}) as (config?: CacheConfig) => LazypockClient,
|
|
302
|
+
{
|
|
303
|
+
deleteByPrefix: (prefix: string) => this.cacheStore?.deleteByPrefix(prefix),
|
|
304
|
+
invalidate: (namespace: string) => this.cacheStore?.invalidate(namespace),
|
|
305
|
+
clear: () => {
|
|
306
|
+
this.cacheStore?.clear();
|
|
307
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
308
|
+
this.realtimeInvalidators.clear();
|
|
309
|
+
},
|
|
310
|
+
stats: () => (this.cacheStore ? this.cacheStore.stats() : null),
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Drop every cached entry (all collections / namespaces).
|
|
316
|
+
* Also disables realtime-driven invalidation subscriptions.
|
|
317
|
+
*/
|
|
318
|
+
clearCache(): this {
|
|
319
|
+
this.cacheStore?.clear();
|
|
320
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
321
|
+
this.realtimeInvalidators.clear();
|
|
322
|
+
return this;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Invalidate cached entries for a collection (or custom namespace).
|
|
327
|
+
* Runs automatically on mutations — call explicitly when data changed
|
|
328
|
+
* out-of-band (e.g. another client wrote to the same collection).
|
|
329
|
+
*/
|
|
330
|
+
invalidateCache(namespace: string): this {
|
|
331
|
+
this.cacheStore?.invalidate(namespace);
|
|
332
|
+
return this;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Cache hit/miss/entry statistics.
|
|
337
|
+
* Returns null when caching was never configured.
|
|
338
|
+
*/
|
|
339
|
+
cacheStats(): { hits: number; misses: number; entries: number } | null {
|
|
340
|
+
return this.cacheStore ? this.cacheStore.stats() : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Subscribe a collection's cache to realtime invalidation: any inbound
|
|
345
|
+
* create/update/delete event for the collection clears its cached entries.
|
|
346
|
+
* Returns an unsubscribe function.
|
|
347
|
+
*/
|
|
348
|
+
invalidateCacheOnRealtime(collectionName: string): () => void {
|
|
349
|
+
if (!this.cacheStore) {
|
|
350
|
+
// ensure a store exists so invalidation has somewhere to go
|
|
351
|
+
this.cache({ enabled: false });
|
|
352
|
+
}
|
|
353
|
+
const existing = this.realtimeInvalidators.get(collectionName);
|
|
354
|
+
if (existing) return existing;
|
|
355
|
+
const unsub = this.collection(collectionName).subscribe(() => {
|
|
356
|
+
this.cacheStore?.invalidate(collectionName);
|
|
357
|
+
});
|
|
358
|
+
this.realtimeInvalidators.set(collectionName, unsub);
|
|
359
|
+
return unsub;
|
|
360
|
+
}
|
|
361
|
+
|
|
218
362
|
// ── Auth ──
|
|
219
363
|
|
|
220
364
|
/** Check whether any superuser exists (for login vs setup screen routing). */
|
package/src/types.ts
CHANGED
|
@@ -65,6 +65,18 @@ export interface RequestOptions {
|
|
|
65
65
|
signal?: AbortSignal;
|
|
66
66
|
/** Custom fetch implementation (for RN or test mocking) */
|
|
67
67
|
fetch?: typeof globalThis.fetch;
|
|
68
|
+
/**
|
|
69
|
+
* Cache control for this request (see {@link CacheRequestOptions}).
|
|
70
|
+
* Resolved against the client's global cache config when unset.
|
|
71
|
+
*/
|
|
72
|
+
cache?: boolean | number | { ttl?: number; key?: string };
|
|
73
|
+
/** Alias of `cache: <ms>` — cache this GET for `ttl` milliseconds. */
|
|
74
|
+
ttl?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Extra cache namespaces to invalidate when this mutation succeeds.
|
|
77
|
+
* The current collection is always invalidated automatically.
|
|
78
|
+
*/
|
|
79
|
+
invalidate?: string[];
|
|
68
80
|
/**
|
|
69
81
|
* Request identifier used by the auto-cancellation mechanism.
|
|
70
82
|
*
|
|
@@ -87,6 +99,17 @@ export interface RequestOptions {
|
|
|
87
99
|
* Alias of `requestKey` (PocketBase `$cancelKey` compat).
|
|
88
100
|
*/
|
|
89
101
|
cancelKey?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Coalesce concurrent identical requests (same `requestKey`) onto a single
|
|
104
|
+
* in-flight promise instead of aborting the earlier one.
|
|
105
|
+
*
|
|
106
|
+
* When enabled, a request arriving while another with the same key is still
|
|
107
|
+
* pending awaits the same result — no duplicate network request, and the
|
|
108
|
+
* caller of the first request never sees an abort rejection.
|
|
109
|
+
*
|
|
110
|
+
* @default false (auto-cancellation aborts the earlier duplicate)
|
|
111
|
+
*/
|
|
112
|
+
singleFlight?: boolean;
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
export class ApiError extends Error {
|