lazypock 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/index.cjs +105 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.global.js +105 -7
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +105 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/collection.ts +45 -2
- package/src/collections.ts +41 -5
- package/src/http.ts +64 -0
- package/src/types.ts +11 -0
package/package.json
CHANGED
package/src/collection.ts
CHANGED
|
@@ -12,6 +12,36 @@ import type {
|
|
|
12
12
|
} from "./types";
|
|
13
13
|
import type { RealtimeService } from "./realtime";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Deterministic JSON stringify for building a stable cache/dedup key.
|
|
17
|
+
* Strips request-transport keys (`requestKey`, `singleFlight`, `fetch`,
|
|
18
|
+
* `signal`) so functionally-identical calls share one key.
|
|
19
|
+
*/
|
|
20
|
+
export function stableStringify(value: unknown): string {
|
|
21
|
+
const seen = new Set<object>();
|
|
22
|
+
const sort = (v: unknown): unknown => {
|
|
23
|
+
if (Array.isArray(v)) return v.map(sort);
|
|
24
|
+
if (v && typeof v === "object") {
|
|
25
|
+
if (seen.has(v as object)) return "[Circular]";
|
|
26
|
+
seen.add(v as object);
|
|
27
|
+
const out: Record<string, unknown> = {};
|
|
28
|
+
for (const k of Object.keys(v as object).sort()) {
|
|
29
|
+
if (k === "requestKey" || k === "singleFlight" || k === "fetch" || k === "signal") {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
out[k] = sort((v as Record<string, unknown>)[k]);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
return v;
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(sort(value));
|
|
40
|
+
} catch {
|
|
41
|
+
return String(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
/**
|
|
16
46
|
* A realtime record-change event delivered to subscription callbacks.
|
|
17
47
|
* Mirrors PocketBase's RealtimeService result shape (`action` + `record`).
|
|
@@ -100,6 +130,7 @@ export class CollectionService<T = ApiRecord> {
|
|
|
100
130
|
cache,
|
|
101
131
|
ttl,
|
|
102
132
|
invalidate,
|
|
133
|
+
singleFlight,
|
|
103
134
|
params,
|
|
104
135
|
...queryParams
|
|
105
136
|
} = options ?? {};
|
|
@@ -124,6 +155,7 @@ export class CollectionService<T = ApiRecord> {
|
|
|
124
155
|
cache,
|
|
125
156
|
ttl,
|
|
126
157
|
invalidate,
|
|
158
|
+
singleFlight,
|
|
127
159
|
params,
|
|
128
160
|
} as RequestOptions,
|
|
129
161
|
);
|
|
@@ -139,6 +171,17 @@ export class CollectionService<T = ApiRecord> {
|
|
|
139
171
|
options?: Record<string, unknown> & RequestOptions,
|
|
140
172
|
): Promise<Array<T2>> {
|
|
141
173
|
const { batch = 1000, ...rest } = options ?? {};
|
|
174
|
+
|
|
175
|
+
// Build a stable request key for the whole full-list fetch (NOT per page,
|
|
176
|
+
// which would break dedup). Concurrent identical getFullList() calls share
|
|
177
|
+
// this key via single-flight, so they don't fire duplicate requests. Pages
|
|
178
|
+
// still advance correctly: each page's URL differs (page=N in the query),
|
|
179
|
+
// so the underlying default key is unique per page — no cross-page cancel.
|
|
180
|
+
const effectiveKey =
|
|
181
|
+
typeof rest.requestKey === "string"
|
|
182
|
+
? rest.requestKey
|
|
183
|
+
: `getFullList:${this.collectionName}:${stableStringify(rest)}`;
|
|
184
|
+
|
|
142
185
|
const items: T2[] = [];
|
|
143
186
|
let page = 1;
|
|
144
187
|
for (;;) {
|
|
@@ -146,9 +189,9 @@ export class CollectionService<T = ApiRecord> {
|
|
|
146
189
|
page,
|
|
147
190
|
batch as number,
|
|
148
191
|
{
|
|
149
|
-
// disable auto-cancellation across pages — each page request is unique
|
|
150
192
|
...rest,
|
|
151
|
-
requestKey:
|
|
193
|
+
requestKey: effectiveKey,
|
|
194
|
+
singleFlight: true,
|
|
152
195
|
} as Record<string, unknown> & RequestOptions,
|
|
153
196
|
);
|
|
154
197
|
if (!res || !res.items || res.items.length === 0) break;
|
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,50 @@ 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
|
+
cache: cacheOpt,
|
|
87
|
+
ttl: ttlOpt,
|
|
88
|
+
invalidate: inval,
|
|
89
|
+
params: passthroughParams,
|
|
90
|
+
...queryParams
|
|
91
|
+
} = rest as Record<string, unknown> & RequestOptions;
|
|
92
|
+
|
|
93
|
+
// Stable key for the whole full-list fetch — see CollectionService.getFullList
|
|
94
|
+
// for the rationale (single-flight dedup, per-page paths stay unique).
|
|
95
|
+
const effectiveKey =
|
|
96
|
+
typeof reqKey === "string"
|
|
97
|
+
? reqKey
|
|
98
|
+
: `getFullList:collections:${stableStringify(rest)}`;
|
|
99
|
+
|
|
77
100
|
const items: T[] = [];
|
|
78
101
|
let page = 1;
|
|
79
102
|
// Auto-paginate until empty (bounded by perPage and totalPages).
|
|
80
103
|
for (;;) {
|
|
81
|
-
const res = await this.getList<T>(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
const res = await this.getList<T>(
|
|
105
|
+
{
|
|
106
|
+
...queryParams,
|
|
107
|
+
...(passthroughParams ?? {}),
|
|
108
|
+
page,
|
|
109
|
+
perPage: batch,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
requestKey: effectiveKey,
|
|
113
|
+
singleFlight: true,
|
|
114
|
+
...(fetchFn ? { fetch: fetchFn } : {}),
|
|
115
|
+
...(hdrs ? { headers: hdrs } : {}),
|
|
116
|
+
...(sig ? { signal: sig } : {}),
|
|
117
|
+
...(cacheOpt !== undefined ? { cache: cacheOpt } : {}),
|
|
118
|
+
...(ttlOpt !== undefined ? { ttl: ttlOpt } : {}),
|
|
119
|
+
...(inval ? { invalidate: inval } : {}),
|
|
120
|
+
} as RequestOptions,
|
|
121
|
+
);
|
|
86
122
|
if (!res || !res.items || res.items.length === 0) break;
|
|
87
123
|
items.push(...(res.items as T[]));
|
|
88
124
|
if (page >= (res.totalPages ?? page)) break;
|
package/src/http.ts
CHANGED
|
@@ -34,6 +34,14 @@ export class HttpClient {
|
|
|
34
34
|
*/
|
|
35
35
|
private cancelControllers: Record<string, AbortController> = {};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* In-flight request promises, keyed by cancellation key. When auto-cancellation
|
|
39
|
+
* would abort a pending duplicate, the newer request instead awaits the same
|
|
40
|
+
* promise — single-flight coalescing (no duplicate network request, no
|
|
41
|
+
* spurious abort rejection for the caller).
|
|
42
|
+
*/
|
|
43
|
+
private inflight: Record<string, Promise<unknown>> = {};
|
|
44
|
+
|
|
37
45
|
/** Global toggle for the auto-cancellation behaviour (default: on). */
|
|
38
46
|
private enableAutoCancellation = true;
|
|
39
47
|
|
|
@@ -200,6 +208,16 @@ export class HttpClient {
|
|
|
200
208
|
: options.requestKey;
|
|
201
209
|
if (options?.autoCancel === false) requestKey = null;
|
|
202
210
|
|
|
211
|
+
// Single-flight coalescing: when the same requestKey is already in-flight
|
|
212
|
+
// and the caller opted in, reuse that promise instead of firing a duplicate
|
|
213
|
+
// request (no abort rejection for either caller).
|
|
214
|
+
if (options?.singleFlight && requestKey !== null) {
|
|
215
|
+
const pending = this.inflight[requestKey];
|
|
216
|
+
if (pending !== undefined) {
|
|
217
|
+
return pending as Promise<T | null>;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
// Wire a fresh AbortController for this request, merging any caller signal.
|
|
204
222
|
// When auto-cancellation is enabled, the previous pending request sharing
|
|
205
223
|
// our key is aborted first (only the last duplicate executes).
|
|
@@ -221,6 +239,52 @@ export class HttpClient {
|
|
|
221
239
|
}
|
|
222
240
|
const signal = controller?.signal ?? externalSignal;
|
|
223
241
|
|
|
242
|
+
// Register the in-flight promise so later single-flight callers reuse it.
|
|
243
|
+
// The promise is created from an inner async fn that performs the request
|
|
244
|
+
// and clears itself from the inflight map on settle.
|
|
245
|
+
const perform = async (): Promise<T | null> => {
|
|
246
|
+
try {
|
|
247
|
+
return await this.doRequest(
|
|
248
|
+
method,
|
|
249
|
+
path,
|
|
250
|
+
body,
|
|
251
|
+
options,
|
|
252
|
+
signal,
|
|
253
|
+
requestKey,
|
|
254
|
+
controller,
|
|
255
|
+
cacheKey,
|
|
256
|
+
cacheDirective,
|
|
257
|
+
);
|
|
258
|
+
} finally {
|
|
259
|
+
if (requestKey !== null) {
|
|
260
|
+
if (this.inflight[requestKey] === promise) {
|
|
261
|
+
delete this.inflight[requestKey];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const promise = perform();
|
|
267
|
+
if (requestKey !== null) {
|
|
268
|
+
this.inflight[requestKey] = promise;
|
|
269
|
+
}
|
|
270
|
+
return promise;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Execute the actual HTTP request (fetch + parse + cache). Called by {@link request}
|
|
275
|
+
* as the inner in-flight unit so single-flight callers can reuse the promise.
|
|
276
|
+
*/
|
|
277
|
+
private async doRequest<T = unknown>(
|
|
278
|
+
method: Method,
|
|
279
|
+
path: string,
|
|
280
|
+
body: unknown,
|
|
281
|
+
options: RequestOptions | undefined,
|
|
282
|
+
signal: AbortSignal | null | undefined,
|
|
283
|
+
requestKey: string | null,
|
|
284
|
+
controller: AbortController | null,
|
|
285
|
+
cacheKey: string | null,
|
|
286
|
+
cacheDirective: ReturnType<typeof resolveCacheDirective>,
|
|
287
|
+
): Promise<T | null> {
|
|
224
288
|
let url = this.baseUrl + path;
|
|
225
289
|
if (options?.params) {
|
|
226
290
|
const qs = new URLSearchParams(options.params).toString();
|
package/src/types.ts
CHANGED
|
@@ -99,6 +99,17 @@ export interface RequestOptions {
|
|
|
99
99
|
* Alias of `requestKey` (PocketBase `$cancelKey` compat).
|
|
100
100
|
*/
|
|
101
101
|
cancelKey?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Coalesce concurrent identical requests (same `requestKey`) onto a single
|
|
104
|
+
* in-flight promise instead of aborting the earlier one.
|
|
105
|
+
*
|
|
106
|
+
* When enabled, a request arriving while another with the same key is still
|
|
107
|
+
* pending awaits the same result — no duplicate network request, and the
|
|
108
|
+
* caller of the first request never sees an abort rejection.
|
|
109
|
+
*
|
|
110
|
+
* @default false (auto-cancellation aborts the earlier duplicate)
|
|
111
|
+
*/
|
|
112
|
+
singleFlight?: boolean;
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
export class ApiError extends Error {
|