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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazypock",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
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: null,
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;
@@ -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
- ...rest,
83
- page,
84
- perPage: batch,
85
- } as Record<string, unknown>);
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 {