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/codegen.ts CHANGED
@@ -106,6 +106,27 @@ export function generateTypes(
106
106
  ${mapEntries}
107
107
  }`);
108
108
 
109
+ // Runtime schema snapshot — lets the client exclude hidden fields by
110
+ // default, validate select/expand, and derive visible field lists.
111
+ const schemaEntries = collections
112
+ .map(
113
+ (c) =>
114
+ ` {
115
+ id: ${JSON.stringify(c.id ?? null)},
116
+ name: ${JSON.stringify(c.name)},
117
+ type: ${JSON.stringify(c.type)},
118
+ system: ${JSON.stringify(c.system ?? false)},
119
+ fields: ${JSON.stringify(c.fields ?? [], null, 2)},
120
+ rules: ${JSON.stringify(c.rules ?? {})},
121
+ options: ${JSON.stringify(c.options ?? {})},
122
+ }`,
123
+ )
124
+ .join(",\n");
125
+ sections.push(`// Schema snapshot (runtime) — hidden-field exclusion + query validation.
126
+ export const lazypockSchema: import("${packageName}").CollectionSchema[] = [
127
+ ${schemaEntries}
128
+ ];`);
129
+
109
130
  // createClient factory
110
131
  sections.push(`import { LazypockClient, type LazypockClientOptions, type CollectionService } from "${packageName}";
111
132
 
@@ -113,9 +134,18 @@ ${mapEntries}
113
134
  * Create a Lazypock client typed against this schema snapshot.
114
135
  * Collection access is fully type-checked:
115
136
  * client.collection("posts").create({ title: "x" }) // title must exist
137
+ *
138
+ * The schema snapshot is wired into the client automatically, so hidden
139
+ * fields are excluded from responses and select/expand are validated.
116
140
  */
117
141
  export function createClient(options: LazypockClientOptions): TypedClient {
118
- return new TypedClient(options);
142
+ return new TypedClient({
143
+ ...options,
144
+ types: {
145
+ ...options.types,
146
+ schemas: options.types?.schemas ?? lazypockSchema,
147
+ },
148
+ });
119
149
  }
120
150
 
121
151
  export class TypedClient extends LazypockClient {
@@ -142,6 +172,9 @@ function renderInterface(opts: { extends?: string; body: string }): string {
142
172
 
143
173
  /** Render a single interface member line for a field. */
144
174
  function memberLine(f: SchemaField): string {
175
+ // Hidden fields are excluded from API responses by the schema-aware
176
+ // client default — don't expose them on the read model either.
177
+ if (f.hidden) return "";
145
178
  const key = fieldKey(f.name);
146
179
  const req = f.required || f.type === "password" ? "" : "?";
147
180
  const type = fieldTypeScriptType(f);
package/src/collection.ts CHANGED
@@ -9,8 +9,43 @@ import type {
9
9
  RequestOptions,
10
10
  CreateData,
11
11
  UpdateData,
12
+ ListOptions,
13
+ ReadOptions,
14
+ FilterString,
15
+ FieldKey,
12
16
  } from "./types";
13
17
  import type { RealtimeService } from "./realtime";
18
+ import type { CollectionSchema } from "./schema";
19
+
20
+ /**
21
+ * Deterministic JSON stringify for building a stable cache/dedup key.
22
+ * Strips request-transport keys (`requestKey`, `singleFlight`, `fetch`,
23
+ * `signal`) so functionally-identical calls share one key.
24
+ */
25
+ export function stableStringify(value: unknown): string {
26
+ const seen = new Set<object>();
27
+ const sort = (v: unknown): unknown => {
28
+ if (Array.isArray(v)) return v.map(sort);
29
+ if (v && typeof v === "object") {
30
+ if (seen.has(v as object)) return "[Circular]";
31
+ seen.add(v as object);
32
+ const out: Record<string, unknown> = {};
33
+ for (const k of Object.keys(v as object).sort()) {
34
+ if (k === "requestKey" || k === "singleFlight" || k === "fetch" || k === "signal") {
35
+ continue;
36
+ }
37
+ out[k] = sort((v as Record<string, unknown>)[k]);
38
+ }
39
+ return out;
40
+ }
41
+ return v;
42
+ };
43
+ try {
44
+ return JSON.stringify(sort(value));
45
+ } catch {
46
+ return String(value);
47
+ }
48
+ }
14
49
 
15
50
  /**
16
51
  * A realtime record-change event delivered to subscription callbacks.
@@ -60,6 +95,14 @@ export class CollectionService<T = ApiRecord> {
60
95
  private collectionName: string;
61
96
  private authStore?: AuthStore;
62
97
  private realtime?: RealtimeService;
98
+ /** Optional schema for this collection (from client `types.schemas`). */
99
+ private schema?: CollectionSchema;
100
+ /**
101
+ * Active field projection from {@link select}. `"*"` (or unset) means
102
+ * "all visible (non-hidden) fields" — resolved against the schema when
103
+ * one is available, otherwise left to the server.
104
+ */
105
+ private fieldsPreset?: string;
63
106
 
64
107
  /** @internal */
65
108
  constructor(
@@ -67,17 +110,117 @@ export class CollectionService<T = ApiRecord> {
67
110
  collectionName: string,
68
111
  authStore?: AuthStore,
69
112
  realtime?: RealtimeService,
113
+ schema?: CollectionSchema,
70
114
  ) {
71
115
  this.http = http;
72
116
  this.collectionName = collectionName;
73
117
  this.authStore = authStore;
74
118
  this.realtime = realtime;
119
+ this.schema = schema;
75
120
  }
76
121
 
77
122
  private encodeId(id: string): string {
78
123
  return encodeURIComponent(id);
79
124
  }
80
125
 
126
+ // ── Field projection (`.select`) ──
127
+
128
+ /**
129
+ * Project list/read responses to the given fields (PocketBase `fields`
130
+ * param). Field names are type-checked when this service is typed
131
+ * (codegen / `typed<T>()`).
132
+ *
133
+ * Returns a derived service — the original is untouched.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * const t = await client.collection("posts").select("id", "title").getList();
138
+ * // GET /api/posts?fields=id,title
139
+ *
140
+ * // All visible fields (hidden fields are excluded server-side)
141
+ * const all = await client.collection("posts").select("*").getList();
142
+ * // GET /api/posts?fields=id,title,published,… (schema known)
143
+ * ```
144
+ *
145
+ * When no schema is known, the default (no `select()` call) sends no
146
+ * `fields` param — the server returns every non-password field.
147
+ * Pass `select("*")` to restore the default after a projection.
148
+ */
149
+ select<K extends FieldKey<T> | "*">(...fields: K[]): CollectionService<T> {
150
+ const preset = fields.length === 0 ? "*" : fields.join(",");
151
+ const derived = new CollectionService<T>(
152
+ this.http,
153
+ this.collectionName,
154
+ this.authStore,
155
+ this.realtime,
156
+ this.schema,
157
+ );
158
+ derived.fieldsPreset = preset;
159
+ if (this.schema && preset !== "*") {
160
+ const known = new Set(this.schema.fields?.map((f) => f.name) ?? []);
161
+ for (const f of fields) {
162
+ if (!known.has(f)) {
163
+ console.warn(
164
+ `[lazypock] select("${f}"): unknown field on collection "${this.collectionName}"`,
165
+ );
166
+ }
167
+ }
168
+ }
169
+ return derived;
170
+ }
171
+
172
+ /**
173
+ * Fields to send with reads:
174
+ * 1. explicit `options.fields` (caller wins)
175
+ * 2. `select()` preset
176
+ * 3. schema default — all visible (non-hidden, non-system) fields
177
+ */
178
+ private effectiveFields(optionsFields?: string): string | undefined {
179
+ if (optionsFields !== undefined) return optionsFields;
180
+ if (this.fieldsPreset !== undefined) {
181
+ if (this.fieldsPreset === "*") {
182
+ return this.visibleFields();
183
+ }
184
+ return this.fieldsPreset;
185
+ }
186
+ return this.visibleFields();
187
+ }
188
+
189
+ /**
190
+ * All non-hidden, non-system, non-password field names from the schema.
191
+ * `undefined` when no schema is available (server decides).
192
+ */
193
+ visibleFields(): string | undefined {
194
+ if (!this.schema?.fields) return undefined;
195
+ const visible = this.schema.fields
196
+ .filter(
197
+ (f) =>
198
+ !f.hidden &&
199
+ f.type !== "password" &&
200
+ f.type !== "autodate",
201
+ )
202
+ .map((f) => f.name);
203
+ return visible.length > 0 ? visible.join(",") : undefined;
204
+ }
205
+
206
+ /** Warn once when an expand field is not a relation (schema known). */
207
+ private validateExpand(expand: string | undefined): void {
208
+ if (!expand || !this.schema?.fields) return;
209
+ const relations = new Set(
210
+ this.schema.fields
211
+ .filter((f) => f.type === "relation")
212
+ .map((f) => f.name),
213
+ );
214
+ for (const f of expand.split(",")) {
215
+ const name = f.trim();
216
+ if (name && !relations.has(name)) {
217
+ console.warn(
218
+ `[lazypock] expand("${name}"): field is not a relation on collection "${this.collectionName}"`,
219
+ );
220
+ }
221
+ }
222
+ }
223
+
81
224
  /**
82
225
  * Fetch a paginated list of records (PocketBase `getList`).
83
226
  *
@@ -88,7 +231,7 @@ export class CollectionService<T = ApiRecord> {
88
231
  getList<T2 = T>(
89
232
  page = 1,
90
233
  perPage = 30,
91
- options?: Record<string, unknown> & RequestOptions,
234
+ options?: ListOptions<T> & RequestOptions,
92
235
  ): Promise<ListResult<T2> | null> {
93
236
  const {
94
237
  requestKey,
@@ -97,12 +240,19 @@ export class CollectionService<T = ApiRecord> {
97
240
  fetch,
98
241
  headers,
99
242
  signal,
100
- cache,
101
- ttl,
102
- invalidate,
243
+ singleFlight,
103
244
  params,
104
245
  ...queryParams
105
246
  } = options ?? {};
247
+ // Resolve the effective `fields` projection (select() preset or schema
248
+ // default) unless the caller passed an explicit `fields`.
249
+ const fields = this.effectiveFields(queryParams.fields as string | undefined);
250
+ if (fields !== undefined && queryParams.fields === undefined) {
251
+ queryParams.fields = fields;
252
+ }
253
+ if (typeof queryParams.expand === "string") {
254
+ this.validateExpand(queryParams.expand);
255
+ }
106
256
  const qs = new URLSearchParams(
107
257
  Object.fromEntries(
108
258
  Object.entries({
@@ -121,9 +271,7 @@ export class CollectionService<T = ApiRecord> {
121
271
  fetch,
122
272
  headers,
123
273
  signal,
124
- cache,
125
- ttl,
126
- invalidate,
274
+ singleFlight,
127
275
  params,
128
276
  } as RequestOptions,
129
277
  );
@@ -136,9 +284,20 @@ export class CollectionService<T = ApiRecord> {
136
284
  * @param options Query params (`sort`, `filter`, `batch`, etc.) + request options.
137
285
  */
138
286
  async getFullList<T2 = T>(
139
- options?: Record<string, unknown> & RequestOptions,
287
+ options?: ListOptions<T> & RequestOptions,
140
288
  ): Promise<Array<T2>> {
141
289
  const { batch = 1000, ...rest } = options ?? {};
290
+
291
+ // Build a stable request key for the whole full-list fetch (NOT per page,
292
+ // which would break dedup). Concurrent identical getFullList() calls share
293
+ // this key via single-flight, so they don't fire duplicate requests. Pages
294
+ // still advance correctly: each page's URL differs (page=N in the query),
295
+ // so the underlying default key is unique per page — no cross-page cancel.
296
+ const effectiveKey =
297
+ typeof rest.requestKey === "string"
298
+ ? rest.requestKey
299
+ : `getFullList:${this.collectionName}:${stableStringify(rest)}`;
300
+
142
301
  const items: T2[] = [];
143
302
  let page = 1;
144
303
  for (;;) {
@@ -146,10 +305,10 @@ export class CollectionService<T = ApiRecord> {
146
305
  page,
147
306
  batch as number,
148
307
  {
149
- // disable auto-cancellation across pages — each page request is unique
150
308
  ...rest,
151
- requestKey: null,
152
- } as Record<string, unknown> & RequestOptions,
309
+ requestKey: effectiveKey,
310
+ singleFlight: true,
311
+ } as ListOptions<T> & RequestOptions,
153
312
  );
154
313
  if (!res || !res.items || res.items.length === 0) break;
155
314
  items.push(...(res.items as T2[]));
@@ -166,8 +325,8 @@ export class CollectionService<T = ApiRecord> {
166
325
  * @param options Optional request options.
167
326
  */
168
327
  async getFirstListItem<T2 = T>(
169
- filter: string,
170
- options?: RequestOptions,
328
+ filter: FilterString<T>,
329
+ options?: ListOptions<T> & RequestOptions,
171
330
  ): Promise<T2 | null> {
172
331
  const res = await this.getList<T2>(1, 1, {
173
332
  ...options,
@@ -181,9 +340,21 @@ export class CollectionService<T = ApiRecord> {
181
340
  * @param id Record ID.
182
341
  * @param options Optional request options.
183
342
  */
184
- getOne(id: string, options?: RequestOptions): Promise<T | null> {
343
+ getOne(id: string, options?: ReadOptions<T> & RequestOptions): Promise<T | null> {
344
+ const fields = this.effectiveFields(options?.fields);
345
+ if (typeof options?.expand === "string") {
346
+ this.validateExpand(options.expand);
347
+ }
348
+ const qs = new URLSearchParams();
349
+ if (fields !== undefined) qs.set("fields", fields);
350
+ if (typeof options?.expand === "string") qs.set("expand", options.expand);
351
+ const qsStr = qs.toString();
185
352
  return this.http.get<T>(
186
- "/" + this.encodeId(this.collectionName) + "/" + this.encodeId(id),
353
+ "/" +
354
+ this.encodeId(this.collectionName) +
355
+ "/" +
356
+ this.encodeId(id) +
357
+ (qsStr ? "?" + qsStr : ""),
187
358
  options,
188
359
  );
189
360
  }
@@ -281,6 +452,23 @@ export class CollectionService<T = ApiRecord> {
281
452
  return this as unknown as CollectionService<TRecord>;
282
453
  }
283
454
 
455
+ /**
456
+ * Explicitly bind a schema for this service (overrides the client-level
457
+ * `types.schemas`). Enables schema-aware defaults: hidden fields are
458
+ * excluded from responses, select/expand are validated.
459
+ */
460
+ withSchema(schema: CollectionSchema): CollectionService<T> {
461
+ const derived = new CollectionService<T>(
462
+ this.http,
463
+ this.collectionName,
464
+ this.authStore,
465
+ this.realtime,
466
+ schema,
467
+ );
468
+ derived.fieldsPreset = this.fieldsPreset;
469
+ return derived;
470
+ }
471
+
284
472
  // ── Realtime Subscriptions (PocketBase-style) ──
285
473
 
286
474
  /**
@@ -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,44 @@ 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
+ params: passthroughParams,
87
+ ...queryParams
88
+ } = rest as Record<string, unknown> & RequestOptions;
89
+
90
+ // Stable key for the whole full-list fetch — see CollectionService.getFullList
91
+ // for the rationale (single-flight dedup, per-page paths stay unique).
92
+ const effectiveKey =
93
+ typeof reqKey === "string"
94
+ ? reqKey
95
+ : `getFullList:collections:${stableStringify(rest)}`;
96
+
77
97
  const items: T[] = [];
78
98
  let page = 1;
79
99
  // Auto-paginate until empty (bounded by perPage and totalPages).
80
100
  for (;;) {
81
- const res = await this.getList<T>({
82
- ...rest,
83
- page,
84
- perPage: batch,
85
- } as Record<string, unknown>);
101
+ const res = await this.getList<T>(
102
+ {
103
+ ...queryParams,
104
+ ...(passthroughParams ?? {}),
105
+ page,
106
+ perPage: batch,
107
+ },
108
+ {
109
+ requestKey: effectiveKey,
110
+ singleFlight: true,
111
+ ...(fetchFn ? { fetch: fetchFn } : {}),
112
+ ...(hdrs ? { headers: hdrs } : {}),
113
+ ...(sig ? { signal: sig } : {}),
114
+ } as RequestOptions,
115
+ );
86
116
  if (!res || !res.items || res.items.length === 0) break;
87
117
  items.push(...(res.items as T[]));
88
118
  if (page >= (res.totalPages ?? page)) break;
package/src/http.ts CHANGED
@@ -3,8 +3,6 @@
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";
8
6
 
9
7
  /**
10
8
  * Low-level HTTP client wrapping `fetch` with automatic auth token injection.
@@ -22,10 +20,6 @@ export class HttpClient {
22
20
  private baseUrl: string;
23
21
  private authStore: AuthStore;
24
22
  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;
29
23
 
30
24
  /**
31
25
  * Abort controllers for in-flight requests, keyed by their cancellation key
@@ -34,6 +28,14 @@ export class HttpClient {
34
28
  */
35
29
  private cancelControllers: Record<string, AbortController> = {};
36
30
 
31
+ /**
32
+ * In-flight request promises, keyed by cancellation key. When auto-cancellation
33
+ * would abort a pending duplicate, the newer request instead awaits the same
34
+ * promise — single-flight coalescing (no duplicate network request, no
35
+ * spurious abort rejection for the caller).
36
+ */
37
+ private inflight: Record<string, Promise<unknown>> = {};
38
+
37
39
  /** Global toggle for the auto-cancellation behaviour (default: on). */
38
40
  private enableAutoCancellation = true;
39
41
 
@@ -47,20 +49,6 @@ export class HttpClient {
47
49
  this.defaultFetch = globalThis.fetch.bind(globalThis);
48
50
  }
49
51
 
50
- /**
51
- * Attach a cache store + master switch.
52
- * Called by the client constructor when cache config is present.
53
- */
54
- setCache(cache: CacheStore, enabled: boolean): void {
55
- this.cache = cache;
56
- this.cacheEnabled = enabled;
57
- }
58
-
59
- /** Whether the global cache flag is on (requests opt in/out individually too). */
60
- get cacheIsEnabled(): boolean {
61
- return this.cacheEnabled;
62
- }
63
-
64
52
  private async refreshAuth(): Promise<{
65
53
  token: string;
66
54
  record: Record<string, unknown>;
@@ -145,16 +133,6 @@ export class HttpClient {
145
133
  * @throws {ApiError} On non-2xx responses or when the request is aborted
146
134
  * (aborted requests throw an `ApiError` with `isAbort === true`).
147
135
  */
148
- /** Invalidate a namespace (collection name). No-op when cache is off. */
149
- invalidateCache(namespace: string): void {
150
- this.cache?.invalidate(namespace);
151
- }
152
-
153
- /** Current cache statistics (hits/misses/entries), or null when disabled. */
154
- cacheStats(): { hits: number; misses: number; entries: number } | null {
155
- return this.cache ? this.cache.stats() : null;
156
- }
157
-
158
136
  async request<T = unknown>(
159
137
  method: Method,
160
138
  path: string,
@@ -166,28 +144,6 @@ export class HttpClient {
166
144
  await this.refreshAuth();
167
145
  }
168
146
 
169
- // ── Cache resolution (read path) ────────────────────────────────
170
- // Only cacheable reads (GET) participate. Cache keys are scoped by auth
171
- // token so user A's cached list can never leak to user B (or anonymous).
172
- //
173
- // Effective caching for this request:
174
- // - per-request { cache } / { ttl } present → use it (true enables,
175
- // false bypasses, number/object sets TTL)
176
- // - otherwise → fall back to the global cache.enabled flag
177
- const cacheDirective = resolveCacheDirective(options);
178
- const wantCache =
179
- cacheDirective !== null
180
- ? cacheDirective.enabled
181
- : this.cacheEnabled;
182
- const cacheKey =
183
- method === "GET" && this.cache && wantCache
184
- ? this.cacheKeyFor(method, path, options?.params)
185
- : null;
186
- if (cacheKey !== null) {
187
- const hit = await this.cache?.get(cacheKey);
188
- if (hit !== undefined) return hit as T;
189
- }
190
-
191
147
  // Resolve the auto-cancellation key (PocketBase `requestKey` semantics):
192
148
  // - options.requestKey null → disabled for this request
193
149
  // - options.requestKey string → use it verbatim
@@ -200,6 +156,16 @@ export class HttpClient {
200
156
  : options.requestKey;
201
157
  if (options?.autoCancel === false) requestKey = null;
202
158
 
159
+ // Single-flight coalescing: when the same requestKey is already in-flight
160
+ // and the caller opted in, reuse that promise instead of firing a duplicate
161
+ // request (no abort rejection for either caller).
162
+ if (options?.singleFlight && requestKey !== null) {
163
+ const pending = this.inflight[requestKey];
164
+ if (pending !== undefined) {
165
+ return pending as Promise<T | null>;
166
+ }
167
+ }
168
+
203
169
  // Wire a fresh AbortController for this request, merging any caller signal.
204
170
  // When auto-cancellation is enabled, the previous pending request sharing
205
171
  // our key is aborted first (only the last duplicate executes).
@@ -221,6 +187,48 @@ export class HttpClient {
221
187
  }
222
188
  const signal = controller?.signal ?? externalSignal;
223
189
 
190
+ // Register the in-flight promise so later single-flight callers reuse it.
191
+ // The promise is created from an inner async fn that performs the request
192
+ // and clears itself from the inflight map on settle.
193
+ const perform = async (): Promise<T | null> => {
194
+ try {
195
+ return await this.doRequest(
196
+ method,
197
+ path,
198
+ body,
199
+ options,
200
+ signal,
201
+ requestKey,
202
+ controller,
203
+ );
204
+ } finally {
205
+ if (requestKey !== null) {
206
+ if (this.inflight[requestKey] === promise) {
207
+ delete this.inflight[requestKey];
208
+ }
209
+ }
210
+ }
211
+ };
212
+ const promise = perform();
213
+ if (requestKey !== null) {
214
+ this.inflight[requestKey] = promise;
215
+ }
216
+ return promise;
217
+ }
218
+
219
+ /**
220
+ * Execute the actual HTTP request (fetch + parse). Called by {@link request}
221
+ * as the inner in-flight unit so single-flight callers can reuse the promise.
222
+ */
223
+ private async doRequest<T = unknown>(
224
+ method: Method,
225
+ path: string,
226
+ body: unknown,
227
+ options: RequestOptions | undefined,
228
+ signal: AbortSignal | null | undefined,
229
+ requestKey: string | null,
230
+ controller: AbortController | null,
231
+ ): Promise<T | null> {
224
232
  let url = this.baseUrl + path;
225
233
  if (options?.params) {
226
234
  const qs = new URLSearchParams(options.params).toString();
@@ -305,83 +313,9 @@ export class HttpClient {
305
313
  );
306
314
  }
307
315
 
308
- // ── Cache store (read path) ─────────────────────────────────────
309
- // Persist successful GET payloads when the directive wants caching.
310
- if (cacheKey !== null && this.cache) {
311
- const namespace = this.namespaceFromPath(path);
312
- const ttl = cacheDirective?.ttl;
313
- const tags = this.cacheTagsFor(path, namespace);
314
- this.cache.set(
315
- cacheKey,
316
- data,
317
- ttl,
318
- namespace ?? undefined,
319
- tags,
320
- );
321
- }
322
-
323
- // ── Cache invalidation (write path) ────────────────────────────
324
- // Mutations invalidate the affected collection's cached entries so
325
- // subsequent reads don't serve stale lists. The current collection is
326
- // always invalidated; `options.invalidate` adds extra namespaces.
327
- if (method !== "GET" && this.cache) {
328
- const namespaces = new Set<string>();
329
- const ns = this.namespaceFromPath(path);
330
- if (ns) namespaces.add(ns);
331
- for (const extra of options?.invalidate ?? []) {
332
- if (extra) namespaces.add(extra);
333
- }
334
- for (const nsName of namespaces) this.cache.invalidate(nsName);
335
- }
336
-
337
316
  return data as T;
338
317
  }
339
318
 
340
- // ── Cache key/namespace helpers ──
341
-
342
- /** Build a token-scoped cache key: `METHOD path|token-hash|params`. */
343
- private cacheKeyFor(
344
- method: Method,
345
- path: string,
346
- params?: Record<string, string>,
347
- ): string {
348
- const token = this.authStore.token || "anon";
349
- const qs = params ? "?" + new URLSearchParams(params).toString() : "";
350
- return `${method} ${path}${qs}|${token}`;
351
- }
352
-
353
- /** Best-effort namespace (collection name) from a REST path. */
354
- private namespaceFromPath(path: string): string | undefined {
355
- // /posts/abc-123 → posts ; /collections/xyz → collections
356
- // strip any query string first (/posts?page=1 → /posts)
357
- const clean = path.split("?")[0];
358
- const parts = clean.split("/").filter(Boolean);
359
- if (parts.length === 0) return undefined;
360
- if (parts[0] === "collections" || parts[0] === "_superusers") {
361
- return parts[0];
362
- }
363
- return parts[0];
364
- }
365
-
366
- /**
367
- * Semantic prefix tags for `deleteByPrefix`, derived from the REST shape:
368
- * - `/{collection}?...` → `getList:{collection}`
369
- * - `/{collection}/{id}` → `getOne:{collection}`
370
- * - `/collections?...` / `/collections/{id}` → `collections:getList` / `collections:getOne`
371
- */
372
- private cacheTagsFor(path: string, namespace: string | undefined): string[] {
373
- if (!namespace) return [];
374
- const clean = path.split("?")[0];
375
- const parts = clean.split("/").filter(Boolean);
376
- if (parts[0] === "collections" || parts[0] === "_superusers") {
377
- const op = parts.length >= 2 ? "getOne" : "getList";
378
- return [`${namespace}:${op}`];
379
- }
380
- // /posts (list) vs /posts/{id} (one)
381
- const op = parts.length >= 2 ? "getOne" : "getList";
382
- return [`${op}:${namespace}`];
383
- }
384
-
385
319
  /**
386
320
  * HTTP GET.
387
321
  * @param path URL path.
package/src/index.ts CHANGED
@@ -23,8 +23,6 @@ export {
23
23
  fieldTypeScriptType,
24
24
  fieldTypeKind,
25
25
  schemaFieldType,
26
- CacheStore,
27
- resolveCacheDirective,
28
26
  } from "./lazypock";
29
27
  export { TypedClient, createClient } from "./client";
30
28
 
@@ -40,12 +38,17 @@ export type {
40
38
  SystemFields,
41
39
  RequestOptions,
42
40
  FileRecord,
43
- // cache
44
- CacheConfig,
45
- CacheRequestOptions,
46
41
  // schema
47
42
  CollectionSchema,
48
43
  SchemaField,
44
+ // schema-driven query typing
45
+ ListOptions,
46
+ ReadOptions,
47
+ FilterString,
48
+ SortString,
49
+ ExpandString,
50
+ FilterOp,
51
+ FieldKey,
49
52
  // options
50
53
  LazypockClientOptions,
51
54
  } from "./lazypock";