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/dist/index.cjs
CHANGED
|
@@ -22,6 +22,7 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
ApiError: () => ApiError,
|
|
24
24
|
AuthStore: () => AuthStore,
|
|
25
|
+
CacheStore: () => CacheStore,
|
|
25
26
|
CollectionService: () => CollectionService,
|
|
26
27
|
CollectionsService: () => CollectionsService,
|
|
27
28
|
FilesService: () => FilesService,
|
|
@@ -37,6 +38,7 @@ __export(index_exports, {
|
|
|
37
38
|
getFileUrl: () => getFileUrl,
|
|
38
39
|
getScaleUrl: () => getScaleUrl,
|
|
39
40
|
getThumbUrl: () => getThumbUrl,
|
|
41
|
+
resolveCacheDirective: () => resolveCacheDirective,
|
|
40
42
|
schemaFieldType: () => schemaFieldType,
|
|
41
43
|
wsUrlFromBaseUrl: () => wsUrlFromBaseUrl
|
|
42
44
|
});
|
|
@@ -53,6 +55,201 @@ var ApiError = class extends Error {
|
|
|
53
55
|
}
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
// src/cache.ts
|
|
59
|
+
var CacheStore = class {
|
|
60
|
+
constructor(config = {}) {
|
|
61
|
+
this.memory = /* @__PURE__ */ new Map();
|
|
62
|
+
this.hits = 0;
|
|
63
|
+
this.misses = 0;
|
|
64
|
+
this.namespaceEntries = /* @__PURE__ */ new Map();
|
|
65
|
+
/** Key → set of prefix tags registered for that key (e.g. `getList:posts`). */
|
|
66
|
+
this.prefixEntries = /* @__PURE__ */ new Map();
|
|
67
|
+
this.ttl = config.defaultTTL ?? 6e4;
|
|
68
|
+
this.persistence = config.store;
|
|
69
|
+
this.maxEntries = config.maxEntries ?? 500;
|
|
70
|
+
}
|
|
71
|
+
/** Resolve the effective TTL: request override → global default. */
|
|
72
|
+
resolveTTL(ttl) {
|
|
73
|
+
return ttl && ttl > 0 ? ttl : this.ttl;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read a cached value. Fast sync path (memory) with async persistence
|
|
77
|
+
* fallback for adapters whose `get` returns a Promise.
|
|
78
|
+
* @param key Cache key (e.g. `"GET /posts?page=1"`).
|
|
79
|
+
* @returns The cached value, or undefined when absent/expired (the hit is
|
|
80
|
+
* cleared on expiry so a stale value is never served).
|
|
81
|
+
*/
|
|
82
|
+
async get(key) {
|
|
83
|
+
const mem = this.memory.get(key);
|
|
84
|
+
if (mem !== void 0) {
|
|
85
|
+
if (Date.now() > mem.expiresAt) {
|
|
86
|
+
this.delete(key);
|
|
87
|
+
this.misses++;
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
this.memory.delete(key);
|
|
91
|
+
this.memory.set(key, mem);
|
|
92
|
+
this.hits++;
|
|
93
|
+
return mem.value;
|
|
94
|
+
}
|
|
95
|
+
if (this.persistence) {
|
|
96
|
+
const entry = await this.readPersisted(key);
|
|
97
|
+
if (entry) {
|
|
98
|
+
if (Date.now() > entry.expiresAt) {
|
|
99
|
+
this.delete(key);
|
|
100
|
+
this.misses++;
|
|
101
|
+
return void 0;
|
|
102
|
+
}
|
|
103
|
+
this.hits++;
|
|
104
|
+
return entry.value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
this.misses++;
|
|
108
|
+
return void 0;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Store a value.
|
|
112
|
+
* @param key Cache key.
|
|
113
|
+
* @param value The response payload.
|
|
114
|
+
* @param ttlOverride Optional TTL override (ms).
|
|
115
|
+
* @param namespace Optional namespace for group invalidation.
|
|
116
|
+
*/
|
|
117
|
+
set(key, value, ttlOverride, namespace, tags) {
|
|
118
|
+
const expiresAt = Date.now() + this.resolveTTL(ttlOverride);
|
|
119
|
+
const entry = { value, expiresAt, namespace, tags };
|
|
120
|
+
this.memory.set(key, entry);
|
|
121
|
+
if (this.memory.size > this.maxEntries) {
|
|
122
|
+
const oldest = this.memory.keys().next().value;
|
|
123
|
+
if (oldest !== void 0) this.delete(oldest);
|
|
124
|
+
}
|
|
125
|
+
if (namespace) {
|
|
126
|
+
let keys = this.namespaceEntries.get(namespace);
|
|
127
|
+
if (!keys) {
|
|
128
|
+
keys = /* @__PURE__ */ new Set();
|
|
129
|
+
this.namespaceEntries.set(namespace, keys);
|
|
130
|
+
}
|
|
131
|
+
keys.add(key);
|
|
132
|
+
}
|
|
133
|
+
for (const tag of tags ?? []) {
|
|
134
|
+
let keys = this.prefixEntries.get(tag);
|
|
135
|
+
if (!keys) {
|
|
136
|
+
keys = /* @__PURE__ */ new Set();
|
|
137
|
+
this.prefixEntries.set(tag, keys);
|
|
138
|
+
}
|
|
139
|
+
keys.add(key);
|
|
140
|
+
}
|
|
141
|
+
if (this.persistence) {
|
|
142
|
+
void this.persistence.set(this.persistKey(key), JSON.stringify(entry));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Invalidate entries belonging to a namespace (e.g. a collection name).
|
|
147
|
+
* Also clears the namespace index entry.
|
|
148
|
+
*/
|
|
149
|
+
invalidate(namespace) {
|
|
150
|
+
const keys = Array.from(this.namespaceEntries.get(namespace) ?? []);
|
|
151
|
+
for (const key of keys) this.delete(key);
|
|
152
|
+
this.namespaceEntries.delete(namespace);
|
|
153
|
+
}
|
|
154
|
+
/** Remove a single key. */
|
|
155
|
+
delete(key) {
|
|
156
|
+
const entry = this.memory.get(key);
|
|
157
|
+
if (entry?.namespace) {
|
|
158
|
+
const set = this.namespaceEntries.get(entry.namespace);
|
|
159
|
+
if (set) {
|
|
160
|
+
set.delete(key);
|
|
161
|
+
if (set.size === 0) this.namespaceEntries.delete(entry.namespace);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const tag of entry?.tags ?? []) {
|
|
165
|
+
const set = this.prefixEntries.get(tag);
|
|
166
|
+
if (set) {
|
|
167
|
+
set.delete(key);
|
|
168
|
+
if (set.size === 0) this.prefixEntries.delete(tag);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
this.memory.delete(key);
|
|
172
|
+
if (this.persistence) {
|
|
173
|
+
void this.persistence.remove(this.persistKey(key));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Delete every entry whose key starts with `prefix`.
|
|
178
|
+
*
|
|
179
|
+
* Useful for fine-grained invalidation, e.g.:
|
|
180
|
+
* ```ts
|
|
181
|
+
* client.cache.deleteByPrefix('getList:posts'); // delete all getList cache
|
|
182
|
+
* client.cache.deleteByPrefix('getOne:posts'); // delete all getOne cache
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
deleteByPrefix(prefix) {
|
|
186
|
+
if (!prefix) return;
|
|
187
|
+
const tagged = this.prefixEntries.get(prefix);
|
|
188
|
+
if (tagged) {
|
|
189
|
+
for (const key of Array.from(tagged)) this.delete(key);
|
|
190
|
+
this.prefixEntries.delete(prefix);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
for (const key of Array.from(this.memory.keys())) {
|
|
194
|
+
if (key.startsWith(prefix)) this.delete(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Drop every cached entry (memory + persistence). */
|
|
198
|
+
clear() {
|
|
199
|
+
this.memory.clear();
|
|
200
|
+
this.namespaceEntries.clear();
|
|
201
|
+
this.prefixEntries.clear();
|
|
202
|
+
}
|
|
203
|
+
/** Cache hit/miss/entry statistics. */
|
|
204
|
+
stats() {
|
|
205
|
+
return { hits: this.hits, misses: this.misses, entries: this.memory.size };
|
|
206
|
+
}
|
|
207
|
+
persistKey(key) {
|
|
208
|
+
return "lazypock:cache:" + key;
|
|
209
|
+
}
|
|
210
|
+
async readPersisted(key) {
|
|
211
|
+
if (!this.persistence) return void 0;
|
|
212
|
+
const raw = await this.persistence.get(this.persistKey(key));
|
|
213
|
+
if (raw == null) return void 0;
|
|
214
|
+
try {
|
|
215
|
+
const entry = JSON.parse(raw);
|
|
216
|
+
this.memory.set(key, entry);
|
|
217
|
+
if (entry.namespace) {
|
|
218
|
+
let keys = this.namespaceEntries.get(entry.namespace);
|
|
219
|
+
if (!keys) {
|
|
220
|
+
keys = /* @__PURE__ */ new Set();
|
|
221
|
+
this.namespaceEntries.set(entry.namespace, keys);
|
|
222
|
+
}
|
|
223
|
+
keys.add(key);
|
|
224
|
+
}
|
|
225
|
+
for (const tag of entry.tags ?? []) {
|
|
226
|
+
let keys = this.prefixEntries.get(tag);
|
|
227
|
+
if (!keys) {
|
|
228
|
+
keys = /* @__PURE__ */ new Set();
|
|
229
|
+
this.prefixEntries.set(tag, keys);
|
|
230
|
+
}
|
|
231
|
+
keys.add(key);
|
|
232
|
+
}
|
|
233
|
+
return entry;
|
|
234
|
+
} catch {
|
|
235
|
+
void this.persistence.remove(this.persistKey(key));
|
|
236
|
+
return void 0;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
function resolveCacheDirective(opts) {
|
|
241
|
+
if (!opts) return null;
|
|
242
|
+
if (typeof opts.ttl === "number" && opts.ttl > 0) {
|
|
243
|
+
return { enabled: true, ttl: opts.ttl };
|
|
244
|
+
}
|
|
245
|
+
const c = opts.cache;
|
|
246
|
+
if (c === void 0) return null;
|
|
247
|
+
if (c === true) return { enabled: true };
|
|
248
|
+
if (c === false) return { enabled: false };
|
|
249
|
+
if (typeof c === "number") return { enabled: true, ttl: c > 0 ? c : void 0 };
|
|
250
|
+
return { enabled: true, ttl: c.ttl, key: c.key };
|
|
251
|
+
}
|
|
252
|
+
|
|
56
253
|
// src/http.ts
|
|
57
254
|
function isAbortError(err) {
|
|
58
255
|
return err instanceof Error && (err.name === "AbortError" || err.message === "Aborted");
|
|
@@ -63,18 +260,39 @@ var HttpClient = class {
|
|
|
63
260
|
* @param authStore The auth store providing the token for Authorization headers.
|
|
64
261
|
*/
|
|
65
262
|
constructor(baseUrl, authStore) {
|
|
263
|
+
/** Master switch resolved from CacheConfig.enabled. */
|
|
264
|
+
this.cacheEnabled = false;
|
|
66
265
|
/**
|
|
67
266
|
* Abort controllers for in-flight requests, keyed by their cancellation key
|
|
68
267
|
* (default `METHOD path`). A new request with the same key aborts the
|
|
69
268
|
* previous one — PocketBase-style auto-cancellation of duplicated requests.
|
|
70
269
|
*/
|
|
71
270
|
this.cancelControllers = {};
|
|
271
|
+
/**
|
|
272
|
+
* In-flight request promises, keyed by cancellation key. When auto-cancellation
|
|
273
|
+
* would abort a pending duplicate, the newer request instead awaits the same
|
|
274
|
+
* promise — single-flight coalescing (no duplicate network request, no
|
|
275
|
+
* spurious abort rejection for the caller).
|
|
276
|
+
*/
|
|
277
|
+
this.inflight = {};
|
|
72
278
|
/** Global toggle for the auto-cancellation behaviour (default: on). */
|
|
73
279
|
this.enableAutoCancellation = true;
|
|
74
280
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
75
281
|
this.authStore = authStore;
|
|
76
282
|
this.defaultFetch = globalThis.fetch.bind(globalThis);
|
|
77
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Attach a cache store + master switch.
|
|
286
|
+
* Called by the client constructor when cache config is present.
|
|
287
|
+
*/
|
|
288
|
+
setCache(cache, enabled) {
|
|
289
|
+
this.cache = cache;
|
|
290
|
+
this.cacheEnabled = enabled;
|
|
291
|
+
}
|
|
292
|
+
/** Whether the global cache flag is on (requests opt in/out individually too). */
|
|
293
|
+
get cacheIsEnabled() {
|
|
294
|
+
return this.cacheEnabled;
|
|
295
|
+
}
|
|
78
296
|
async refreshAuth() {
|
|
79
297
|
const collection = this.authStore.collectionName;
|
|
80
298
|
if (!collection) return null;
|
|
@@ -151,12 +369,33 @@ var HttpClient = class {
|
|
|
151
369
|
* @throws {ApiError} On non-2xx responses or when the request is aborted
|
|
152
370
|
* (aborted requests throw an `ApiError` with `isAbort === true`).
|
|
153
371
|
*/
|
|
372
|
+
/** Invalidate a namespace (collection name). No-op when cache is off. */
|
|
373
|
+
invalidateCache(namespace) {
|
|
374
|
+
this.cache?.invalidate(namespace);
|
|
375
|
+
}
|
|
376
|
+
/** Current cache statistics (hits/misses/entries), or null when disabled. */
|
|
377
|
+
cacheStats() {
|
|
378
|
+
return this.cache ? this.cache.stats() : null;
|
|
379
|
+
}
|
|
154
380
|
async request(method, path, body, options) {
|
|
155
381
|
if (this.authStore.isExpired && this.authStore.collectionName) {
|
|
156
382
|
await this.refreshAuth();
|
|
157
383
|
}
|
|
384
|
+
const cacheDirective = resolveCacheDirective(options);
|
|
385
|
+
const wantCache = cacheDirective !== null ? cacheDirective.enabled : this.cacheEnabled;
|
|
386
|
+
const cacheKey = method === "GET" && this.cache && wantCache ? this.cacheKeyFor(method, path, options?.params) : null;
|
|
387
|
+
if (cacheKey !== null) {
|
|
388
|
+
const hit = await this.cache?.get(cacheKey);
|
|
389
|
+
if (hit !== void 0) return hit;
|
|
390
|
+
}
|
|
158
391
|
let requestKey = options?.requestKey === void 0 ? options?.cancelKey ?? `${method} ${path}` : options.requestKey;
|
|
159
392
|
if (options?.autoCancel === false) requestKey = null;
|
|
393
|
+
if (options?.singleFlight && requestKey !== null) {
|
|
394
|
+
const pending = this.inflight[requestKey];
|
|
395
|
+
if (pending !== void 0) {
|
|
396
|
+
return pending;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
160
399
|
let controller = null;
|
|
161
400
|
const externalSignal = options?.signal;
|
|
162
401
|
if (requestKey !== null) {
|
|
@@ -174,6 +413,38 @@ var HttpClient = class {
|
|
|
174
413
|
}
|
|
175
414
|
}
|
|
176
415
|
const signal = controller?.signal ?? externalSignal;
|
|
416
|
+
const perform = async () => {
|
|
417
|
+
try {
|
|
418
|
+
return await this.doRequest(
|
|
419
|
+
method,
|
|
420
|
+
path,
|
|
421
|
+
body,
|
|
422
|
+
options,
|
|
423
|
+
signal,
|
|
424
|
+
requestKey,
|
|
425
|
+
controller,
|
|
426
|
+
cacheKey,
|
|
427
|
+
cacheDirective
|
|
428
|
+
);
|
|
429
|
+
} finally {
|
|
430
|
+
if (requestKey !== null) {
|
|
431
|
+
if (this.inflight[requestKey] === promise) {
|
|
432
|
+
delete this.inflight[requestKey];
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const promise = perform();
|
|
438
|
+
if (requestKey !== null) {
|
|
439
|
+
this.inflight[requestKey] = promise;
|
|
440
|
+
}
|
|
441
|
+
return promise;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Execute the actual HTTP request (fetch + parse + cache). Called by {@link request}
|
|
445
|
+
* as the inner in-flight unit so single-flight callers can reuse the promise.
|
|
446
|
+
*/
|
|
447
|
+
async doRequest(method, path, body, options, signal, requestKey, controller, cacheKey, cacheDirective) {
|
|
177
448
|
let url = this.baseUrl + path;
|
|
178
449
|
if (options?.params) {
|
|
179
450
|
const qs = new URLSearchParams(options.params).toString();
|
|
@@ -238,8 +509,63 @@ var HttpClient = class {
|
|
|
238
509
|
res.status
|
|
239
510
|
);
|
|
240
511
|
}
|
|
512
|
+
if (cacheKey !== null && this.cache) {
|
|
513
|
+
const namespace = this.namespaceFromPath(path);
|
|
514
|
+
const ttl = cacheDirective?.ttl;
|
|
515
|
+
const tags = this.cacheTagsFor(path, namespace);
|
|
516
|
+
this.cache.set(
|
|
517
|
+
cacheKey,
|
|
518
|
+
data,
|
|
519
|
+
ttl,
|
|
520
|
+
namespace ?? void 0,
|
|
521
|
+
tags
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (method !== "GET" && this.cache) {
|
|
525
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
526
|
+
const ns = this.namespaceFromPath(path);
|
|
527
|
+
if (ns) namespaces.add(ns);
|
|
528
|
+
for (const extra of options?.invalidate ?? []) {
|
|
529
|
+
if (extra) namespaces.add(extra);
|
|
530
|
+
}
|
|
531
|
+
for (const nsName of namespaces) this.cache.invalidate(nsName);
|
|
532
|
+
}
|
|
241
533
|
return data;
|
|
242
534
|
}
|
|
535
|
+
// ── Cache key/namespace helpers ──
|
|
536
|
+
/** Build a token-scoped cache key: `METHOD path|token-hash|params`. */
|
|
537
|
+
cacheKeyFor(method, path, params) {
|
|
538
|
+
const token = this.authStore.token || "anon";
|
|
539
|
+
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
|
540
|
+
return `${method} ${path}${qs}|${token}`;
|
|
541
|
+
}
|
|
542
|
+
/** Best-effort namespace (collection name) from a REST path. */
|
|
543
|
+
namespaceFromPath(path) {
|
|
544
|
+
const clean = path.split("?")[0];
|
|
545
|
+
const parts = clean.split("/").filter(Boolean);
|
|
546
|
+
if (parts.length === 0) return void 0;
|
|
547
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
548
|
+
return parts[0];
|
|
549
|
+
}
|
|
550
|
+
return parts[0];
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Semantic prefix tags for `deleteByPrefix`, derived from the REST shape:
|
|
554
|
+
* - `/{collection}?...` → `getList:{collection}`
|
|
555
|
+
* - `/{collection}/{id}` → `getOne:{collection}`
|
|
556
|
+
* - `/collections?...` / `/collections/{id}` → `collections:getList` / `collections:getOne`
|
|
557
|
+
*/
|
|
558
|
+
cacheTagsFor(path, namespace) {
|
|
559
|
+
if (!namespace) return [];
|
|
560
|
+
const clean = path.split("?")[0];
|
|
561
|
+
const parts = clean.split("/").filter(Boolean);
|
|
562
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
563
|
+
const op2 = parts.length >= 2 ? "getOne" : "getList";
|
|
564
|
+
return [`${namespace}:${op2}`];
|
|
565
|
+
}
|
|
566
|
+
const op = parts.length >= 2 ? "getOne" : "getList";
|
|
567
|
+
return [`${op}:${namespace}`];
|
|
568
|
+
}
|
|
243
569
|
/**
|
|
244
570
|
* HTTP GET.
|
|
245
571
|
* @param path URL path.
|
|
@@ -415,6 +741,30 @@ var memoryStorage = {
|
|
|
415
741
|
};
|
|
416
742
|
|
|
417
743
|
// src/collection.ts
|
|
744
|
+
function stableStringify(value) {
|
|
745
|
+
const seen = /* @__PURE__ */ new Set();
|
|
746
|
+
const sort = (v) => {
|
|
747
|
+
if (Array.isArray(v)) return v.map(sort);
|
|
748
|
+
if (v && typeof v === "object") {
|
|
749
|
+
if (seen.has(v)) return "[Circular]";
|
|
750
|
+
seen.add(v);
|
|
751
|
+
const out = {};
|
|
752
|
+
for (const k of Object.keys(v).sort()) {
|
|
753
|
+
if (k === "requestKey" || k === "singleFlight" || k === "fetch" || k === "signal") {
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
out[k] = sort(v[k]);
|
|
757
|
+
}
|
|
758
|
+
return out;
|
|
759
|
+
}
|
|
760
|
+
return v;
|
|
761
|
+
};
|
|
762
|
+
try {
|
|
763
|
+
return JSON.stringify(sort(value));
|
|
764
|
+
} catch {
|
|
765
|
+
return String(value);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
418
768
|
function normalizeAction(event, rawAction) {
|
|
419
769
|
if (typeof rawAction === "string") {
|
|
420
770
|
const a = rawAction.toLowerCase();
|
|
@@ -446,19 +796,44 @@ var CollectionService = class {
|
|
|
446
796
|
* @param options Query params (`filter`, `sort`, `expand`, `fields`) + request options.
|
|
447
797
|
*/
|
|
448
798
|
getList(page = 1, perPage = 30, options) {
|
|
449
|
-
const {
|
|
799
|
+
const {
|
|
800
|
+
requestKey,
|
|
801
|
+
autoCancel,
|
|
802
|
+
cancelKey,
|
|
803
|
+
fetch,
|
|
804
|
+
headers,
|
|
805
|
+
signal,
|
|
806
|
+
cache,
|
|
807
|
+
ttl,
|
|
808
|
+
invalidate,
|
|
809
|
+
singleFlight,
|
|
810
|
+
params,
|
|
811
|
+
...queryParams
|
|
812
|
+
} = options ?? {};
|
|
450
813
|
const qs = new URLSearchParams(
|
|
451
814
|
Object.fromEntries(
|
|
452
815
|
Object.entries({
|
|
453
816
|
page: String(page),
|
|
454
817
|
perPage: String(perPage),
|
|
455
|
-
...
|
|
818
|
+
...queryParams
|
|
456
819
|
}).map(([k, v]) => [k, String(v)])
|
|
457
820
|
)
|
|
458
821
|
).toString();
|
|
459
822
|
return this.http.get(
|
|
460
823
|
"/" + this.encodeId(this.collectionName) + "?" + qs,
|
|
461
|
-
{
|
|
824
|
+
{
|
|
825
|
+
requestKey,
|
|
826
|
+
autoCancel,
|
|
827
|
+
cancelKey,
|
|
828
|
+
fetch,
|
|
829
|
+
headers,
|
|
830
|
+
signal,
|
|
831
|
+
cache,
|
|
832
|
+
ttl,
|
|
833
|
+
invalidate,
|
|
834
|
+
singleFlight,
|
|
835
|
+
params
|
|
836
|
+
}
|
|
462
837
|
);
|
|
463
838
|
}
|
|
464
839
|
/**
|
|
@@ -469,6 +844,7 @@ var CollectionService = class {
|
|
|
469
844
|
*/
|
|
470
845
|
async getFullList(options) {
|
|
471
846
|
const { batch = 1e3, ...rest } = options ?? {};
|
|
847
|
+
const effectiveKey = typeof rest.requestKey === "string" ? rest.requestKey : `getFullList:${this.collectionName}:${stableStringify(rest)}`;
|
|
472
848
|
const items = [];
|
|
473
849
|
let page = 1;
|
|
474
850
|
for (; ; ) {
|
|
@@ -476,9 +852,9 @@ var CollectionService = class {
|
|
|
476
852
|
page,
|
|
477
853
|
batch,
|
|
478
854
|
{
|
|
479
|
-
// disable auto-cancellation across pages — each page request is unique
|
|
480
855
|
...rest,
|
|
481
|
-
requestKey:
|
|
856
|
+
requestKey: effectiveKey,
|
|
857
|
+
singleFlight: true
|
|
482
858
|
}
|
|
483
859
|
);
|
|
484
860
|
if (!res || !res.items || res.items.length === 0) break;
|
|
@@ -1001,14 +1377,40 @@ var CollectionsService = class {
|
|
|
1001
1377
|
async getFullList(options) {
|
|
1002
1378
|
if (!this.http) return [];
|
|
1003
1379
|
const { batch = 1e3, ...rest } = options ?? {};
|
|
1380
|
+
const {
|
|
1381
|
+
requestKey: reqKey,
|
|
1382
|
+
singleFlight: _singleFlight,
|
|
1383
|
+
fetch: fetchFn,
|
|
1384
|
+
headers: hdrs,
|
|
1385
|
+
signal: sig,
|
|
1386
|
+
cache: cacheOpt,
|
|
1387
|
+
ttl: ttlOpt,
|
|
1388
|
+
invalidate: inval,
|
|
1389
|
+
params: passthroughParams,
|
|
1390
|
+
...queryParams
|
|
1391
|
+
} = rest;
|
|
1392
|
+
const effectiveKey = typeof reqKey === "string" ? reqKey : `getFullList:collections:${stableStringify(rest)}`;
|
|
1004
1393
|
const items = [];
|
|
1005
1394
|
let page = 1;
|
|
1006
1395
|
for (; ; ) {
|
|
1007
|
-
const res = await this.getList(
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1396
|
+
const res = await this.getList(
|
|
1397
|
+
{
|
|
1398
|
+
...queryParams,
|
|
1399
|
+
...passthroughParams ?? {},
|
|
1400
|
+
page,
|
|
1401
|
+
perPage: batch
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
requestKey: effectiveKey,
|
|
1405
|
+
singleFlight: true,
|
|
1406
|
+
...fetchFn ? { fetch: fetchFn } : {},
|
|
1407
|
+
...hdrs ? { headers: hdrs } : {},
|
|
1408
|
+
...sig ? { signal: sig } : {},
|
|
1409
|
+
...cacheOpt !== void 0 ? { cache: cacheOpt } : {},
|
|
1410
|
+
...ttlOpt !== void 0 ? { ttl: ttlOpt } : {},
|
|
1411
|
+
...inval ? { invalidate: inval } : {}
|
|
1412
|
+
}
|
|
1413
|
+
);
|
|
1012
1414
|
if (!res || !res.items || res.items.length === 0) break;
|
|
1013
1415
|
items.push(...res.items);
|
|
1014
1416
|
if (page >= (res.totalPages ?? page)) break;
|
|
@@ -1291,6 +1693,50 @@ var LazypockClient = class {
|
|
|
1291
1693
|
*/
|
|
1292
1694
|
constructor(options) {
|
|
1293
1695
|
this.collectionCache = /* @__PURE__ */ new Map();
|
|
1696
|
+
/** Namespace → realtime unsubscribe; used for realtime-driven invalidation. */
|
|
1697
|
+
this.realtimeInvalidators = /* @__PURE__ */ new Map();
|
|
1698
|
+
// ── Query cache (opt-in by default; opt-out per request) ──
|
|
1699
|
+
/**
|
|
1700
|
+
* Configure the query cache at runtime (also a namespace for cache
|
|
1701
|
+
* management methods).
|
|
1702
|
+
*
|
|
1703
|
+
* ```ts
|
|
1704
|
+
* client.cache({ enabled: true, defaultTTL: 30_000 });
|
|
1705
|
+
* client.cache.deleteByPrefix('getList:posts'); // all list caches for posts
|
|
1706
|
+
* client.cache.deleteByPrefix('getOne:posts'); // all one-record caches
|
|
1707
|
+
* ```
|
|
1708
|
+
*
|
|
1709
|
+
* When enabled, GET requests cache their payload; mutations invalidate the
|
|
1710
|
+
* affected collection automatically. Individual requests can opt out with
|
|
1711
|
+
* `{ cache: false }` or override the TTL with `{ ttl: ms }`.
|
|
1712
|
+
*/
|
|
1713
|
+
this.cache = Object.assign(
|
|
1714
|
+
((config) => {
|
|
1715
|
+
if (!this.cacheStore) {
|
|
1716
|
+
this.cacheStore = new CacheStore({
|
|
1717
|
+
defaultTTL: config?.defaultTTL,
|
|
1718
|
+
store: config?.store,
|
|
1719
|
+
maxEntries: config?.maxEntries
|
|
1720
|
+
});
|
|
1721
|
+
this.http.setCache(this.cacheStore, config?.enabled ?? true);
|
|
1722
|
+
} else {
|
|
1723
|
+
if (config?.enabled !== void 0) {
|
|
1724
|
+
this.http.setCache(this.cacheStore, config.enabled);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return this;
|
|
1728
|
+
}),
|
|
1729
|
+
{
|
|
1730
|
+
deleteByPrefix: (prefix) => this.cacheStore?.deleteByPrefix(prefix),
|
|
1731
|
+
invalidate: (namespace) => this.cacheStore?.invalidate(namespace),
|
|
1732
|
+
clear: () => {
|
|
1733
|
+
this.cacheStore?.clear();
|
|
1734
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
1735
|
+
this.realtimeInvalidators.clear();
|
|
1736
|
+
},
|
|
1737
|
+
stats: () => this.cacheStore ? this.cacheStore.stats() : null
|
|
1738
|
+
}
|
|
1739
|
+
);
|
|
1294
1740
|
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
1295
1741
|
this.authStore = options.authStore ?? new AuthStore(options.storage ?? memoryStorage);
|
|
1296
1742
|
this.http = new HttpClient(baseUrl, this.authStore);
|
|
@@ -1300,6 +1746,14 @@ var LazypockClient = class {
|
|
|
1300
1746
|
}
|
|
1301
1747
|
this.files = new FilesService(this.http);
|
|
1302
1748
|
this.collections = new CollectionsService(this.http, this.realtime);
|
|
1749
|
+
if (options.cache) {
|
|
1750
|
+
this.cacheStore = new CacheStore({
|
|
1751
|
+
defaultTTL: options.cache.defaultTTL,
|
|
1752
|
+
store: options.cache.store,
|
|
1753
|
+
maxEntries: options.cache.maxEntries
|
|
1754
|
+
});
|
|
1755
|
+
this.http.setCache(this.cacheStore, options.cache.enabled ?? false);
|
|
1756
|
+
}
|
|
1303
1757
|
if (options.types?.schemas) {
|
|
1304
1758
|
this.schemaByName = new Map(
|
|
1305
1759
|
options.types.schemas.map((s) => [s.name, s])
|
|
@@ -1380,6 +1834,49 @@ var LazypockClient = class {
|
|
|
1380
1834
|
this.http.cancelAllRequests();
|
|
1381
1835
|
return this;
|
|
1382
1836
|
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Drop every cached entry (all collections / namespaces).
|
|
1839
|
+
* Also disables realtime-driven invalidation subscriptions.
|
|
1840
|
+
*/
|
|
1841
|
+
clearCache() {
|
|
1842
|
+
this.cacheStore?.clear();
|
|
1843
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
1844
|
+
this.realtimeInvalidators.clear();
|
|
1845
|
+
return this;
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* Invalidate cached entries for a collection (or custom namespace).
|
|
1849
|
+
* Runs automatically on mutations — call explicitly when data changed
|
|
1850
|
+
* out-of-band (e.g. another client wrote to the same collection).
|
|
1851
|
+
*/
|
|
1852
|
+
invalidateCache(namespace) {
|
|
1853
|
+
this.cacheStore?.invalidate(namespace);
|
|
1854
|
+
return this;
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Cache hit/miss/entry statistics.
|
|
1858
|
+
* Returns null when caching was never configured.
|
|
1859
|
+
*/
|
|
1860
|
+
cacheStats() {
|
|
1861
|
+
return this.cacheStore ? this.cacheStore.stats() : null;
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Subscribe a collection's cache to realtime invalidation: any inbound
|
|
1865
|
+
* create/update/delete event for the collection clears its cached entries.
|
|
1866
|
+
* Returns an unsubscribe function.
|
|
1867
|
+
*/
|
|
1868
|
+
invalidateCacheOnRealtime(collectionName) {
|
|
1869
|
+
if (!this.cacheStore) {
|
|
1870
|
+
this.cache({ enabled: false });
|
|
1871
|
+
}
|
|
1872
|
+
const existing = this.realtimeInvalidators.get(collectionName);
|
|
1873
|
+
if (existing) return existing;
|
|
1874
|
+
const unsub = this.collection(collectionName).subscribe(() => {
|
|
1875
|
+
this.cacheStore?.invalidate(collectionName);
|
|
1876
|
+
});
|
|
1877
|
+
this.realtimeInvalidators.set(collectionName, unsub);
|
|
1878
|
+
return unsub;
|
|
1879
|
+
}
|
|
1383
1880
|
// ── Auth ──
|
|
1384
1881
|
/** Check whether any superuser exists (for login vs setup screen routing). */
|
|
1385
1882
|
async checkSuperuser() {
|
|
@@ -1517,6 +2014,7 @@ function createClient(options) {
|
|
|
1517
2014
|
0 && (module.exports = {
|
|
1518
2015
|
ApiError,
|
|
1519
2016
|
AuthStore,
|
|
2017
|
+
CacheStore,
|
|
1520
2018
|
CollectionService,
|
|
1521
2019
|
CollectionsService,
|
|
1522
2020
|
FilesService,
|
|
@@ -1532,6 +2030,7 @@ function createClient(options) {
|
|
|
1532
2030
|
getFileUrl,
|
|
1533
2031
|
getScaleUrl,
|
|
1534
2032
|
getThumbUrl,
|
|
2033
|
+
resolveCacheDirective,
|
|
1535
2034
|
schemaFieldType,
|
|
1536
2035
|
wsUrlFromBaseUrl
|
|
1537
2036
|
});
|