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.
@@ -23,6 +23,7 @@ var Lazypock = (() => {
23
23
  __export(index_exports, {
24
24
  ApiError: () => ApiError,
25
25
  AuthStore: () => AuthStore,
26
+ CacheStore: () => CacheStore,
26
27
  CollectionService: () => CollectionService,
27
28
  CollectionsService: () => CollectionsService,
28
29
  FilesService: () => FilesService,
@@ -38,6 +39,7 @@ var Lazypock = (() => {
38
39
  getFileUrl: () => getFileUrl,
39
40
  getScaleUrl: () => getScaleUrl,
40
41
  getThumbUrl: () => getThumbUrl,
42
+ resolveCacheDirective: () => resolveCacheDirective,
41
43
  schemaFieldType: () => schemaFieldType,
42
44
  wsUrlFromBaseUrl: () => wsUrlFromBaseUrl
43
45
  });
@@ -53,6 +55,201 @@ var Lazypock = (() => {
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 Lazypock = (() => {
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 Lazypock = (() => {
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 Lazypock = (() => {
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 Lazypock = (() => {
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 Lazypock = (() => {
446
727
  * @param options Query params (`filter`, `sort`, `expand`, `fields`) + request options.
447
728
  */
448
729
  getList(page = 1, perPage = 30, options) {
449
- const { requestKey, autoCancel, cancelKey, ...rest } = options ?? {};
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
- ...rest
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
- { requestKey, autoCancel, cancelKey }
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 @@ ${opts.body}
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 @@ ${opts.body}
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 @@ ${opts.body}
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() {