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/README.md +75 -98
- package/dist/{chunk-Y6PMBL2S.js → chunk-HOBNUW5Z.js} +27 -2
- package/dist/{chunk-Y6PMBL2S.js.map → chunk-HOBNUW5Z.js.map} +1 -1
- package/dist/cli.cjs +26 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.global.js +26 -1
- package/dist/cli.global.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +248 -397
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +182 -296
- package/dist/index.d.ts +182 -296
- package/dist/index.global.js +248 -395
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +223 -395
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen.ts +34 -1
- package/src/collection.ts +203 -15
- package/src/collections.ts +35 -5
- package/src/http.ts +60 -126
- package/src/index.ts +8 -5
- package/src/lazypock.ts +19 -146
- package/src/types.ts +115 -12
- package/src/cache.ts +0 -314
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
|
-
|
|
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
|
-
}
|