lazypock 0.3.0 → 0.5.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/lazypock.ts CHANGED
@@ -19,6 +19,13 @@ import {
19
19
  type UpdateData,
20
20
  type SystemFields,
21
21
  type RequestOptions,
22
+ type ListOptions,
23
+ type ReadOptions,
24
+ type FilterString,
25
+ type SortString,
26
+ type ExpandString,
27
+ type FilterOp,
28
+ type FieldKey,
22
29
  } from "./types";
23
30
  import { RealtimeService, wsUrlFromBaseUrl } from "./realtime";
24
31
  import {
@@ -32,8 +39,6 @@ import { CollectionsService } from "./collections";
32
39
  import type { CollectionSchema, SchemaField } from "./schema";
33
40
  import { generateTypes, collectionTypeName } from "./codegen";
34
41
  import { fieldTypeScriptType, fieldTypeKind, schemaFieldType } from "./typegen";
35
- import { CacheStore, type CacheConfig, type CacheRequestOptions } from "./cache";
36
- import { resolveCacheDirective } from "./cache";
37
42
 
38
43
  export {
39
44
  AuthStore,
@@ -66,26 +71,15 @@ export type {
66
71
  FileRecord,
67
72
  CollectionSchema,
68
73
  SchemaField,
74
+ // schema-driven query typing
75
+ ListOptions,
76
+ ReadOptions,
77
+ FilterString,
78
+ SortString,
79
+ ExpandString,
80
+ FilterOp,
81
+ FieldKey,
69
82
  };
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
- }
89
83
 
90
84
  /** Options for constructing a {@link LazypockClient}. */
91
85
  export interface LazypockClientOptions {
@@ -97,24 +91,6 @@ export interface LazypockClientOptions {
97
91
  authStore?: AuthStore;
98
92
  /** Real-time service for Phoenix Channel WebSocket subscriptions */
99
93
  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;
118
94
  /**
119
95
  * Optional schema types for generating typed services at runtime.
120
96
  * When provided, `collection()` returns a service whose create/update
@@ -149,9 +125,6 @@ export class LazypockClient {
149
125
  readonly files: FilesService;
150
126
  private collectionCache = new Map<string, CollectionService>();
151
127
  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>();
155
128
 
156
129
  /**
157
130
  * Create a new Lazypock client.
@@ -169,14 +142,6 @@ export class LazypockClient {
169
142
  }
170
143
  this.files = new FilesService(this.http);
171
144
  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
- }
180
145
  if (options.types?.schemas) {
181
146
  this.schemaByName = new Map(
182
147
  options.types.schemas.map((s) => [s.name, s]),
@@ -195,7 +160,7 @@ export class LazypockClient {
195
160
  * @param name The collection name.
196
161
  * @returns A {@link CollectionService} instance.
197
162
  */
198
- collection(name: string): CollectionService<unknown> {
163
+ collection(name: string, schema?: CollectionSchema): CollectionService<unknown> {
199
164
  let svc = this.collectionCache.get(name);
200
165
  if (!svc) {
201
166
  svc = new CollectionService(
@@ -203,6 +168,8 @@ export class LazypockClient {
203
168
  name,
204
169
  this.authStore,
205
170
  this.realtime,
171
+ // Prefer the explicit schema, then the client-level `types.schemas`.
172
+ schema ?? this.schemaByName?.get(name),
206
173
  );
207
174
  this.collectionCache.set(name, svc);
208
175
  }
@@ -265,100 +232,6 @@ export class LazypockClient {
265
232
  return this;
266
233
  }
267
234
 
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
-
362
235
  // ── Auth ──
363
236
 
364
237
  /** Check whether any superuser exists (for login vs setup screen routing). */
@@ -418,7 +291,7 @@ export class LazypockClient {
418
291
  this.authStore.setCollectionName(collection);
419
292
  this.authStore.set(data.token, data.record as unknown as AuthModel);
420
293
  }
421
- } else {
294
+ } else {
422
295
  // Superuser login — PocketBase parity: `_superusers` is an auth collection.
423
296
  // Falls back to the legacy /superusers/login for older servers.
424
297
  try {
package/src/types.ts CHANGED
@@ -65,18 +65,6 @@ 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[];
80
68
  /**
81
69
  * Request identifier used by the auto-cancellation mechanism.
82
70
  *
@@ -99,6 +87,17 @@ export interface RequestOptions {
99
87
  * Alias of `requestKey` (PocketBase `$cancelKey` compat).
100
88
  */
101
89
  cancelKey?: string;
90
+ /**
91
+ * Coalesce concurrent identical requests (same `requestKey`) onto a single
92
+ * in-flight promise instead of aborting the earlier one.
93
+ *
94
+ * When enabled, a request arriving while another with the same key is still
95
+ * pending awaits the same result — no duplicate network request, and the
96
+ * caller of the first request never sees an abort rejection.
97
+ *
98
+ * @default false (auto-cancellation aborts the earlier duplicate)
99
+ */
100
+ singleFlight?: boolean;
102
101
  }
103
102
 
104
103
  export class ApiError extends Error {
@@ -119,3 +118,107 @@ export class ApiError extends Error {
119
118
  this.isAbort = isAbort;
120
119
  }
121
120
  }
121
+
122
+ // ── Schema-driven query typing ─────────────────────────
123
+ // Template-literal helpers that turn a concrete record shape `T` (e.g. a
124
+ // codegen-generated interface) into validated/suggestable query strings:
125
+ // - select("title", "published") → field names checked
126
+ // - getList(1, 20, { sort: "-created" }) → "created" suggested
127
+ // - getList(1, 20, { filter: "title ~ 'x'" }) → field + operator suggested
128
+ // - getList(1, 20, { expand: "author" }) → field suggested (schema-checked at runtime)
129
+ // When `T` is the untyped {@link ApiRecord}, every helper degrades to `string`.
130
+
131
+ /**
132
+ * String keys of a record shape (excludes methods/symbols).
133
+ * Falls back to `string` when `T` has no known keys (e.g. `unknown`), so
134
+ * untyped services accept any field name.
135
+ */
136
+ export type FieldKey<T> = Extract<keyof T, string> extends never
137
+ ? string
138
+ : Extract<keyof T, string>;
139
+
140
+ /** Valid filter operators, matching the backend FilterCompiler. */
141
+ export type FilterOp =
142
+ | "="
143
+ | "!="
144
+ | "~"
145
+ | "!~"
146
+ | ">"
147
+ | ">="
148
+ | "<"
149
+ | "<=";
150
+
151
+ /** One `field op value` clause. */
152
+ type FilterClause<T> = `${FieldKey<T>} ${FilterOp} ${string}`;
153
+
154
+ /**
155
+ * Type-checked filter expression (PocketBase syntax).
156
+ *
157
+ * ```ts
158
+ * getList(1, 20, { filter: "title ~ 'x' && published = true" })
159
+ * getList(1, 20, { filter: "(title = 'a' || title = 'b')" })
160
+ * ```
161
+ *
162
+ * The first clause's field name + operator are validated; the rest of the
163
+ * expression (values, `&&`/`||`, parens, `!`) is free-form.
164
+ */
165
+ export type FilterString<T> =
166
+ | FilterClause<T>
167
+ | `${FilterClause<T>}${string}`;
168
+
169
+ /** A single `[+|-]field` sort token. */
170
+ type SortField<T> = `${"" | "-" | "+"}${FieldKey<T>}`;
171
+
172
+ /**
173
+ * Type-checked sort string: `field`, `-field` (desc), `+field`, or
174
+ * comma-separated combinations (e.g. `"-created,title"`).
175
+ */
176
+ export type SortString<T> = SortField<T> | `${SortField<T>},${string}`;
177
+
178
+ /**
179
+ * Type-checked expand string: comma-separated relation field names
180
+ * (e.g. `"author"` or `"author,category"`). Non-relation fields are
181
+ * warned about at runtime when a schema is available.
182
+ */
183
+ export type ExpandString<T> = FieldKey<T> | `${FieldKey<T>},${string}`;
184
+
185
+ /**
186
+ * Query options for list/read operations, typed against a record shape `T`.
187
+ *
188
+ * `filter`, `sort`, `expand` and `fields` are recognized; any other key is
189
+ * passed through as a raw query parameter (PocketBase-compatible).
190
+ */
191
+ export interface ListOptions<T = ApiRecord> {
192
+ /**
193
+ * PocketBase filter expression. Field names + operators are type-checked
194
+ * when `T` is a concrete shape.
195
+ */
196
+ filter?: FilterString<T>;
197
+ /**
198
+ * Sort field(s): `field`, `-field` (descending), comma-separated.
199
+ */
200
+ sort?: SortString<T>;
201
+ /**
202
+ * Comma-separated relation field names to expand.
203
+ */
204
+ expand?: ExpandString<T>;
205
+ /**
206
+ * Explicit field projection (overrides {@link CollectionService.select}).
207
+ */
208
+ fields?: string;
209
+ /**
210
+ * Raw query parameters — any other key is passed through verbatim.
211
+ */
212
+ [key: string]: unknown;
213
+ }
214
+
215
+ /**
216
+ * Options for single-record reads (`getOne`): expand + explicit fields.
217
+ */
218
+ export interface ReadOptions<T = ApiRecord> {
219
+ /** Comma-separated relation field names to expand. */
220
+ expand?: ExpandString<T>;
221
+ /** Explicit field projection (overrides {@link CollectionService.select}). */
222
+ fields?: string;
223
+ }
224
+
package/src/cache.ts DELETED
@@ -1,314 +0,0 @@
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
- }