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/src/cache.ts ADDED
@@ -0,0 +1,314 @@
1
+ // ── Cache Store ──────────────────────────────────────────
2
+ // Pluggable query-cache for GET responses. Mirrors the AuthStore pattern:
3
+ // a default memory store (Map) with an optional custom storage adapter
4
+ // (localStorage, AsyncStorage, IndexedDB, ...) for cross-page persistence.
5
+
6
+ import type { StorageAdapter } from "./auth";
7
+
8
+ /** Options for enabling/customising the client's query cache. */
9
+ export interface CacheConfig {
10
+ /**
11
+ * Master switch. When `true`, readable GET requests are cached with the
12
+ * default TTL unless a request opts out via `{ cache: false }`.
13
+ *
14
+ * When `false` (default), caching is disabled unless a request opts in
15
+ * via `{ cache: true }` or `{ ttl: <ms> }`. Opt-in works regardless.
16
+ */
17
+ enabled?: boolean;
18
+ /**
19
+ * Default time-to-live for cached entries, in milliseconds.
20
+ * @default 60_000 (1 minute)
21
+ */
22
+ defaultTTL?: number;
23
+ /**
24
+ * Optional persistence backend (same interface as AuthStore's storage).
25
+ * Defaults to an in-memory Map — swap for `localStorage` / `AsyncStorage`
26
+ * to keep the cache across page reloads / app restarts.
27
+ */
28
+ store?: StorageAdapter;
29
+ /**
30
+ * Max number of entries to keep in memory (LRU eviction).
31
+ * @default 500
32
+ */
33
+ maxEntries?: number;
34
+ /**
35
+ * When true and the client has an active realtime subscription for a
36
+ * collection, inbound create/update/delete events invalidate that
37
+ * collection's cached entries automatically.
38
+ * @default false — only local mutations invalidate (explicit + predictable)
39
+ */
40
+ invalidateOnRealtime?: boolean;
41
+ }
42
+
43
+ /** Per-request cache controls (mixed into {@link RequestOptions}). */
44
+ export interface CacheRequestOptions {
45
+ /**
46
+ * Cache control for this request:
47
+ * - `true` — cache with the default (or global) TTL
48
+ * - `false` — always fetch fresh, bypass cache (and don't store the result)
49
+ * - a number — cache with this TTL in milliseconds
50
+ * - an object — `{ ttl, key }` for finer control
51
+ *
52
+ * When unset, the global `cache.enabled` flag decides.
53
+ */
54
+ cache?: boolean | number | { ttl?: number; key?: string };
55
+ /** Alias of `cache: <ms>` (convenience, reads naturally). */
56
+ ttl?: number;
57
+ /**
58
+ * Extra cache namespaces to invalidate when this mutation succeeds.
59
+ * The current collection is always invalidated automatically.
60
+ * @example create({ ... }, { invalidate: ['users'] })
61
+ */
62
+ invalidate?: string[];
63
+ }
64
+
65
+ /** A single cached entry. */
66
+ interface CacheEntry<T = unknown> {
67
+ value: T;
68
+ expiresAt: number;
69
+ /** Namespace (collection name) this entry belongs to — for invalidation. */
70
+ namespace?: string;
71
+ /** Prefix tags (e.g. `getList:posts`) for deleteByPrefix. */
72
+ tags?: string[];
73
+ }
74
+
75
+ /** LRU-ish memory store + optional persistent adapter hybrid. */
76
+ export class CacheStore {
77
+ private memory = new Map<string, CacheEntry>();
78
+ private readonly ttl: number;
79
+ private readonly persistence?: StorageAdapter;
80
+ private readonly maxEntries: number;
81
+ private hits = 0;
82
+ private misses = 0;
83
+ private namespaceEntries = new Map<string, Set<string>>();
84
+ /** Key → set of prefix tags registered for that key (e.g. `getList:posts`). */
85
+ private prefixEntries = new Map<string, Set<string>>();
86
+
87
+ constructor(config: {
88
+ defaultTTL?: number;
89
+ store?: StorageAdapter;
90
+ maxEntries?: number;
91
+ } = {}) {
92
+ this.ttl = config.defaultTTL ?? 60_000;
93
+ this.persistence = config.store;
94
+ this.maxEntries = config.maxEntries ?? 500;
95
+ }
96
+
97
+ /** Resolve the effective TTL: request override → global default. */
98
+ private resolveTTL(ttl?: number): number {
99
+ return ttl && ttl > 0 ? ttl : this.ttl;
100
+ }
101
+
102
+ /**
103
+ * Read a cached value. Fast sync path (memory) with async persistence
104
+ * fallback for adapters whose `get` returns a Promise.
105
+ * @param key Cache key (e.g. `"GET /posts?page=1"`).
106
+ * @returns The cached value, or undefined when absent/expired (the hit is
107
+ * cleared on expiry so a stale value is never served).
108
+ */
109
+ async get<T = unknown>(key: string): Promise<T | undefined> {
110
+ const mem = this.memory.get(key);
111
+ if (mem !== undefined) {
112
+ if (Date.now() > mem.expiresAt) {
113
+ this.delete(key);
114
+ this.misses++;
115
+ return undefined;
116
+ }
117
+ // refresh recency for LRU eviction
118
+ this.memory.delete(key);
119
+ this.memory.set(key, mem);
120
+ this.hits++;
121
+ return mem.value as T;
122
+ }
123
+ if (this.persistence) {
124
+ const entry = await this.readPersisted(key);
125
+ if (entry) {
126
+ if (Date.now() > entry.expiresAt) {
127
+ this.delete(key);
128
+ this.misses++;
129
+ return undefined;
130
+ }
131
+ this.hits++;
132
+ return entry.value as T;
133
+ }
134
+ }
135
+ this.misses++;
136
+ return undefined;
137
+ }
138
+
139
+ /**
140
+ * Store a value.
141
+ * @param key Cache key.
142
+ * @param value The response payload.
143
+ * @param ttlOverride Optional TTL override (ms).
144
+ * @param namespace Optional namespace for group invalidation.
145
+ */
146
+ set(
147
+ key: string,
148
+ value: unknown,
149
+ ttlOverride?: number,
150
+ namespace?: string,
151
+ tags?: string[],
152
+ ): void {
153
+ const expiresAt = Date.now() + this.resolveTTL(ttlOverride);
154
+ const entry: CacheEntry = { value, expiresAt, namespace, tags };
155
+ this.memory.set(key, entry);
156
+
157
+ // LRU eviction when over capacity
158
+ if (this.memory.size > this.maxEntries) {
159
+ const oldest = this.memory.keys().next().value as string | undefined;
160
+ if (oldest !== undefined) this.delete(oldest);
161
+ }
162
+
163
+ if (namespace) {
164
+ let keys = this.namespaceEntries.get(namespace);
165
+ if (!keys) {
166
+ keys = new Set();
167
+ this.namespaceEntries.set(namespace, keys);
168
+ }
169
+ keys.add(key);
170
+ }
171
+
172
+ for (const tag of tags ?? []) {
173
+ let keys = this.prefixEntries.get(tag);
174
+ if (!keys) {
175
+ keys = new Set();
176
+ this.prefixEntries.set(tag, keys);
177
+ }
178
+ keys.add(key);
179
+ }
180
+
181
+ if (this.persistence) {
182
+ void this.persistence.set(this.persistKey(key), JSON.stringify(entry));
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Invalidate entries belonging to a namespace (e.g. a collection name).
188
+ * Also clears the namespace index entry.
189
+ */
190
+ invalidate(namespace: string): void {
191
+ const keys = Array.from(this.namespaceEntries.get(namespace) ?? []);
192
+ for (const key of keys) this.delete(key);
193
+ this.namespaceEntries.delete(namespace);
194
+ }
195
+
196
+ /** Remove a single key. */
197
+ delete(key: string): void {
198
+ const entry = this.memory.get(key);
199
+ if (entry?.namespace) {
200
+ const set = this.namespaceEntries.get(entry.namespace);
201
+ if (set) {
202
+ set.delete(key);
203
+ if (set.size === 0) this.namespaceEntries.delete(entry.namespace);
204
+ }
205
+ }
206
+ for (const tag of entry?.tags ?? []) {
207
+ const set = this.prefixEntries.get(tag);
208
+ if (set) {
209
+ set.delete(key);
210
+ if (set.size === 0) this.prefixEntries.delete(tag);
211
+ }
212
+ }
213
+ this.memory.delete(key);
214
+ if (this.persistence) {
215
+ void this.persistence.remove(this.persistKey(key));
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Delete every entry whose key starts with `prefix`.
221
+ *
222
+ * Useful for fine-grained invalidation, e.g.:
223
+ * ```ts
224
+ * client.cache.deleteByPrefix('getList:posts'); // delete all getList cache
225
+ * client.cache.deleteByPrefix('getOne:posts'); // delete all getOne cache
226
+ * ```
227
+ */
228
+ deleteByPrefix(prefix: string): void {
229
+ if (!prefix) return;
230
+ // exact tag match (fast path — the common `op:collection` case)
231
+ const tagged = this.prefixEntries.get(prefix);
232
+ if (tagged) {
233
+ for (const key of Array.from(tagged)) this.delete(key);
234
+ this.prefixEntries.delete(prefix);
235
+ return;
236
+ }
237
+ // general prefix scan (e.g. `posts` matches any `op:posts`/`GET /posts`)
238
+ for (const key of Array.from(this.memory.keys())) {
239
+ if (key.startsWith(prefix)) this.delete(key);
240
+ }
241
+ }
242
+
243
+ /** Drop every cached entry (memory + persistence). */
244
+ clear(): void {
245
+ this.memory.clear();
246
+ this.namespaceEntries.clear();
247
+ this.prefixEntries.clear();
248
+ // Best-effort: clear all persisted keys via the adapter. The adapter has
249
+ // no list API, so we track a prefix index in memory only — a full
250
+ // persistence wipe is only possible if the adapter supports enumeration.
251
+ // Most use localStorage directly; callers may also recreate the client.
252
+ }
253
+
254
+ /** Cache hit/miss/entry statistics. */
255
+ stats(): { hits: number; misses: number; entries: number } {
256
+ return { hits: this.hits, misses: this.misses, entries: this.memory.size };
257
+ }
258
+
259
+ private persistKey(key: string): string {
260
+ return "lazypock:cache:" + key;
261
+ }
262
+
263
+ private async readPersisted(key: string): Promise<CacheEntry | undefined> {
264
+ if (!this.persistence) return undefined;
265
+ const raw = await this.persistence.get(this.persistKey(key));
266
+ if (raw == null) return undefined;
267
+ try {
268
+ const entry = JSON.parse(raw) as CacheEntry;
269
+ // Re-hydrate a copy in memory (TTL checked by caller)
270
+ this.memory.set(key, entry);
271
+ if (entry.namespace) {
272
+ let keys = this.namespaceEntries.get(entry.namespace);
273
+ if (!keys) {
274
+ keys = new Set();
275
+ this.namespaceEntries.set(entry.namespace, keys);
276
+ }
277
+ keys.add(key);
278
+ }
279
+ for (const tag of entry.tags ?? []) {
280
+ let keys = this.prefixEntries.get(tag);
281
+ if (!keys) {
282
+ keys = new Set();
283
+ this.prefixEntries.set(tag, keys);
284
+ }
285
+ keys.add(key);
286
+ }
287
+ return entry;
288
+ } catch {
289
+ void this.persistence.remove(this.persistKey(key));
290
+ return undefined;
291
+ }
292
+ }
293
+ }
294
+
295
+ // ── Helpers ──
296
+
297
+ /** Resolve per-request cache options into a usable directive. */
298
+ export function resolveCacheDirective(opts?: {
299
+ cache?: boolean | number | { ttl?: number; key?: string };
300
+ ttl?: number;
301
+ }): { enabled: boolean; ttl?: number; key?: string } | null {
302
+ if (!opts) return null;
303
+ // convenience alias: ttl: 5000 → cache for 5s
304
+ if (typeof opts.ttl === "number" && opts.ttl > 0) {
305
+ return { enabled: true, ttl: opts.ttl };
306
+ }
307
+ const c = opts.cache;
308
+ if (c === undefined) return null; // use global enabled flag
309
+ if (c === true) return { enabled: true };
310
+ if (c === false) return { enabled: false };
311
+ if (typeof c === "number") return { enabled: true, ttl: c > 0 ? c : undefined };
312
+ // object form
313
+ return { enabled: true, ttl: c.ttl, key: c.key };
314
+ }
package/src/collection.ts CHANGED
@@ -90,19 +90,42 @@ export class CollectionService<T = ApiRecord> {
90
90
  perPage = 30,
91
91
  options?: Record<string, unknown> & RequestOptions,
92
92
  ): Promise<ListResult<T2> | null> {
93
- const { requestKey, autoCancel, cancelKey, ...rest } = options ?? {};
93
+ const {
94
+ requestKey,
95
+ autoCancel,
96
+ cancelKey,
97
+ fetch,
98
+ headers,
99
+ signal,
100
+ cache,
101
+ ttl,
102
+ invalidate,
103
+ params,
104
+ ...queryParams
105
+ } = options ?? {};
94
106
  const qs = new URLSearchParams(
95
107
  Object.fromEntries(
96
108
  Object.entries({
97
109
  page: String(page),
98
110
  perPage: String(perPage),
99
- ...rest,
111
+ ...queryParams,
100
112
  }).map(([k, v]) => [k, String(v)]),
101
113
  ),
102
114
  ).toString();
103
115
  return this.http.get<ListResult<T2>>(
104
116
  "/" + this.encodeId(this.collectionName) + "?" + qs,
105
- { requestKey, autoCancel, cancelKey },
117
+ {
118
+ requestKey,
119
+ autoCancel,
120
+ cancelKey,
121
+ fetch,
122
+ headers,
123
+ signal,
124
+ cache,
125
+ ttl,
126
+ invalidate,
127
+ params,
128
+ } as RequestOptions,
106
129
  );
107
130
  }
108
131
 
package/src/http.ts CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  import { ApiError, type Method, type RequestOptions } from "./types";
5
5
  import type { AuthStore } from "./auth";
6
+ import type { CacheStore } from "./cache";
7
+ import { resolveCacheDirective } from "./cache";
6
8
 
7
9
  /**
8
10
  * Low-level HTTP client wrapping `fetch` with automatic auth token injection.
@@ -20,6 +22,10 @@ export class HttpClient {
20
22
  private baseUrl: string;
21
23
  private authStore: AuthStore;
22
24
  private defaultFetch: typeof globalThis.fetch;
25
+ /** Optional query cache store (wired when the client enables caching). */
26
+ private cache?: CacheStore;
27
+ /** Master switch resolved from CacheConfig.enabled. */
28
+ private cacheEnabled = false;
23
29
 
24
30
  /**
25
31
  * Abort controllers for in-flight requests, keyed by their cancellation key
@@ -41,6 +47,20 @@ export class HttpClient {
41
47
  this.defaultFetch = globalThis.fetch.bind(globalThis);
42
48
  }
43
49
 
50
+ /**
51
+ * Attach a cache store + master switch.
52
+ * Called by the client constructor when cache config is present.
53
+ */
54
+ setCache(cache: CacheStore, enabled: boolean): void {
55
+ this.cache = cache;
56
+ this.cacheEnabled = enabled;
57
+ }
58
+
59
+ /** Whether the global cache flag is on (requests opt in/out individually too). */
60
+ get cacheIsEnabled(): boolean {
61
+ return this.cacheEnabled;
62
+ }
63
+
44
64
  private async refreshAuth(): Promise<{
45
65
  token: string;
46
66
  record: Record<string, unknown>;
@@ -125,6 +145,16 @@ export class HttpClient {
125
145
  * @throws {ApiError} On non-2xx responses or when the request is aborted
126
146
  * (aborted requests throw an `ApiError` with `isAbort === true`).
127
147
  */
148
+ /** Invalidate a namespace (collection name). No-op when cache is off. */
149
+ invalidateCache(namespace: string): void {
150
+ this.cache?.invalidate(namespace);
151
+ }
152
+
153
+ /** Current cache statistics (hits/misses/entries), or null when disabled. */
154
+ cacheStats(): { hits: number; misses: number; entries: number } | null {
155
+ return this.cache ? this.cache.stats() : null;
156
+ }
157
+
128
158
  async request<T = unknown>(
129
159
  method: Method,
130
160
  path: string,
@@ -136,6 +166,28 @@ export class HttpClient {
136
166
  await this.refreshAuth();
137
167
  }
138
168
 
169
+ // ── Cache resolution (read path) ────────────────────────────────
170
+ // Only cacheable reads (GET) participate. Cache keys are scoped by auth
171
+ // token so user A's cached list can never leak to user B (or anonymous).
172
+ //
173
+ // Effective caching for this request:
174
+ // - per-request { cache } / { ttl } present → use it (true enables,
175
+ // false bypasses, number/object sets TTL)
176
+ // - otherwise → fall back to the global cache.enabled flag
177
+ const cacheDirective = resolveCacheDirective(options);
178
+ const wantCache =
179
+ cacheDirective !== null
180
+ ? cacheDirective.enabled
181
+ : this.cacheEnabled;
182
+ const cacheKey =
183
+ method === "GET" && this.cache && wantCache
184
+ ? this.cacheKeyFor(method, path, options?.params)
185
+ : null;
186
+ if (cacheKey !== null) {
187
+ const hit = await this.cache?.get(cacheKey);
188
+ if (hit !== undefined) return hit as T;
189
+ }
190
+
139
191
  // Resolve the auto-cancellation key (PocketBase `requestKey` semantics):
140
192
  // - options.requestKey null → disabled for this request
141
193
  // - options.requestKey string → use it verbatim
@@ -253,9 +305,83 @@ export class HttpClient {
253
305
  );
254
306
  }
255
307
 
308
+ // ── Cache store (read path) ─────────────────────────────────────
309
+ // Persist successful GET payloads when the directive wants caching.
310
+ if (cacheKey !== null && this.cache) {
311
+ const namespace = this.namespaceFromPath(path);
312
+ const ttl = cacheDirective?.ttl;
313
+ const tags = this.cacheTagsFor(path, namespace);
314
+ this.cache.set(
315
+ cacheKey,
316
+ data,
317
+ ttl,
318
+ namespace ?? undefined,
319
+ tags,
320
+ );
321
+ }
322
+
323
+ // ── Cache invalidation (write path) ────────────────────────────
324
+ // Mutations invalidate the affected collection's cached entries so
325
+ // subsequent reads don't serve stale lists. The current collection is
326
+ // always invalidated; `options.invalidate` adds extra namespaces.
327
+ if (method !== "GET" && this.cache) {
328
+ const namespaces = new Set<string>();
329
+ const ns = this.namespaceFromPath(path);
330
+ if (ns) namespaces.add(ns);
331
+ for (const extra of options?.invalidate ?? []) {
332
+ if (extra) namespaces.add(extra);
333
+ }
334
+ for (const nsName of namespaces) this.cache.invalidate(nsName);
335
+ }
336
+
256
337
  return data as T;
257
338
  }
258
339
 
340
+ // ── Cache key/namespace helpers ──
341
+
342
+ /** Build a token-scoped cache key: `METHOD path|token-hash|params`. */
343
+ private cacheKeyFor(
344
+ method: Method,
345
+ path: string,
346
+ params?: Record<string, string>,
347
+ ): string {
348
+ const token = this.authStore.token || "anon";
349
+ const qs = params ? "?" + new URLSearchParams(params).toString() : "";
350
+ return `${method} ${path}${qs}|${token}`;
351
+ }
352
+
353
+ /** Best-effort namespace (collection name) from a REST path. */
354
+ private namespaceFromPath(path: string): string | undefined {
355
+ // /posts/abc-123 → posts ; /collections/xyz → collections
356
+ // strip any query string first (/posts?page=1 → /posts)
357
+ const clean = path.split("?")[0];
358
+ const parts = clean.split("/").filter(Boolean);
359
+ if (parts.length === 0) return undefined;
360
+ if (parts[0] === "collections" || parts[0] === "_superusers") {
361
+ return parts[0];
362
+ }
363
+ return parts[0];
364
+ }
365
+
366
+ /**
367
+ * Semantic prefix tags for `deleteByPrefix`, derived from the REST shape:
368
+ * - `/{collection}?...` → `getList:{collection}`
369
+ * - `/{collection}/{id}` → `getOne:{collection}`
370
+ * - `/collections?...` / `/collections/{id}` → `collections:getList` / `collections:getOne`
371
+ */
372
+ private cacheTagsFor(path: string, namespace: string | undefined): string[] {
373
+ if (!namespace) return [];
374
+ const clean = path.split("?")[0];
375
+ const parts = clean.split("/").filter(Boolean);
376
+ if (parts[0] === "collections" || parts[0] === "_superusers") {
377
+ const op = parts.length >= 2 ? "getOne" : "getList";
378
+ return [`${namespace}:${op}`];
379
+ }
380
+ // /posts (list) vs /posts/{id} (one)
381
+ const op = parts.length >= 2 ? "getOne" : "getList";
382
+ return [`${op}:${namespace}`];
383
+ }
384
+
259
385
  /**
260
386
  * HTTP GET.
261
387
  * @param path URL path.
package/src/index.ts CHANGED
@@ -23,6 +23,8 @@ export {
23
23
  fieldTypeScriptType,
24
24
  fieldTypeKind,
25
25
  schemaFieldType,
26
+ CacheStore,
27
+ resolveCacheDirective,
26
28
  } from "./lazypock";
27
29
  export { TypedClient, createClient } from "./client";
28
30
 
@@ -38,6 +40,9 @@ export type {
38
40
  SystemFields,
39
41
  RequestOptions,
40
42
  FileRecord,
43
+ // cache
44
+ CacheConfig,
45
+ CacheRequestOptions,
41
46
  // schema
42
47
  CollectionSchema,
43
48
  SchemaField,