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/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
@@ -12,6 +12,36 @@ import type {
12
12
  } from "./types";
13
13
  import type { RealtimeService } from "./realtime";
14
14
 
15
+ /**
16
+ * Deterministic JSON stringify for building a stable cache/dedup key.
17
+ * Strips request-transport keys (`requestKey`, `singleFlight`, `fetch`,
18
+ * `signal`) so functionally-identical calls share one key.
19
+ */
20
+ export function stableStringify(value: unknown): string {
21
+ const seen = new Set<object>();
22
+ const sort = (v: unknown): unknown => {
23
+ if (Array.isArray(v)) return v.map(sort);
24
+ if (v && typeof v === "object") {
25
+ if (seen.has(v as object)) return "[Circular]";
26
+ seen.add(v as object);
27
+ const out: Record<string, unknown> = {};
28
+ for (const k of Object.keys(v as object).sort()) {
29
+ if (k === "requestKey" || k === "singleFlight" || k === "fetch" || k === "signal") {
30
+ continue;
31
+ }
32
+ out[k] = sort((v as Record<string, unknown>)[k]);
33
+ }
34
+ return out;
35
+ }
36
+ return v;
37
+ };
38
+ try {
39
+ return JSON.stringify(sort(value));
40
+ } catch {
41
+ return String(value);
42
+ }
43
+ }
44
+
15
45
  /**
16
46
  * A realtime record-change event delivered to subscription callbacks.
17
47
  * Mirrors PocketBase's RealtimeService result shape (`action` + `record`).
@@ -90,19 +120,44 @@ export class CollectionService<T = ApiRecord> {
90
120
  perPage = 30,
91
121
  options?: Record<string, unknown> & RequestOptions,
92
122
  ): Promise<ListResult<T2> | null> {
93
- const { requestKey, autoCancel, cancelKey, ...rest } = options ?? {};
123
+ const {
124
+ requestKey,
125
+ autoCancel,
126
+ cancelKey,
127
+ fetch,
128
+ headers,
129
+ signal,
130
+ cache,
131
+ ttl,
132
+ invalidate,
133
+ singleFlight,
134
+ params,
135
+ ...queryParams
136
+ } = options ?? {};
94
137
  const qs = new URLSearchParams(
95
138
  Object.fromEntries(
96
139
  Object.entries({
97
140
  page: String(page),
98
141
  perPage: String(perPage),
99
- ...rest,
142
+ ...queryParams,
100
143
  }).map(([k, v]) => [k, String(v)]),
101
144
  ),
102
145
  ).toString();
103
146
  return this.http.get<ListResult<T2>>(
104
147
  "/" + this.encodeId(this.collectionName) + "?" + qs,
105
- { requestKey, autoCancel, cancelKey },
148
+ {
149
+ requestKey,
150
+ autoCancel,
151
+ cancelKey,
152
+ fetch,
153
+ headers,
154
+ signal,
155
+ cache,
156
+ ttl,
157
+ invalidate,
158
+ singleFlight,
159
+ params,
160
+ } as RequestOptions,
106
161
  );
107
162
  }
108
163
 
@@ -116,6 +171,17 @@ export class CollectionService<T = ApiRecord> {
116
171
  options?: Record<string, unknown> & RequestOptions,
117
172
  ): Promise<Array<T2>> {
118
173
  const { batch = 1000, ...rest } = options ?? {};
174
+
175
+ // Build a stable request key for the whole full-list fetch (NOT per page,
176
+ // which would break dedup). Concurrent identical getFullList() calls share
177
+ // this key via single-flight, so they don't fire duplicate requests. Pages
178
+ // still advance correctly: each page's URL differs (page=N in the query),
179
+ // so the underlying default key is unique per page — no cross-page cancel.
180
+ const effectiveKey =
181
+ typeof rest.requestKey === "string"
182
+ ? rest.requestKey
183
+ : `getFullList:${this.collectionName}:${stableStringify(rest)}`;
184
+
119
185
  const items: T2[] = [];
120
186
  let page = 1;
121
187
  for (;;) {
@@ -123,9 +189,9 @@ export class CollectionService<T = ApiRecord> {
123
189
  page,
124
190
  batch as number,
125
191
  {
126
- // disable auto-cancellation across pages — each page request is unique
127
192
  ...rest,
128
- requestKey: null,
193
+ requestKey: effectiveKey,
194
+ singleFlight: true,
129
195
  } as Record<string, unknown> & RequestOptions,
130
196
  );
131
197
  if (!res || !res.items || res.items.length === 0) break;
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { HttpClient } from "./http";
14
14
  import type { RealtimeService } from "./realtime";
15
+ import { stableStringify } from "./collection";
15
16
  import type { ListResult, RequestOptions, ApiRecord } from "./types";
16
17
 
17
18
  const REGISTRY_TOPIC = "collections";
@@ -74,15 +75,50 @@ export class CollectionsService {
74
75
  ): Promise<Array<T>> {
75
76
  if (!this.http) return [];
76
77
  const { batch = 1000, ...rest } = options ?? {};
78
+
79
+ // Extract request-transport options so they never leak into query params.
80
+ const {
81
+ requestKey: reqKey,
82
+ singleFlight: _singleFlight,
83
+ fetch: fetchFn,
84
+ headers: hdrs,
85
+ signal: sig,
86
+ cache: cacheOpt,
87
+ ttl: ttlOpt,
88
+ invalidate: inval,
89
+ params: passthroughParams,
90
+ ...queryParams
91
+ } = rest as Record<string, unknown> & RequestOptions;
92
+
93
+ // Stable key for the whole full-list fetch — see CollectionService.getFullList
94
+ // for the rationale (single-flight dedup, per-page paths stay unique).
95
+ const effectiveKey =
96
+ typeof reqKey === "string"
97
+ ? reqKey
98
+ : `getFullList:collections:${stableStringify(rest)}`;
99
+
77
100
  const items: T[] = [];
78
101
  let page = 1;
79
102
  // Auto-paginate until empty (bounded by perPage and totalPages).
80
103
  for (;;) {
81
- const res = await this.getList<T>({
82
- ...rest,
83
- page,
84
- perPage: batch,
85
- } as Record<string, unknown>);
104
+ const res = await this.getList<T>(
105
+ {
106
+ ...queryParams,
107
+ ...(passthroughParams ?? {}),
108
+ page,
109
+ perPage: batch,
110
+ },
111
+ {
112
+ requestKey: effectiveKey,
113
+ singleFlight: true,
114
+ ...(fetchFn ? { fetch: fetchFn } : {}),
115
+ ...(hdrs ? { headers: hdrs } : {}),
116
+ ...(sig ? { signal: sig } : {}),
117
+ ...(cacheOpt !== undefined ? { cache: cacheOpt } : {}),
118
+ ...(ttlOpt !== undefined ? { ttl: ttlOpt } : {}),
119
+ ...(inval ? { invalidate: inval } : {}),
120
+ } as RequestOptions,
121
+ );
86
122
  if (!res || !res.items || res.items.length === 0) break;
87
123
  items.push(...(res.items as T[]));
88
124
  if (page >= (res.totalPages ?? page)) break;