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/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
@@ -28,6 +34,14 @@ export class HttpClient {
28
34
  */
29
35
  private cancelControllers: Record<string, AbortController> = {};
30
36
 
37
+ /**
38
+ * In-flight request promises, keyed by cancellation key. When auto-cancellation
39
+ * would abort a pending duplicate, the newer request instead awaits the same
40
+ * promise — single-flight coalescing (no duplicate network request, no
41
+ * spurious abort rejection for the caller).
42
+ */
43
+ private inflight: Record<string, Promise<unknown>> = {};
44
+
31
45
  /** Global toggle for the auto-cancellation behaviour (default: on). */
32
46
  private enableAutoCancellation = true;
33
47
 
@@ -41,6 +55,20 @@ export class HttpClient {
41
55
  this.defaultFetch = globalThis.fetch.bind(globalThis);
42
56
  }
43
57
 
58
+ /**
59
+ * Attach a cache store + master switch.
60
+ * Called by the client constructor when cache config is present.
61
+ */
62
+ setCache(cache: CacheStore, enabled: boolean): void {
63
+ this.cache = cache;
64
+ this.cacheEnabled = enabled;
65
+ }
66
+
67
+ /** Whether the global cache flag is on (requests opt in/out individually too). */
68
+ get cacheIsEnabled(): boolean {
69
+ return this.cacheEnabled;
70
+ }
71
+
44
72
  private async refreshAuth(): Promise<{
45
73
  token: string;
46
74
  record: Record<string, unknown>;
@@ -125,6 +153,16 @@ export class HttpClient {
125
153
  * @throws {ApiError} On non-2xx responses or when the request is aborted
126
154
  * (aborted requests throw an `ApiError` with `isAbort === true`).
127
155
  */
156
+ /** Invalidate a namespace (collection name). No-op when cache is off. */
157
+ invalidateCache(namespace: string): void {
158
+ this.cache?.invalidate(namespace);
159
+ }
160
+
161
+ /** Current cache statistics (hits/misses/entries), or null when disabled. */
162
+ cacheStats(): { hits: number; misses: number; entries: number } | null {
163
+ return this.cache ? this.cache.stats() : null;
164
+ }
165
+
128
166
  async request<T = unknown>(
129
167
  method: Method,
130
168
  path: string,
@@ -136,6 +174,28 @@ export class HttpClient {
136
174
  await this.refreshAuth();
137
175
  }
138
176
 
177
+ // ── Cache resolution (read path) ────────────────────────────────
178
+ // Only cacheable reads (GET) participate. Cache keys are scoped by auth
179
+ // token so user A's cached list can never leak to user B (or anonymous).
180
+ //
181
+ // Effective caching for this request:
182
+ // - per-request { cache } / { ttl } present → use it (true enables,
183
+ // false bypasses, number/object sets TTL)
184
+ // - otherwise → fall back to the global cache.enabled flag
185
+ const cacheDirective = resolveCacheDirective(options);
186
+ const wantCache =
187
+ cacheDirective !== null
188
+ ? cacheDirective.enabled
189
+ : this.cacheEnabled;
190
+ const cacheKey =
191
+ method === "GET" && this.cache && wantCache
192
+ ? this.cacheKeyFor(method, path, options?.params)
193
+ : null;
194
+ if (cacheKey !== null) {
195
+ const hit = await this.cache?.get(cacheKey);
196
+ if (hit !== undefined) return hit as T;
197
+ }
198
+
139
199
  // Resolve the auto-cancellation key (PocketBase `requestKey` semantics):
140
200
  // - options.requestKey null → disabled for this request
141
201
  // - options.requestKey string → use it verbatim
@@ -148,6 +208,16 @@ export class HttpClient {
148
208
  : options.requestKey;
149
209
  if (options?.autoCancel === false) requestKey = null;
150
210
 
211
+ // Single-flight coalescing: when the same requestKey is already in-flight
212
+ // and the caller opted in, reuse that promise instead of firing a duplicate
213
+ // request (no abort rejection for either caller).
214
+ if (options?.singleFlight && requestKey !== null) {
215
+ const pending = this.inflight[requestKey];
216
+ if (pending !== undefined) {
217
+ return pending as Promise<T | null>;
218
+ }
219
+ }
220
+
151
221
  // Wire a fresh AbortController for this request, merging any caller signal.
152
222
  // When auto-cancellation is enabled, the previous pending request sharing
153
223
  // our key is aborted first (only the last duplicate executes).
@@ -169,6 +239,52 @@ export class HttpClient {
169
239
  }
170
240
  const signal = controller?.signal ?? externalSignal;
171
241
 
242
+ // Register the in-flight promise so later single-flight callers reuse it.
243
+ // The promise is created from an inner async fn that performs the request
244
+ // and clears itself from the inflight map on settle.
245
+ const perform = async (): Promise<T | null> => {
246
+ try {
247
+ return await this.doRequest(
248
+ method,
249
+ path,
250
+ body,
251
+ options,
252
+ signal,
253
+ requestKey,
254
+ controller,
255
+ cacheKey,
256
+ cacheDirective,
257
+ );
258
+ } finally {
259
+ if (requestKey !== null) {
260
+ if (this.inflight[requestKey] === promise) {
261
+ delete this.inflight[requestKey];
262
+ }
263
+ }
264
+ }
265
+ };
266
+ const promise = perform();
267
+ if (requestKey !== null) {
268
+ this.inflight[requestKey] = promise;
269
+ }
270
+ return promise;
271
+ }
272
+
273
+ /**
274
+ * Execute the actual HTTP request (fetch + parse + cache). Called by {@link request}
275
+ * as the inner in-flight unit so single-flight callers can reuse the promise.
276
+ */
277
+ private async doRequest<T = unknown>(
278
+ method: Method,
279
+ path: string,
280
+ body: unknown,
281
+ options: RequestOptions | undefined,
282
+ signal: AbortSignal | null | undefined,
283
+ requestKey: string | null,
284
+ controller: AbortController | null,
285
+ cacheKey: string | null,
286
+ cacheDirective: ReturnType<typeof resolveCacheDirective>,
287
+ ): Promise<T | null> {
172
288
  let url = this.baseUrl + path;
173
289
  if (options?.params) {
174
290
  const qs = new URLSearchParams(options.params).toString();
@@ -253,9 +369,83 @@ export class HttpClient {
253
369
  );
254
370
  }
255
371
 
372
+ // ── Cache store (read path) ─────────────────────────────────────
373
+ // Persist successful GET payloads when the directive wants caching.
374
+ if (cacheKey !== null && this.cache) {
375
+ const namespace = this.namespaceFromPath(path);
376
+ const ttl = cacheDirective?.ttl;
377
+ const tags = this.cacheTagsFor(path, namespace);
378
+ this.cache.set(
379
+ cacheKey,
380
+ data,
381
+ ttl,
382
+ namespace ?? undefined,
383
+ tags,
384
+ );
385
+ }
386
+
387
+ // ── Cache invalidation (write path) ────────────────────────────
388
+ // Mutations invalidate the affected collection's cached entries so
389
+ // subsequent reads don't serve stale lists. The current collection is
390
+ // always invalidated; `options.invalidate` adds extra namespaces.
391
+ if (method !== "GET" && this.cache) {
392
+ const namespaces = new Set<string>();
393
+ const ns = this.namespaceFromPath(path);
394
+ if (ns) namespaces.add(ns);
395
+ for (const extra of options?.invalidate ?? []) {
396
+ if (extra) namespaces.add(extra);
397
+ }
398
+ for (const nsName of namespaces) this.cache.invalidate(nsName);
399
+ }
400
+
256
401
  return data as T;
257
402
  }
258
403
 
404
+ // ── Cache key/namespace helpers ──
405
+
406
+ /** Build a token-scoped cache key: `METHOD path|token-hash|params`. */
407
+ private cacheKeyFor(
408
+ method: Method,
409
+ path: string,
410
+ params?: Record<string, string>,
411
+ ): string {
412
+ const token = this.authStore.token || "anon";
413
+ const qs = params ? "?" + new URLSearchParams(params).toString() : "";
414
+ return `${method} ${path}${qs}|${token}`;
415
+ }
416
+
417
+ /** Best-effort namespace (collection name) from a REST path. */
418
+ private namespaceFromPath(path: string): string | undefined {
419
+ // /posts/abc-123 → posts ; /collections/xyz → collections
420
+ // strip any query string first (/posts?page=1 → /posts)
421
+ const clean = path.split("?")[0];
422
+ const parts = clean.split("/").filter(Boolean);
423
+ if (parts.length === 0) return undefined;
424
+ if (parts[0] === "collections" || parts[0] === "_superusers") {
425
+ return parts[0];
426
+ }
427
+ return parts[0];
428
+ }
429
+
430
+ /**
431
+ * Semantic prefix tags for `deleteByPrefix`, derived from the REST shape:
432
+ * - `/{collection}?...` → `getList:{collection}`
433
+ * - `/{collection}/{id}` → `getOne:{collection}`
434
+ * - `/collections?...` / `/collections/{id}` → `collections:getList` / `collections:getOne`
435
+ */
436
+ private cacheTagsFor(path: string, namespace: string | undefined): string[] {
437
+ if (!namespace) return [];
438
+ const clean = path.split("?")[0];
439
+ const parts = clean.split("/").filter(Boolean);
440
+ if (parts[0] === "collections" || parts[0] === "_superusers") {
441
+ const op = parts.length >= 2 ? "getOne" : "getList";
442
+ return [`${namespace}:${op}`];
443
+ }
444
+ // /posts (list) vs /posts/{id} (one)
445
+ const op = parts.length >= 2 ? "getOne" : "getList";
446
+ return [`${op}:${namespace}`];
447
+ }
448
+
259
449
  /**
260
450
  * HTTP GET.
261
451
  * @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,
package/src/lazypock.ts CHANGED
@@ -32,6 +32,8 @@ import { CollectionsService } from "./collections";
32
32
  import type { CollectionSchema, SchemaField } from "./schema";
33
33
  import { generateTypes, collectionTypeName } from "./codegen";
34
34
  import { fieldTypeScriptType, fieldTypeKind, schemaFieldType } from "./typegen";
35
+ import { CacheStore, type CacheConfig, type CacheRequestOptions } from "./cache";
36
+ import { resolveCacheDirective } from "./cache";
35
37
 
36
38
  export {
37
39
  AuthStore,
@@ -65,6 +67,25 @@ export type {
65
67
  CollectionSchema,
66
68
  SchemaField,
67
69
  };
70
+ export type { CacheConfig, CacheRequestOptions };
71
+ export { CacheStore, resolveCacheDirective };
72
+
73
+ /**
74
+ * Callable cache namespace: `client.cache(config)` configures, and
75
+ * `client.cache.deleteByPrefix(...)` etc. manage cached entries.
76
+ */
77
+ export interface CacheController {
78
+ /** Configure the query cache at runtime. */
79
+ (config?: CacheConfig): LazypockClient;
80
+ /** Delete every entry whose key starts with `prefix` (e.g. `getList:posts`). */
81
+ deleteByPrefix(prefix: string): void;
82
+ /** Invalidate a collection's cached entries (alias of invalidateCache). */
83
+ invalidate(namespace: string): void;
84
+ /** Drop every cached entry. */
85
+ clear(): void;
86
+ /** Cache hit/miss/entry stats, or null when never configured. */
87
+ stats(): { hits: number; misses: number; entries: number } | null;
88
+ }
68
89
 
69
90
  /** Options for constructing a {@link LazypockClient}. */
70
91
  export interface LazypockClientOptions {
@@ -76,6 +97,24 @@ export interface LazypockClientOptions {
76
97
  authStore?: AuthStore;
77
98
  /** Real-time service for Phoenix Channel WebSocket subscriptions */
78
99
  realtime?: RealtimeService;
100
+ /**
101
+ * Query cache configuration. Disabled by default.
102
+ *
103
+ * ```ts
104
+ * const client = createClient({
105
+ * baseUrl: '...',
106
+ * cache: {
107
+ * enabled: true,
108
+ * defaultTTL: 30_000,
109
+ * store: myStorage, // optional persistence (same interface as auth)
110
+ * },
111
+ * });
112
+ * ```
113
+ *
114
+ * When enabled, readable GETs are cached. Requests can opt out via
115
+ * `{ cache: false }`, or opt in with a custom TTL via `{ ttl: ms }`.
116
+ */
117
+ cache?: CacheConfig;
79
118
  /**
80
119
  * Optional schema types for generating typed services at runtime.
81
120
  * When provided, `collection()` returns a service whose create/update
@@ -110,6 +149,9 @@ export class LazypockClient {
110
149
  readonly files: FilesService;
111
150
  private collectionCache = new Map<string, CollectionService>();
112
151
  private schemaByName?: Map<string, CollectionSchema>;
152
+ private cacheStore?: CacheStore;
153
+ /** Namespace → realtime unsubscribe; used for realtime-driven invalidation. */
154
+ private realtimeInvalidators = new Map<string, () => void>();
113
155
 
114
156
  /**
115
157
  * Create a new Lazypock client.
@@ -127,6 +169,14 @@ export class LazypockClient {
127
169
  }
128
170
  this.files = new FilesService(this.http);
129
171
  this.collections = new CollectionsService(this.http, this.realtime);
172
+ if (options.cache) {
173
+ this.cacheStore = new CacheStore({
174
+ defaultTTL: options.cache.defaultTTL,
175
+ store: options.cache.store,
176
+ maxEntries: options.cache.maxEntries,
177
+ });
178
+ this.http.setCache(this.cacheStore, options.cache.enabled ?? false);
179
+ }
130
180
  if (options.types?.schemas) {
131
181
  this.schemaByName = new Map(
132
182
  options.types.schemas.map((s) => [s.name, s]),
@@ -215,6 +265,100 @@ export class LazypockClient {
215
265
  return this;
216
266
  }
217
267
 
268
+ // ── Query cache (opt-in by default; opt-out per request) ──
269
+
270
+ /**
271
+ * Configure the query cache at runtime (also a namespace for cache
272
+ * management methods).
273
+ *
274
+ * ```ts
275
+ * client.cache({ enabled: true, defaultTTL: 30_000 });
276
+ * client.cache.deleteByPrefix('getList:posts'); // all list caches for posts
277
+ * client.cache.deleteByPrefix('getOne:posts'); // all one-record caches
278
+ * ```
279
+ *
280
+ * When enabled, GET requests cache their payload; mutations invalidate the
281
+ * affected collection automatically. Individual requests can opt out with
282
+ * `{ cache: false }` or override the TTL with `{ ttl: ms }`.
283
+ */
284
+ readonly cache: CacheController = Object.assign(
285
+ ((config?: CacheConfig) => {
286
+ if (!this.cacheStore) {
287
+ // Lazy-create so `.cache({ enabled: true })` works even when the
288
+ // constructor wasn't given cache config.
289
+ this.cacheStore = new CacheStore({
290
+ defaultTTL: config?.defaultTTL,
291
+ store: config?.store,
292
+ maxEntries: config?.maxEntries,
293
+ });
294
+ this.http.setCache(this.cacheStore, config?.enabled ?? true);
295
+ } else {
296
+ if (config?.enabled !== undefined) {
297
+ this.http.setCache(this.cacheStore, config.enabled);
298
+ }
299
+ }
300
+ return this;
301
+ }) as (config?: CacheConfig) => LazypockClient,
302
+ {
303
+ deleteByPrefix: (prefix: string) => this.cacheStore?.deleteByPrefix(prefix),
304
+ invalidate: (namespace: string) => this.cacheStore?.invalidate(namespace),
305
+ clear: () => {
306
+ this.cacheStore?.clear();
307
+ for (const unsub of this.realtimeInvalidators.values()) unsub();
308
+ this.realtimeInvalidators.clear();
309
+ },
310
+ stats: () => (this.cacheStore ? this.cacheStore.stats() : null),
311
+ },
312
+ );
313
+
314
+ /**
315
+ * Drop every cached entry (all collections / namespaces).
316
+ * Also disables realtime-driven invalidation subscriptions.
317
+ */
318
+ clearCache(): this {
319
+ this.cacheStore?.clear();
320
+ for (const unsub of this.realtimeInvalidators.values()) unsub();
321
+ this.realtimeInvalidators.clear();
322
+ return this;
323
+ }
324
+
325
+ /**
326
+ * Invalidate cached entries for a collection (or custom namespace).
327
+ * Runs automatically on mutations — call explicitly when data changed
328
+ * out-of-band (e.g. another client wrote to the same collection).
329
+ */
330
+ invalidateCache(namespace: string): this {
331
+ this.cacheStore?.invalidate(namespace);
332
+ return this;
333
+ }
334
+
335
+ /**
336
+ * Cache hit/miss/entry statistics.
337
+ * Returns null when caching was never configured.
338
+ */
339
+ cacheStats(): { hits: number; misses: number; entries: number } | null {
340
+ return this.cacheStore ? this.cacheStore.stats() : null;
341
+ }
342
+
343
+ /**
344
+ * Subscribe a collection's cache to realtime invalidation: any inbound
345
+ * create/update/delete event for the collection clears its cached entries.
346
+ * Returns an unsubscribe function.
347
+ */
348
+ invalidateCacheOnRealtime(collectionName: string): () => void {
349
+ if (!this.cacheStore) {
350
+ // ensure a store exists so invalidation has somewhere to go
351
+ this.cache({ enabled: false });
352
+ }
353
+ const existing = this.realtimeInvalidators.get(collectionName);
354
+ if (existing) return existing;
355
+ const unsub = this.collection(collectionName).subscribe(() => {
356
+ this.cacheStore?.invalidate(collectionName);
357
+ });
358
+ this.realtimeInvalidators.set(collectionName, unsub);
359
+ return unsub;
360
+ }
361
+
218
362
  // ── Auth ──
219
363
 
220
364
  /** Check whether any superuser exists (for login vs setup screen routing). */
package/src/types.ts CHANGED
@@ -65,6 +65,18 @@ export interface RequestOptions {
65
65
  signal?: AbortSignal;
66
66
  /** Custom fetch implementation (for RN or test mocking) */
67
67
  fetch?: typeof globalThis.fetch;
68
+ /**
69
+ * Cache control for this request (see {@link CacheRequestOptions}).
70
+ * Resolved against the client's global cache config when unset.
71
+ */
72
+ cache?: boolean | number | { ttl?: number; key?: string };
73
+ /** Alias of `cache: <ms>` — cache this GET for `ttl` milliseconds. */
74
+ ttl?: number;
75
+ /**
76
+ * Extra cache namespaces to invalidate when this mutation succeeds.
77
+ * The current collection is always invalidated automatically.
78
+ */
79
+ invalidate?: string[];
68
80
  /**
69
81
  * Request identifier used by the auto-cancellation mechanism.
70
82
  *
@@ -87,6 +99,17 @@ export interface RequestOptions {
87
99
  * Alias of `requestKey` (PocketBase `$cancelKey` compat).
88
100
  */
89
101
  cancelKey?: string;
102
+ /**
103
+ * Coalesce concurrent identical requests (same `requestKey`) onto a single
104
+ * in-flight promise instead of aborting the earlier one.
105
+ *
106
+ * When enabled, a request arriving while another with the same key is still
107
+ * pending awaits the same result — no duplicate network request, and the
108
+ * caller of the first request never sees an abort rejection.
109
+ *
110
+ * @default false (auto-cancellation aborts the earlier duplicate)
111
+ */
112
+ singleFlight?: boolean;
90
113
  }
91
114
 
92
115
  export class ApiError extends Error {