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.js
CHANGED
|
@@ -17,6 +17,201 @@ var ApiError = class extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
// src/cache.ts
|
|
21
|
+
var CacheStore = class {
|
|
22
|
+
constructor(config = {}) {
|
|
23
|
+
this.memory = /* @__PURE__ */ new Map();
|
|
24
|
+
this.hits = 0;
|
|
25
|
+
this.misses = 0;
|
|
26
|
+
this.namespaceEntries = /* @__PURE__ */ new Map();
|
|
27
|
+
/** Key → set of prefix tags registered for that key (e.g. `getList:posts`). */
|
|
28
|
+
this.prefixEntries = /* @__PURE__ */ new Map();
|
|
29
|
+
this.ttl = config.defaultTTL ?? 6e4;
|
|
30
|
+
this.persistence = config.store;
|
|
31
|
+
this.maxEntries = config.maxEntries ?? 500;
|
|
32
|
+
}
|
|
33
|
+
/** Resolve the effective TTL: request override → global default. */
|
|
34
|
+
resolveTTL(ttl) {
|
|
35
|
+
return ttl && ttl > 0 ? ttl : this.ttl;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read a cached value. Fast sync path (memory) with async persistence
|
|
39
|
+
* fallback for adapters whose `get` returns a Promise.
|
|
40
|
+
* @param key Cache key (e.g. `"GET /posts?page=1"`).
|
|
41
|
+
* @returns The cached value, or undefined when absent/expired (the hit is
|
|
42
|
+
* cleared on expiry so a stale value is never served).
|
|
43
|
+
*/
|
|
44
|
+
async get(key) {
|
|
45
|
+
const mem = this.memory.get(key);
|
|
46
|
+
if (mem !== void 0) {
|
|
47
|
+
if (Date.now() > mem.expiresAt) {
|
|
48
|
+
this.delete(key);
|
|
49
|
+
this.misses++;
|
|
50
|
+
return void 0;
|
|
51
|
+
}
|
|
52
|
+
this.memory.delete(key);
|
|
53
|
+
this.memory.set(key, mem);
|
|
54
|
+
this.hits++;
|
|
55
|
+
return mem.value;
|
|
56
|
+
}
|
|
57
|
+
if (this.persistence) {
|
|
58
|
+
const entry = await this.readPersisted(key);
|
|
59
|
+
if (entry) {
|
|
60
|
+
if (Date.now() > entry.expiresAt) {
|
|
61
|
+
this.delete(key);
|
|
62
|
+
this.misses++;
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
this.hits++;
|
|
66
|
+
return entry.value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
this.misses++;
|
|
70
|
+
return void 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Store a value.
|
|
74
|
+
* @param key Cache key.
|
|
75
|
+
* @param value The response payload.
|
|
76
|
+
* @param ttlOverride Optional TTL override (ms).
|
|
77
|
+
* @param namespace Optional namespace for group invalidation.
|
|
78
|
+
*/
|
|
79
|
+
set(key, value, ttlOverride, namespace, tags) {
|
|
80
|
+
const expiresAt = Date.now() + this.resolveTTL(ttlOverride);
|
|
81
|
+
const entry = { value, expiresAt, namespace, tags };
|
|
82
|
+
this.memory.set(key, entry);
|
|
83
|
+
if (this.memory.size > this.maxEntries) {
|
|
84
|
+
const oldest = this.memory.keys().next().value;
|
|
85
|
+
if (oldest !== void 0) this.delete(oldest);
|
|
86
|
+
}
|
|
87
|
+
if (namespace) {
|
|
88
|
+
let keys = this.namespaceEntries.get(namespace);
|
|
89
|
+
if (!keys) {
|
|
90
|
+
keys = /* @__PURE__ */ new Set();
|
|
91
|
+
this.namespaceEntries.set(namespace, keys);
|
|
92
|
+
}
|
|
93
|
+
keys.add(key);
|
|
94
|
+
}
|
|
95
|
+
for (const tag of tags ?? []) {
|
|
96
|
+
let keys = this.prefixEntries.get(tag);
|
|
97
|
+
if (!keys) {
|
|
98
|
+
keys = /* @__PURE__ */ new Set();
|
|
99
|
+
this.prefixEntries.set(tag, keys);
|
|
100
|
+
}
|
|
101
|
+
keys.add(key);
|
|
102
|
+
}
|
|
103
|
+
if (this.persistence) {
|
|
104
|
+
void this.persistence.set(this.persistKey(key), JSON.stringify(entry));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Invalidate entries belonging to a namespace (e.g. a collection name).
|
|
109
|
+
* Also clears the namespace index entry.
|
|
110
|
+
*/
|
|
111
|
+
invalidate(namespace) {
|
|
112
|
+
const keys = Array.from(this.namespaceEntries.get(namespace) ?? []);
|
|
113
|
+
for (const key of keys) this.delete(key);
|
|
114
|
+
this.namespaceEntries.delete(namespace);
|
|
115
|
+
}
|
|
116
|
+
/** Remove a single key. */
|
|
117
|
+
delete(key) {
|
|
118
|
+
const entry = this.memory.get(key);
|
|
119
|
+
if (entry?.namespace) {
|
|
120
|
+
const set = this.namespaceEntries.get(entry.namespace);
|
|
121
|
+
if (set) {
|
|
122
|
+
set.delete(key);
|
|
123
|
+
if (set.size === 0) this.namespaceEntries.delete(entry.namespace);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const tag of entry?.tags ?? []) {
|
|
127
|
+
const set = this.prefixEntries.get(tag);
|
|
128
|
+
if (set) {
|
|
129
|
+
set.delete(key);
|
|
130
|
+
if (set.size === 0) this.prefixEntries.delete(tag);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
this.memory.delete(key);
|
|
134
|
+
if (this.persistence) {
|
|
135
|
+
void this.persistence.remove(this.persistKey(key));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Delete every entry whose key starts with `prefix`.
|
|
140
|
+
*
|
|
141
|
+
* Useful for fine-grained invalidation, e.g.:
|
|
142
|
+
* ```ts
|
|
143
|
+
* client.cache.deleteByPrefix('getList:posts'); // delete all getList cache
|
|
144
|
+
* client.cache.deleteByPrefix('getOne:posts'); // delete all getOne cache
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
deleteByPrefix(prefix) {
|
|
148
|
+
if (!prefix) return;
|
|
149
|
+
const tagged = this.prefixEntries.get(prefix);
|
|
150
|
+
if (tagged) {
|
|
151
|
+
for (const key of Array.from(tagged)) this.delete(key);
|
|
152
|
+
this.prefixEntries.delete(prefix);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const key of Array.from(this.memory.keys())) {
|
|
156
|
+
if (key.startsWith(prefix)) this.delete(key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Drop every cached entry (memory + persistence). */
|
|
160
|
+
clear() {
|
|
161
|
+
this.memory.clear();
|
|
162
|
+
this.namespaceEntries.clear();
|
|
163
|
+
this.prefixEntries.clear();
|
|
164
|
+
}
|
|
165
|
+
/** Cache hit/miss/entry statistics. */
|
|
166
|
+
stats() {
|
|
167
|
+
return { hits: this.hits, misses: this.misses, entries: this.memory.size };
|
|
168
|
+
}
|
|
169
|
+
persistKey(key) {
|
|
170
|
+
return "lazypock:cache:" + key;
|
|
171
|
+
}
|
|
172
|
+
async readPersisted(key) {
|
|
173
|
+
if (!this.persistence) return void 0;
|
|
174
|
+
const raw = await this.persistence.get(this.persistKey(key));
|
|
175
|
+
if (raw == null) return void 0;
|
|
176
|
+
try {
|
|
177
|
+
const entry = JSON.parse(raw);
|
|
178
|
+
this.memory.set(key, entry);
|
|
179
|
+
if (entry.namespace) {
|
|
180
|
+
let keys = this.namespaceEntries.get(entry.namespace);
|
|
181
|
+
if (!keys) {
|
|
182
|
+
keys = /* @__PURE__ */ new Set();
|
|
183
|
+
this.namespaceEntries.set(entry.namespace, keys);
|
|
184
|
+
}
|
|
185
|
+
keys.add(key);
|
|
186
|
+
}
|
|
187
|
+
for (const tag of entry.tags ?? []) {
|
|
188
|
+
let keys = this.prefixEntries.get(tag);
|
|
189
|
+
if (!keys) {
|
|
190
|
+
keys = /* @__PURE__ */ new Set();
|
|
191
|
+
this.prefixEntries.set(tag, keys);
|
|
192
|
+
}
|
|
193
|
+
keys.add(key);
|
|
194
|
+
}
|
|
195
|
+
return entry;
|
|
196
|
+
} catch {
|
|
197
|
+
void this.persistence.remove(this.persistKey(key));
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
function resolveCacheDirective(opts) {
|
|
203
|
+
if (!opts) return null;
|
|
204
|
+
if (typeof opts.ttl === "number" && opts.ttl > 0) {
|
|
205
|
+
return { enabled: true, ttl: opts.ttl };
|
|
206
|
+
}
|
|
207
|
+
const c = opts.cache;
|
|
208
|
+
if (c === void 0) return null;
|
|
209
|
+
if (c === true) return { enabled: true };
|
|
210
|
+
if (c === false) return { enabled: false };
|
|
211
|
+
if (typeof c === "number") return { enabled: true, ttl: c > 0 ? c : void 0 };
|
|
212
|
+
return { enabled: true, ttl: c.ttl, key: c.key };
|
|
213
|
+
}
|
|
214
|
+
|
|
20
215
|
// src/http.ts
|
|
21
216
|
function isAbortError(err) {
|
|
22
217
|
return err instanceof Error && (err.name === "AbortError" || err.message === "Aborted");
|
|
@@ -27,18 +222,39 @@ var HttpClient = class {
|
|
|
27
222
|
* @param authStore The auth store providing the token for Authorization headers.
|
|
28
223
|
*/
|
|
29
224
|
constructor(baseUrl, authStore) {
|
|
225
|
+
/** Master switch resolved from CacheConfig.enabled. */
|
|
226
|
+
this.cacheEnabled = false;
|
|
30
227
|
/**
|
|
31
228
|
* Abort controllers for in-flight requests, keyed by their cancellation key
|
|
32
229
|
* (default `METHOD path`). A new request with the same key aborts the
|
|
33
230
|
* previous one — PocketBase-style auto-cancellation of duplicated requests.
|
|
34
231
|
*/
|
|
35
232
|
this.cancelControllers = {};
|
|
233
|
+
/**
|
|
234
|
+
* In-flight request promises, keyed by cancellation key. When auto-cancellation
|
|
235
|
+
* would abort a pending duplicate, the newer request instead awaits the same
|
|
236
|
+
* promise — single-flight coalescing (no duplicate network request, no
|
|
237
|
+
* spurious abort rejection for the caller).
|
|
238
|
+
*/
|
|
239
|
+
this.inflight = {};
|
|
36
240
|
/** Global toggle for the auto-cancellation behaviour (default: on). */
|
|
37
241
|
this.enableAutoCancellation = true;
|
|
38
242
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
39
243
|
this.authStore = authStore;
|
|
40
244
|
this.defaultFetch = globalThis.fetch.bind(globalThis);
|
|
41
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Attach a cache store + master switch.
|
|
248
|
+
* Called by the client constructor when cache config is present.
|
|
249
|
+
*/
|
|
250
|
+
setCache(cache, enabled) {
|
|
251
|
+
this.cache = cache;
|
|
252
|
+
this.cacheEnabled = enabled;
|
|
253
|
+
}
|
|
254
|
+
/** Whether the global cache flag is on (requests opt in/out individually too). */
|
|
255
|
+
get cacheIsEnabled() {
|
|
256
|
+
return this.cacheEnabled;
|
|
257
|
+
}
|
|
42
258
|
async refreshAuth() {
|
|
43
259
|
const collection = this.authStore.collectionName;
|
|
44
260
|
if (!collection) return null;
|
|
@@ -115,12 +331,33 @@ var HttpClient = class {
|
|
|
115
331
|
* @throws {ApiError} On non-2xx responses or when the request is aborted
|
|
116
332
|
* (aborted requests throw an `ApiError` with `isAbort === true`).
|
|
117
333
|
*/
|
|
334
|
+
/** Invalidate a namespace (collection name). No-op when cache is off. */
|
|
335
|
+
invalidateCache(namespace) {
|
|
336
|
+
this.cache?.invalidate(namespace);
|
|
337
|
+
}
|
|
338
|
+
/** Current cache statistics (hits/misses/entries), or null when disabled. */
|
|
339
|
+
cacheStats() {
|
|
340
|
+
return this.cache ? this.cache.stats() : null;
|
|
341
|
+
}
|
|
118
342
|
async request(method, path, body, options) {
|
|
119
343
|
if (this.authStore.isExpired && this.authStore.collectionName) {
|
|
120
344
|
await this.refreshAuth();
|
|
121
345
|
}
|
|
346
|
+
const cacheDirective = resolveCacheDirective(options);
|
|
347
|
+
const wantCache = cacheDirective !== null ? cacheDirective.enabled : this.cacheEnabled;
|
|
348
|
+
const cacheKey = method === "GET" && this.cache && wantCache ? this.cacheKeyFor(method, path, options?.params) : null;
|
|
349
|
+
if (cacheKey !== null) {
|
|
350
|
+
const hit = await this.cache?.get(cacheKey);
|
|
351
|
+
if (hit !== void 0) return hit;
|
|
352
|
+
}
|
|
122
353
|
let requestKey = options?.requestKey === void 0 ? options?.cancelKey ?? `${method} ${path}` : options.requestKey;
|
|
123
354
|
if (options?.autoCancel === false) requestKey = null;
|
|
355
|
+
if (options?.singleFlight && requestKey !== null) {
|
|
356
|
+
const pending = this.inflight[requestKey];
|
|
357
|
+
if (pending !== void 0) {
|
|
358
|
+
return pending;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
124
361
|
let controller = null;
|
|
125
362
|
const externalSignal = options?.signal;
|
|
126
363
|
if (requestKey !== null) {
|
|
@@ -138,6 +375,38 @@ var HttpClient = class {
|
|
|
138
375
|
}
|
|
139
376
|
}
|
|
140
377
|
const signal = controller?.signal ?? externalSignal;
|
|
378
|
+
const perform = async () => {
|
|
379
|
+
try {
|
|
380
|
+
return await this.doRequest(
|
|
381
|
+
method,
|
|
382
|
+
path,
|
|
383
|
+
body,
|
|
384
|
+
options,
|
|
385
|
+
signal,
|
|
386
|
+
requestKey,
|
|
387
|
+
controller,
|
|
388
|
+
cacheKey,
|
|
389
|
+
cacheDirective
|
|
390
|
+
);
|
|
391
|
+
} finally {
|
|
392
|
+
if (requestKey !== null) {
|
|
393
|
+
if (this.inflight[requestKey] === promise) {
|
|
394
|
+
delete this.inflight[requestKey];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
const promise = perform();
|
|
400
|
+
if (requestKey !== null) {
|
|
401
|
+
this.inflight[requestKey] = promise;
|
|
402
|
+
}
|
|
403
|
+
return promise;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Execute the actual HTTP request (fetch + parse + cache). Called by {@link request}
|
|
407
|
+
* as the inner in-flight unit so single-flight callers can reuse the promise.
|
|
408
|
+
*/
|
|
409
|
+
async doRequest(method, path, body, options, signal, requestKey, controller, cacheKey, cacheDirective) {
|
|
141
410
|
let url = this.baseUrl + path;
|
|
142
411
|
if (options?.params) {
|
|
143
412
|
const qs = new URLSearchParams(options.params).toString();
|
|
@@ -202,8 +471,63 @@ var HttpClient = class {
|
|
|
202
471
|
res.status
|
|
203
472
|
);
|
|
204
473
|
}
|
|
474
|
+
if (cacheKey !== null && this.cache) {
|
|
475
|
+
const namespace = this.namespaceFromPath(path);
|
|
476
|
+
const ttl = cacheDirective?.ttl;
|
|
477
|
+
const tags = this.cacheTagsFor(path, namespace);
|
|
478
|
+
this.cache.set(
|
|
479
|
+
cacheKey,
|
|
480
|
+
data,
|
|
481
|
+
ttl,
|
|
482
|
+
namespace ?? void 0,
|
|
483
|
+
tags
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
if (method !== "GET" && this.cache) {
|
|
487
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
488
|
+
const ns = this.namespaceFromPath(path);
|
|
489
|
+
if (ns) namespaces.add(ns);
|
|
490
|
+
for (const extra of options?.invalidate ?? []) {
|
|
491
|
+
if (extra) namespaces.add(extra);
|
|
492
|
+
}
|
|
493
|
+
for (const nsName of namespaces) this.cache.invalidate(nsName);
|
|
494
|
+
}
|
|
205
495
|
return data;
|
|
206
496
|
}
|
|
497
|
+
// ── Cache key/namespace helpers ──
|
|
498
|
+
/** Build a token-scoped cache key: `METHOD path|token-hash|params`. */
|
|
499
|
+
cacheKeyFor(method, path, params) {
|
|
500
|
+
const token = this.authStore.token || "anon";
|
|
501
|
+
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
|
502
|
+
return `${method} ${path}${qs}|${token}`;
|
|
503
|
+
}
|
|
504
|
+
/** Best-effort namespace (collection name) from a REST path. */
|
|
505
|
+
namespaceFromPath(path) {
|
|
506
|
+
const clean = path.split("?")[0];
|
|
507
|
+
const parts = clean.split("/").filter(Boolean);
|
|
508
|
+
if (parts.length === 0) return void 0;
|
|
509
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
510
|
+
return parts[0];
|
|
511
|
+
}
|
|
512
|
+
return parts[0];
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Semantic prefix tags for `deleteByPrefix`, derived from the REST shape:
|
|
516
|
+
* - `/{collection}?...` → `getList:{collection}`
|
|
517
|
+
* - `/{collection}/{id}` → `getOne:{collection}`
|
|
518
|
+
* - `/collections?...` / `/collections/{id}` → `collections:getList` / `collections:getOne`
|
|
519
|
+
*/
|
|
520
|
+
cacheTagsFor(path, namespace) {
|
|
521
|
+
if (!namespace) return [];
|
|
522
|
+
const clean = path.split("?")[0];
|
|
523
|
+
const parts = clean.split("/").filter(Boolean);
|
|
524
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
525
|
+
const op2 = parts.length >= 2 ? "getOne" : "getList";
|
|
526
|
+
return [`${namespace}:${op2}`];
|
|
527
|
+
}
|
|
528
|
+
const op = parts.length >= 2 ? "getOne" : "getList";
|
|
529
|
+
return [`${op}:${namespace}`];
|
|
530
|
+
}
|
|
207
531
|
/**
|
|
208
532
|
* HTTP GET.
|
|
209
533
|
* @param path URL path.
|
|
@@ -379,6 +703,30 @@ var memoryStorage = {
|
|
|
379
703
|
};
|
|
380
704
|
|
|
381
705
|
// src/collection.ts
|
|
706
|
+
function stableStringify(value) {
|
|
707
|
+
const seen = /* @__PURE__ */ new Set();
|
|
708
|
+
const sort = (v) => {
|
|
709
|
+
if (Array.isArray(v)) return v.map(sort);
|
|
710
|
+
if (v && typeof v === "object") {
|
|
711
|
+
if (seen.has(v)) return "[Circular]";
|
|
712
|
+
seen.add(v);
|
|
713
|
+
const out = {};
|
|
714
|
+
for (const k of Object.keys(v).sort()) {
|
|
715
|
+
if (k === "requestKey" || k === "singleFlight" || k === "fetch" || k === "signal") {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
out[k] = sort(v[k]);
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
722
|
+
return v;
|
|
723
|
+
};
|
|
724
|
+
try {
|
|
725
|
+
return JSON.stringify(sort(value));
|
|
726
|
+
} catch {
|
|
727
|
+
return String(value);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
382
730
|
function normalizeAction(event, rawAction) {
|
|
383
731
|
if (typeof rawAction === "string") {
|
|
384
732
|
const a = rawAction.toLowerCase();
|
|
@@ -410,19 +758,44 @@ var CollectionService = class {
|
|
|
410
758
|
* @param options Query params (`filter`, `sort`, `expand`, `fields`) + request options.
|
|
411
759
|
*/
|
|
412
760
|
getList(page = 1, perPage = 30, options) {
|
|
413
|
-
const {
|
|
761
|
+
const {
|
|
762
|
+
requestKey,
|
|
763
|
+
autoCancel,
|
|
764
|
+
cancelKey,
|
|
765
|
+
fetch,
|
|
766
|
+
headers,
|
|
767
|
+
signal,
|
|
768
|
+
cache,
|
|
769
|
+
ttl,
|
|
770
|
+
invalidate,
|
|
771
|
+
singleFlight,
|
|
772
|
+
params,
|
|
773
|
+
...queryParams
|
|
774
|
+
} = options ?? {};
|
|
414
775
|
const qs = new URLSearchParams(
|
|
415
776
|
Object.fromEntries(
|
|
416
777
|
Object.entries({
|
|
417
778
|
page: String(page),
|
|
418
779
|
perPage: String(perPage),
|
|
419
|
-
...
|
|
780
|
+
...queryParams
|
|
420
781
|
}).map(([k, v]) => [k, String(v)])
|
|
421
782
|
)
|
|
422
783
|
).toString();
|
|
423
784
|
return this.http.get(
|
|
424
785
|
"/" + this.encodeId(this.collectionName) + "?" + qs,
|
|
425
|
-
{
|
|
786
|
+
{
|
|
787
|
+
requestKey,
|
|
788
|
+
autoCancel,
|
|
789
|
+
cancelKey,
|
|
790
|
+
fetch,
|
|
791
|
+
headers,
|
|
792
|
+
signal,
|
|
793
|
+
cache,
|
|
794
|
+
ttl,
|
|
795
|
+
invalidate,
|
|
796
|
+
singleFlight,
|
|
797
|
+
params
|
|
798
|
+
}
|
|
426
799
|
);
|
|
427
800
|
}
|
|
428
801
|
/**
|
|
@@ -433,6 +806,7 @@ var CollectionService = class {
|
|
|
433
806
|
*/
|
|
434
807
|
async getFullList(options) {
|
|
435
808
|
const { batch = 1e3, ...rest } = options ?? {};
|
|
809
|
+
const effectiveKey = typeof rest.requestKey === "string" ? rest.requestKey : `getFullList:${this.collectionName}:${stableStringify(rest)}`;
|
|
436
810
|
const items = [];
|
|
437
811
|
let page = 1;
|
|
438
812
|
for (; ; ) {
|
|
@@ -440,9 +814,9 @@ var CollectionService = class {
|
|
|
440
814
|
page,
|
|
441
815
|
batch,
|
|
442
816
|
{
|
|
443
|
-
// disable auto-cancellation across pages — each page request is unique
|
|
444
817
|
...rest,
|
|
445
|
-
requestKey:
|
|
818
|
+
requestKey: effectiveKey,
|
|
819
|
+
singleFlight: true
|
|
446
820
|
}
|
|
447
821
|
);
|
|
448
822
|
if (!res || !res.items || res.items.length === 0) break;
|
|
@@ -965,14 +1339,40 @@ var CollectionsService = class {
|
|
|
965
1339
|
async getFullList(options) {
|
|
966
1340
|
if (!this.http) return [];
|
|
967
1341
|
const { batch = 1e3, ...rest } = options ?? {};
|
|
1342
|
+
const {
|
|
1343
|
+
requestKey: reqKey,
|
|
1344
|
+
singleFlight: _singleFlight,
|
|
1345
|
+
fetch: fetchFn,
|
|
1346
|
+
headers: hdrs,
|
|
1347
|
+
signal: sig,
|
|
1348
|
+
cache: cacheOpt,
|
|
1349
|
+
ttl: ttlOpt,
|
|
1350
|
+
invalidate: inval,
|
|
1351
|
+
params: passthroughParams,
|
|
1352
|
+
...queryParams
|
|
1353
|
+
} = rest;
|
|
1354
|
+
const effectiveKey = typeof reqKey === "string" ? reqKey : `getFullList:collections:${stableStringify(rest)}`;
|
|
968
1355
|
const items = [];
|
|
969
1356
|
let page = 1;
|
|
970
1357
|
for (; ; ) {
|
|
971
|
-
const res = await this.getList(
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1358
|
+
const res = await this.getList(
|
|
1359
|
+
{
|
|
1360
|
+
...queryParams,
|
|
1361
|
+
...passthroughParams ?? {},
|
|
1362
|
+
page,
|
|
1363
|
+
perPage: batch
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
requestKey: effectiveKey,
|
|
1367
|
+
singleFlight: true,
|
|
1368
|
+
...fetchFn ? { fetch: fetchFn } : {},
|
|
1369
|
+
...hdrs ? { headers: hdrs } : {},
|
|
1370
|
+
...sig ? { signal: sig } : {},
|
|
1371
|
+
...cacheOpt !== void 0 ? { cache: cacheOpt } : {},
|
|
1372
|
+
...ttlOpt !== void 0 ? { ttl: ttlOpt } : {},
|
|
1373
|
+
...inval ? { invalidate: inval } : {}
|
|
1374
|
+
}
|
|
1375
|
+
);
|
|
976
1376
|
if (!res || !res.items || res.items.length === 0) break;
|
|
977
1377
|
items.push(...res.items);
|
|
978
1378
|
if (page >= (res.totalPages ?? page)) break;
|
|
@@ -1071,6 +1471,50 @@ var LazypockClient = class {
|
|
|
1071
1471
|
*/
|
|
1072
1472
|
constructor(options) {
|
|
1073
1473
|
this.collectionCache = /* @__PURE__ */ new Map();
|
|
1474
|
+
/** Namespace → realtime unsubscribe; used for realtime-driven invalidation. */
|
|
1475
|
+
this.realtimeInvalidators = /* @__PURE__ */ new Map();
|
|
1476
|
+
// ── Query cache (opt-in by default; opt-out per request) ──
|
|
1477
|
+
/**
|
|
1478
|
+
* Configure the query cache at runtime (also a namespace for cache
|
|
1479
|
+
* management methods).
|
|
1480
|
+
*
|
|
1481
|
+
* ```ts
|
|
1482
|
+
* client.cache({ enabled: true, defaultTTL: 30_000 });
|
|
1483
|
+
* client.cache.deleteByPrefix('getList:posts'); // all list caches for posts
|
|
1484
|
+
* client.cache.deleteByPrefix('getOne:posts'); // all one-record caches
|
|
1485
|
+
* ```
|
|
1486
|
+
*
|
|
1487
|
+
* When enabled, GET requests cache their payload; mutations invalidate the
|
|
1488
|
+
* affected collection automatically. Individual requests can opt out with
|
|
1489
|
+
* `{ cache: false }` or override the TTL with `{ ttl: ms }`.
|
|
1490
|
+
*/
|
|
1491
|
+
this.cache = Object.assign(
|
|
1492
|
+
((config) => {
|
|
1493
|
+
if (!this.cacheStore) {
|
|
1494
|
+
this.cacheStore = new CacheStore({
|
|
1495
|
+
defaultTTL: config?.defaultTTL,
|
|
1496
|
+
store: config?.store,
|
|
1497
|
+
maxEntries: config?.maxEntries
|
|
1498
|
+
});
|
|
1499
|
+
this.http.setCache(this.cacheStore, config?.enabled ?? true);
|
|
1500
|
+
} else {
|
|
1501
|
+
if (config?.enabled !== void 0) {
|
|
1502
|
+
this.http.setCache(this.cacheStore, config.enabled);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
return this;
|
|
1506
|
+
}),
|
|
1507
|
+
{
|
|
1508
|
+
deleteByPrefix: (prefix) => this.cacheStore?.deleteByPrefix(prefix),
|
|
1509
|
+
invalidate: (namespace) => this.cacheStore?.invalidate(namespace),
|
|
1510
|
+
clear: () => {
|
|
1511
|
+
this.cacheStore?.clear();
|
|
1512
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
1513
|
+
this.realtimeInvalidators.clear();
|
|
1514
|
+
},
|
|
1515
|
+
stats: () => this.cacheStore ? this.cacheStore.stats() : null
|
|
1516
|
+
}
|
|
1517
|
+
);
|
|
1074
1518
|
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
1075
1519
|
this.authStore = options.authStore ?? new AuthStore(options.storage ?? memoryStorage);
|
|
1076
1520
|
this.http = new HttpClient(baseUrl, this.authStore);
|
|
@@ -1080,6 +1524,14 @@ var LazypockClient = class {
|
|
|
1080
1524
|
}
|
|
1081
1525
|
this.files = new FilesService(this.http);
|
|
1082
1526
|
this.collections = new CollectionsService(this.http, this.realtime);
|
|
1527
|
+
if (options.cache) {
|
|
1528
|
+
this.cacheStore = new CacheStore({
|
|
1529
|
+
defaultTTL: options.cache.defaultTTL,
|
|
1530
|
+
store: options.cache.store,
|
|
1531
|
+
maxEntries: options.cache.maxEntries
|
|
1532
|
+
});
|
|
1533
|
+
this.http.setCache(this.cacheStore, options.cache.enabled ?? false);
|
|
1534
|
+
}
|
|
1083
1535
|
if (options.types?.schemas) {
|
|
1084
1536
|
this.schemaByName = new Map(
|
|
1085
1537
|
options.types.schemas.map((s) => [s.name, s])
|
|
@@ -1160,6 +1612,49 @@ var LazypockClient = class {
|
|
|
1160
1612
|
this.http.cancelAllRequests();
|
|
1161
1613
|
return this;
|
|
1162
1614
|
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Drop every cached entry (all collections / namespaces).
|
|
1617
|
+
* Also disables realtime-driven invalidation subscriptions.
|
|
1618
|
+
*/
|
|
1619
|
+
clearCache() {
|
|
1620
|
+
this.cacheStore?.clear();
|
|
1621
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
1622
|
+
this.realtimeInvalidators.clear();
|
|
1623
|
+
return this;
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Invalidate cached entries for a collection (or custom namespace).
|
|
1627
|
+
* Runs automatically on mutations — call explicitly when data changed
|
|
1628
|
+
* out-of-band (e.g. another client wrote to the same collection).
|
|
1629
|
+
*/
|
|
1630
|
+
invalidateCache(namespace) {
|
|
1631
|
+
this.cacheStore?.invalidate(namespace);
|
|
1632
|
+
return this;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Cache hit/miss/entry statistics.
|
|
1636
|
+
* Returns null when caching was never configured.
|
|
1637
|
+
*/
|
|
1638
|
+
cacheStats() {
|
|
1639
|
+
return this.cacheStore ? this.cacheStore.stats() : null;
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Subscribe a collection's cache to realtime invalidation: any inbound
|
|
1643
|
+
* create/update/delete event for the collection clears its cached entries.
|
|
1644
|
+
* Returns an unsubscribe function.
|
|
1645
|
+
*/
|
|
1646
|
+
invalidateCacheOnRealtime(collectionName) {
|
|
1647
|
+
if (!this.cacheStore) {
|
|
1648
|
+
this.cache({ enabled: false });
|
|
1649
|
+
}
|
|
1650
|
+
const existing = this.realtimeInvalidators.get(collectionName);
|
|
1651
|
+
if (existing) return existing;
|
|
1652
|
+
const unsub = this.collection(collectionName).subscribe(() => {
|
|
1653
|
+
this.cacheStore?.invalidate(collectionName);
|
|
1654
|
+
});
|
|
1655
|
+
this.realtimeInvalidators.set(collectionName, unsub);
|
|
1656
|
+
return unsub;
|
|
1657
|
+
}
|
|
1163
1658
|
// ── Auth ──
|
|
1164
1659
|
/** Check whether any superuser exists (for login vs setup screen routing). */
|
|
1165
1660
|
async checkSuperuser() {
|
|
@@ -1296,6 +1791,7 @@ function createClient(options) {
|
|
|
1296
1791
|
export {
|
|
1297
1792
|
ApiError,
|
|
1298
1793
|
AuthStore,
|
|
1794
|
+
CacheStore,
|
|
1299
1795
|
CollectionService,
|
|
1300
1796
|
CollectionsService,
|
|
1301
1797
|
FilesService,
|
|
@@ -1311,6 +1807,7 @@ export {
|
|
|
1311
1807
|
getFileUrl,
|
|
1312
1808
|
getScaleUrl,
|
|
1313
1809
|
getThumbUrl,
|
|
1810
|
+
resolveCacheDirective,
|
|
1314
1811
|
schemaFieldType,
|
|
1315
1812
|
wsUrlFromBaseUrl
|
|
1316
1813
|
};
|