lazypock 0.2.0 → 0.3.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 +107 -0
- package/dist/index.cjs +404 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +261 -1
- package/dist/index.d.ts +261 -1
- package/dist/index.global.js +402 -3
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +402 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cache.ts +314 -0
- package/src/collection.ts +26 -3
- package/src/http.ts +126 -0
- package/src/index.ts +5 -0
- package/src/lazypock.ts +144 -0
- package/src/types.ts +12 -0
package/README.md
CHANGED
|
@@ -179,6 +179,7 @@ The main client class.
|
|
|
179
179
|
| `storage` | `StorageAdapter` | `memoryStorage` | Custom storage adapter for token persistence |
|
|
180
180
|
| `authStore` | `AuthStore` | auto-created | Explicit auth store instance |
|
|
181
181
|
| `realtime` | `RealtimeService` | auto-created | Real-time service for WebSocket subscriptions |
|
|
182
|
+
| `cache` | [`CacheConfig`](#query-cache) | disabled | Query-cache configuration (opt-in) |
|
|
182
183
|
|
|
183
184
|
#### Auto-Cancellation Methods
|
|
184
185
|
|
|
@@ -186,6 +187,14 @@ The main client class.
|
|
|
186
187
|
- `cancelRequest(requestKey)` — Abort a single pending request by key (default `HTTP_METHOD + path`)
|
|
187
188
|
- `cancelAllRequests()` — Abort all pending requests
|
|
188
189
|
|
|
190
|
+
#### Query-Cache Methods
|
|
191
|
+
|
|
192
|
+
- `cache(config?)` — Enable/configure the query cache at runtime (see [Query Cache](#query-cache))
|
|
193
|
+
- `clearCache()` — Drop every cached entry
|
|
194
|
+
- `invalidateCache(namespace)` — Invalidate entries for a collection / custom namespace
|
|
195
|
+
- `cacheStats()` — `{ hits, misses, entries }` cache statistics
|
|
196
|
+
- `invalidateCacheOnRealtime(collection)` — Subscribe the cache to realtime events for a collection; returns an unsubscribe fn
|
|
197
|
+
|
|
189
198
|
#### Authentication Methods
|
|
190
199
|
|
|
191
200
|
- `login(email, password, collection?)` — Login as superuser or auth collection user
|
|
@@ -349,6 +358,104 @@ client.cancelRequest('GET /api/posts?page=1');
|
|
|
349
358
|
client.cancelAllRequests();
|
|
350
359
|
```
|
|
351
360
|
|
|
361
|
+
## Query Cache
|
|
362
|
+
|
|
363
|
+
Lazypock has a built-in query cache for **GET** requests — disabled by default.
|
|
364
|
+
It's useful for read-heavy UIs (lists, dashboards) to avoid hammering the server.
|
|
365
|
+
|
|
366
|
+
### Enabling
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import { createClient } from "lazypock";
|
|
370
|
+
|
|
371
|
+
const client = createClient({
|
|
372
|
+
baseUrl: "https://api.example.com",
|
|
373
|
+
cache: {
|
|
374
|
+
enabled: true,
|
|
375
|
+
defaultTTL: 30_000, // 30s
|
|
376
|
+
// store: myStorage, // optional: reuse any StorageAdapter (localStorage/AsyncStorage)
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
When enabled, **all** GET requests are cached with the default TTL, and
|
|
382
|
+
mutations (`create`/`update`/`delete`) automatically invalidate the affected
|
|
383
|
+
collection's cached entries.
|
|
384
|
+
|
|
385
|
+
### Per-request control
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
// Cache this request (works even when the global cache is off)
|
|
389
|
+
await client.collection('posts').getList(1, 20, { cache: true });
|
|
390
|
+
|
|
391
|
+
// Bypass the cache — always fetch fresh (and don't store the result)
|
|
392
|
+
const fresh = await client.collection('posts').getList(1, 20, { cache: false });
|
|
393
|
+
|
|
394
|
+
// Custom TTL for this request
|
|
395
|
+
await client.collection('posts').getOne('abc', { ttl: 120_000 });
|
|
396
|
+
|
|
397
|
+
// Cache with a custom key (dedupe/override the default `METHOD path|token` key)
|
|
398
|
+
await client.collection('posts').getList(1, 20, { cache: { ttl: 60_000, key: 'my-list' } });
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Prefix deletion (per-operation invalidation)
|
|
402
|
+
|
|
403
|
+
Every cached entry is tagged with its operation and collection, so you can
|
|
404
|
+
invalidate a whole class of caches without touching the rest:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
client.cache.deleteByPrefix('getList:posts'); // delete all getList cache for posts
|
|
408
|
+
client.cache.deleteByPrefix('getOne:posts'); // delete all getOne cache for posts
|
|
409
|
+
client.cache.deleteByPrefix('collections:getList'); // admin collection list caches
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The `client.cache` namespace also exposes `invalidate(ns)`, `clear()`, `stats()`,
|
|
413
|
+
and is callable to (re)configure (`client.cache({ enabled: true })`).
|
|
414
|
+
|
|
415
|
+
### Invalidation
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
// Mutations invalidate the collection automatically:
|
|
419
|
+
await client.collection('posts').create({ title: 'New' });
|
|
420
|
+
await client.collection('posts').getList(1, 20); // re-fetched (cache cleared)
|
|
421
|
+
|
|
422
|
+
// Invalidate extra namespaces explicitly:
|
|
423
|
+
await client.collection('posts').create(
|
|
424
|
+
{ title: 'New' },
|
|
425
|
+
{ invalidate: ['users'] },
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Manual / out-of-band invalidation:
|
|
429
|
+
client.invalidateCache('posts');
|
|
430
|
+
client.clearCache();
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Cache key scoping
|
|
434
|
+
|
|
435
|
+
Cache keys are **scoped by auth token** — a logged-in user's cached data can
|
|
436
|
+
never leak to another user (or to anonymous visitors). Logging out/in changes
|
|
437
|
+
the token, so cached entries are naturally isolated per identity.
|
|
438
|
+
|
|
439
|
+
### Realtime-driven invalidation
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
// Keep the posts cache fresh: any create/update/delete event clears it.
|
|
443
|
+
const stop = client.invalidateCacheOnRealtime('posts');
|
|
444
|
+
// later:
|
|
445
|
+
stop();
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
When a realtime event arrives for the collection, its cached entries are
|
|
449
|
+
cleared so the next read fetches fresh data. This is **invalidate-only** —
|
|
450
|
+
cached list payloads are never mutated in place (a filter/sort change could
|
|
451
|
+
make an in-place patch serve wrong data).
|
|
452
|
+
|
|
453
|
+
### Stats
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
client.cacheStats(); // { hits, misses, entries }
|
|
457
|
+
```
|
|
458
|
+
|
|
352
459
|
## Error Handling
|
|
353
460
|
|
|
354
461
|
The SDK throws `ApiError` on non-2xx responses:
|
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,6 +260,8 @@ 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
|
|
@@ -75,6 +274,18 @@ var HttpClient = class {
|
|
|
75
274
|
this.authStore = authStore;
|
|
76
275
|
this.defaultFetch = globalThis.fetch.bind(globalThis);
|
|
77
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Attach a cache store + master switch.
|
|
279
|
+
* Called by the client constructor when cache config is present.
|
|
280
|
+
*/
|
|
281
|
+
setCache(cache, enabled) {
|
|
282
|
+
this.cache = cache;
|
|
283
|
+
this.cacheEnabled = enabled;
|
|
284
|
+
}
|
|
285
|
+
/** Whether the global cache flag is on (requests opt in/out individually too). */
|
|
286
|
+
get cacheIsEnabled() {
|
|
287
|
+
return this.cacheEnabled;
|
|
288
|
+
}
|
|
78
289
|
async refreshAuth() {
|
|
79
290
|
const collection = this.authStore.collectionName;
|
|
80
291
|
if (!collection) return null;
|
|
@@ -151,10 +362,25 @@ var HttpClient = class {
|
|
|
151
362
|
* @throws {ApiError} On non-2xx responses or when the request is aborted
|
|
152
363
|
* (aborted requests throw an `ApiError` with `isAbort === true`).
|
|
153
364
|
*/
|
|
365
|
+
/** Invalidate a namespace (collection name). No-op when cache is off. */
|
|
366
|
+
invalidateCache(namespace) {
|
|
367
|
+
this.cache?.invalidate(namespace);
|
|
368
|
+
}
|
|
369
|
+
/** Current cache statistics (hits/misses/entries), or null when disabled. */
|
|
370
|
+
cacheStats() {
|
|
371
|
+
return this.cache ? this.cache.stats() : null;
|
|
372
|
+
}
|
|
154
373
|
async request(method, path, body, options) {
|
|
155
374
|
if (this.authStore.isExpired && this.authStore.collectionName) {
|
|
156
375
|
await this.refreshAuth();
|
|
157
376
|
}
|
|
377
|
+
const cacheDirective = resolveCacheDirective(options);
|
|
378
|
+
const wantCache = cacheDirective !== null ? cacheDirective.enabled : this.cacheEnabled;
|
|
379
|
+
const cacheKey = method === "GET" && this.cache && wantCache ? this.cacheKeyFor(method, path, options?.params) : null;
|
|
380
|
+
if (cacheKey !== null) {
|
|
381
|
+
const hit = await this.cache?.get(cacheKey);
|
|
382
|
+
if (hit !== void 0) return hit;
|
|
383
|
+
}
|
|
158
384
|
let requestKey = options?.requestKey === void 0 ? options?.cancelKey ?? `${method} ${path}` : options.requestKey;
|
|
159
385
|
if (options?.autoCancel === false) requestKey = null;
|
|
160
386
|
let controller = null;
|
|
@@ -238,8 +464,63 @@ var HttpClient = class {
|
|
|
238
464
|
res.status
|
|
239
465
|
);
|
|
240
466
|
}
|
|
467
|
+
if (cacheKey !== null && this.cache) {
|
|
468
|
+
const namespace = this.namespaceFromPath(path);
|
|
469
|
+
const ttl = cacheDirective?.ttl;
|
|
470
|
+
const tags = this.cacheTagsFor(path, namespace);
|
|
471
|
+
this.cache.set(
|
|
472
|
+
cacheKey,
|
|
473
|
+
data,
|
|
474
|
+
ttl,
|
|
475
|
+
namespace ?? void 0,
|
|
476
|
+
tags
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
if (method !== "GET" && this.cache) {
|
|
480
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
481
|
+
const ns = this.namespaceFromPath(path);
|
|
482
|
+
if (ns) namespaces.add(ns);
|
|
483
|
+
for (const extra of options?.invalidate ?? []) {
|
|
484
|
+
if (extra) namespaces.add(extra);
|
|
485
|
+
}
|
|
486
|
+
for (const nsName of namespaces) this.cache.invalidate(nsName);
|
|
487
|
+
}
|
|
241
488
|
return data;
|
|
242
489
|
}
|
|
490
|
+
// ── Cache key/namespace helpers ──
|
|
491
|
+
/** Build a token-scoped cache key: `METHOD path|token-hash|params`. */
|
|
492
|
+
cacheKeyFor(method, path, params) {
|
|
493
|
+
const token = this.authStore.token || "anon";
|
|
494
|
+
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
|
495
|
+
return `${method} ${path}${qs}|${token}`;
|
|
496
|
+
}
|
|
497
|
+
/** Best-effort namespace (collection name) from a REST path. */
|
|
498
|
+
namespaceFromPath(path) {
|
|
499
|
+
const clean = path.split("?")[0];
|
|
500
|
+
const parts = clean.split("/").filter(Boolean);
|
|
501
|
+
if (parts.length === 0) return void 0;
|
|
502
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
503
|
+
return parts[0];
|
|
504
|
+
}
|
|
505
|
+
return parts[0];
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Semantic prefix tags for `deleteByPrefix`, derived from the REST shape:
|
|
509
|
+
* - `/{collection}?...` → `getList:{collection}`
|
|
510
|
+
* - `/{collection}/{id}` → `getOne:{collection}`
|
|
511
|
+
* - `/collections?...` / `/collections/{id}` → `collections:getList` / `collections:getOne`
|
|
512
|
+
*/
|
|
513
|
+
cacheTagsFor(path, namespace) {
|
|
514
|
+
if (!namespace) return [];
|
|
515
|
+
const clean = path.split("?")[0];
|
|
516
|
+
const parts = clean.split("/").filter(Boolean);
|
|
517
|
+
if (parts[0] === "collections" || parts[0] === "_superusers") {
|
|
518
|
+
const op2 = parts.length >= 2 ? "getOne" : "getList";
|
|
519
|
+
return [`${namespace}:${op2}`];
|
|
520
|
+
}
|
|
521
|
+
const op = parts.length >= 2 ? "getOne" : "getList";
|
|
522
|
+
return [`${op}:${namespace}`];
|
|
523
|
+
}
|
|
243
524
|
/**
|
|
244
525
|
* HTTP GET.
|
|
245
526
|
* @param path URL path.
|
|
@@ -446,19 +727,42 @@ var CollectionService = class {
|
|
|
446
727
|
* @param options Query params (`filter`, `sort`, `expand`, `fields`) + request options.
|
|
447
728
|
*/
|
|
448
729
|
getList(page = 1, perPage = 30, options) {
|
|
449
|
-
const {
|
|
730
|
+
const {
|
|
731
|
+
requestKey,
|
|
732
|
+
autoCancel,
|
|
733
|
+
cancelKey,
|
|
734
|
+
fetch,
|
|
735
|
+
headers,
|
|
736
|
+
signal,
|
|
737
|
+
cache,
|
|
738
|
+
ttl,
|
|
739
|
+
invalidate,
|
|
740
|
+
params,
|
|
741
|
+
...queryParams
|
|
742
|
+
} = options ?? {};
|
|
450
743
|
const qs = new URLSearchParams(
|
|
451
744
|
Object.fromEntries(
|
|
452
745
|
Object.entries({
|
|
453
746
|
page: String(page),
|
|
454
747
|
perPage: String(perPage),
|
|
455
|
-
...
|
|
748
|
+
...queryParams
|
|
456
749
|
}).map(([k, v]) => [k, String(v)])
|
|
457
750
|
)
|
|
458
751
|
).toString();
|
|
459
752
|
return this.http.get(
|
|
460
753
|
"/" + this.encodeId(this.collectionName) + "?" + qs,
|
|
461
|
-
{
|
|
754
|
+
{
|
|
755
|
+
requestKey,
|
|
756
|
+
autoCancel,
|
|
757
|
+
cancelKey,
|
|
758
|
+
fetch,
|
|
759
|
+
headers,
|
|
760
|
+
signal,
|
|
761
|
+
cache,
|
|
762
|
+
ttl,
|
|
763
|
+
invalidate,
|
|
764
|
+
params
|
|
765
|
+
}
|
|
462
766
|
);
|
|
463
767
|
}
|
|
464
768
|
/**
|
|
@@ -1291,6 +1595,50 @@ var LazypockClient = class {
|
|
|
1291
1595
|
*/
|
|
1292
1596
|
constructor(options) {
|
|
1293
1597
|
this.collectionCache = /* @__PURE__ */ new Map();
|
|
1598
|
+
/** Namespace → realtime unsubscribe; used for realtime-driven invalidation. */
|
|
1599
|
+
this.realtimeInvalidators = /* @__PURE__ */ new Map();
|
|
1600
|
+
// ── Query cache (opt-in by default; opt-out per request) ──
|
|
1601
|
+
/**
|
|
1602
|
+
* Configure the query cache at runtime (also a namespace for cache
|
|
1603
|
+
* management methods).
|
|
1604
|
+
*
|
|
1605
|
+
* ```ts
|
|
1606
|
+
* client.cache({ enabled: true, defaultTTL: 30_000 });
|
|
1607
|
+
* client.cache.deleteByPrefix('getList:posts'); // all list caches for posts
|
|
1608
|
+
* client.cache.deleteByPrefix('getOne:posts'); // all one-record caches
|
|
1609
|
+
* ```
|
|
1610
|
+
*
|
|
1611
|
+
* When enabled, GET requests cache their payload; mutations invalidate the
|
|
1612
|
+
* affected collection automatically. Individual requests can opt out with
|
|
1613
|
+
* `{ cache: false }` or override the TTL with `{ ttl: ms }`.
|
|
1614
|
+
*/
|
|
1615
|
+
this.cache = Object.assign(
|
|
1616
|
+
((config) => {
|
|
1617
|
+
if (!this.cacheStore) {
|
|
1618
|
+
this.cacheStore = new CacheStore({
|
|
1619
|
+
defaultTTL: config?.defaultTTL,
|
|
1620
|
+
store: config?.store,
|
|
1621
|
+
maxEntries: config?.maxEntries
|
|
1622
|
+
});
|
|
1623
|
+
this.http.setCache(this.cacheStore, config?.enabled ?? true);
|
|
1624
|
+
} else {
|
|
1625
|
+
if (config?.enabled !== void 0) {
|
|
1626
|
+
this.http.setCache(this.cacheStore, config.enabled);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return this;
|
|
1630
|
+
}),
|
|
1631
|
+
{
|
|
1632
|
+
deleteByPrefix: (prefix) => this.cacheStore?.deleteByPrefix(prefix),
|
|
1633
|
+
invalidate: (namespace) => this.cacheStore?.invalidate(namespace),
|
|
1634
|
+
clear: () => {
|
|
1635
|
+
this.cacheStore?.clear();
|
|
1636
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
1637
|
+
this.realtimeInvalidators.clear();
|
|
1638
|
+
},
|
|
1639
|
+
stats: () => this.cacheStore ? this.cacheStore.stats() : null
|
|
1640
|
+
}
|
|
1641
|
+
);
|
|
1294
1642
|
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
1295
1643
|
this.authStore = options.authStore ?? new AuthStore(options.storage ?? memoryStorage);
|
|
1296
1644
|
this.http = new HttpClient(baseUrl, this.authStore);
|
|
@@ -1300,6 +1648,14 @@ var LazypockClient = class {
|
|
|
1300
1648
|
}
|
|
1301
1649
|
this.files = new FilesService(this.http);
|
|
1302
1650
|
this.collections = new CollectionsService(this.http, this.realtime);
|
|
1651
|
+
if (options.cache) {
|
|
1652
|
+
this.cacheStore = new CacheStore({
|
|
1653
|
+
defaultTTL: options.cache.defaultTTL,
|
|
1654
|
+
store: options.cache.store,
|
|
1655
|
+
maxEntries: options.cache.maxEntries
|
|
1656
|
+
});
|
|
1657
|
+
this.http.setCache(this.cacheStore, options.cache.enabled ?? false);
|
|
1658
|
+
}
|
|
1303
1659
|
if (options.types?.schemas) {
|
|
1304
1660
|
this.schemaByName = new Map(
|
|
1305
1661
|
options.types.schemas.map((s) => [s.name, s])
|
|
@@ -1380,6 +1736,49 @@ var LazypockClient = class {
|
|
|
1380
1736
|
this.http.cancelAllRequests();
|
|
1381
1737
|
return this;
|
|
1382
1738
|
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Drop every cached entry (all collections / namespaces).
|
|
1741
|
+
* Also disables realtime-driven invalidation subscriptions.
|
|
1742
|
+
*/
|
|
1743
|
+
clearCache() {
|
|
1744
|
+
this.cacheStore?.clear();
|
|
1745
|
+
for (const unsub of this.realtimeInvalidators.values()) unsub();
|
|
1746
|
+
this.realtimeInvalidators.clear();
|
|
1747
|
+
return this;
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Invalidate cached entries for a collection (or custom namespace).
|
|
1751
|
+
* Runs automatically on mutations — call explicitly when data changed
|
|
1752
|
+
* out-of-band (e.g. another client wrote to the same collection).
|
|
1753
|
+
*/
|
|
1754
|
+
invalidateCache(namespace) {
|
|
1755
|
+
this.cacheStore?.invalidate(namespace);
|
|
1756
|
+
return this;
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Cache hit/miss/entry statistics.
|
|
1760
|
+
* Returns null when caching was never configured.
|
|
1761
|
+
*/
|
|
1762
|
+
cacheStats() {
|
|
1763
|
+
return this.cacheStore ? this.cacheStore.stats() : null;
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Subscribe a collection's cache to realtime invalidation: any inbound
|
|
1767
|
+
* create/update/delete event for the collection clears its cached entries.
|
|
1768
|
+
* Returns an unsubscribe function.
|
|
1769
|
+
*/
|
|
1770
|
+
invalidateCacheOnRealtime(collectionName) {
|
|
1771
|
+
if (!this.cacheStore) {
|
|
1772
|
+
this.cache({ enabled: false });
|
|
1773
|
+
}
|
|
1774
|
+
const existing = this.realtimeInvalidators.get(collectionName);
|
|
1775
|
+
if (existing) return existing;
|
|
1776
|
+
const unsub = this.collection(collectionName).subscribe(() => {
|
|
1777
|
+
this.cacheStore?.invalidate(collectionName);
|
|
1778
|
+
});
|
|
1779
|
+
this.realtimeInvalidators.set(collectionName, unsub);
|
|
1780
|
+
return unsub;
|
|
1781
|
+
}
|
|
1383
1782
|
// ── Auth ──
|
|
1384
1783
|
/** Check whether any superuser exists (for login vs setup screen routing). */
|
|
1385
1784
|
async checkSuperuser() {
|
|
@@ -1517,6 +1916,7 @@ function createClient(options) {
|
|
|
1517
1916
|
0 && (module.exports = {
|
|
1518
1917
|
ApiError,
|
|
1519
1918
|
AuthStore,
|
|
1919
|
+
CacheStore,
|
|
1520
1920
|
CollectionService,
|
|
1521
1921
|
CollectionsService,
|
|
1522
1922
|
FilesService,
|
|
@@ -1532,6 +1932,7 @@ function createClient(options) {
|
|
|
1532
1932
|
getFileUrl,
|
|
1533
1933
|
getScaleUrl,
|
|
1534
1934
|
getThumbUrl,
|
|
1935
|
+
resolveCacheDirective,
|
|
1535
1936
|
schemaFieldType,
|
|
1536
1937
|
wsUrlFromBaseUrl
|
|
1537
1938
|
});
|