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/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(
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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?:
|
|
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:
|
|
152
|
-
|
|
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:
|
|
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
|
-
"/" +
|
|
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
|
/**
|
package/src/collections.ts
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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";
|