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 CHANGED
@@ -358,6 +358,34 @@ client.cancelRequest('GET /api/posts?page=1');
358
358
  client.cancelAllRequests();
359
359
  ```
360
360
 
361
+ #### Single-flight dedup (`getFullList`)
362
+
363
+ `getFullList()` (and `collections.getFullList()`) are **single-flight**: concurrent
364
+ calls with the same effective options share one in-flight request instead of
365
+ firing duplicates. This means the common pattern below results in **one**
366
+ network request, and **both** callers resolve with the same data — no abort
367
+ rejection:
368
+
369
+ ```typescript
370
+ const [a, b] = await Promise.all([
371
+ client.collection('posts').getFullList(),
372
+ client.collection('posts').getFullList(),
373
+ ]);
374
+ // one GET fired; a === b
375
+ ```
376
+
377
+ Calls with **different** options (e.g. different `sort`/`filter`) are still
378
+ distinct requests. Multi-page fetches continue to work normally — each page
379
+ request is unique (page number is part of the URL), so pages never cancel each
380
+ other.
381
+
382
+ The underlying `singleFlight` option is also available on any request when you
383
+ want to coalesce concurrent identical calls yourself:
384
+
385
+ ```typescript
386
+ await client.collection('posts').getList(1, 20, { singleFlight: true });
387
+ ```
388
+
361
389
  ## Query Cache
362
390
 
363
391
  Lazypock has a built-in query cache for **GET** requests — disabled by default.
package/dist/index.cjs CHANGED
@@ -268,6 +268,13 @@ var HttpClient = class {
268
268
  * previous one — PocketBase-style auto-cancellation of duplicated requests.
269
269
  */
270
270
  this.cancelControllers = {};
271
+ /**
272
+ * In-flight request promises, keyed by cancellation key. When auto-cancellation
273
+ * would abort a pending duplicate, the newer request instead awaits the same
274
+ * promise — single-flight coalescing (no duplicate network request, no
275
+ * spurious abort rejection for the caller).
276
+ */
277
+ this.inflight = {};
271
278
  /** Global toggle for the auto-cancellation behaviour (default: on). */
272
279
  this.enableAutoCancellation = true;
273
280
  this.baseUrl = baseUrl.replace(/\/+$/, "");
@@ -383,6 +390,12 @@ var HttpClient = class {
383
390
  }
384
391
  let requestKey = options?.requestKey === void 0 ? options?.cancelKey ?? `${method} ${path}` : options.requestKey;
385
392
  if (options?.autoCancel === false) requestKey = null;
393
+ if (options?.singleFlight && requestKey !== null) {
394
+ const pending = this.inflight[requestKey];
395
+ if (pending !== void 0) {
396
+ return pending;
397
+ }
398
+ }
386
399
  let controller = null;
387
400
  const externalSignal = options?.signal;
388
401
  if (requestKey !== null) {
@@ -400,6 +413,38 @@ var HttpClient = class {
400
413
  }
401
414
  }
402
415
  const signal = controller?.signal ?? externalSignal;
416
+ const perform = async () => {
417
+ try {
418
+ return await this.doRequest(
419
+ method,
420
+ path,
421
+ body,
422
+ options,
423
+ signal,
424
+ requestKey,
425
+ controller,
426
+ cacheKey,
427
+ cacheDirective
428
+ );
429
+ } finally {
430
+ if (requestKey !== null) {
431
+ if (this.inflight[requestKey] === promise) {
432
+ delete this.inflight[requestKey];
433
+ }
434
+ }
435
+ }
436
+ };
437
+ const promise = perform();
438
+ if (requestKey !== null) {
439
+ this.inflight[requestKey] = promise;
440
+ }
441
+ return promise;
442
+ }
443
+ /**
444
+ * Execute the actual HTTP request (fetch + parse + cache). Called by {@link request}
445
+ * as the inner in-flight unit so single-flight callers can reuse the promise.
446
+ */
447
+ async doRequest(method, path, body, options, signal, requestKey, controller, cacheKey, cacheDirective) {
403
448
  let url = this.baseUrl + path;
404
449
  if (options?.params) {
405
450
  const qs = new URLSearchParams(options.params).toString();
@@ -696,6 +741,30 @@ var memoryStorage = {
696
741
  };
697
742
 
698
743
  // src/collection.ts
744
+ function stableStringify(value) {
745
+ const seen = /* @__PURE__ */ new Set();
746
+ const sort = (v) => {
747
+ if (Array.isArray(v)) return v.map(sort);
748
+ if (v && typeof v === "object") {
749
+ if (seen.has(v)) return "[Circular]";
750
+ seen.add(v);
751
+ const out = {};
752
+ for (const k of Object.keys(v).sort()) {
753
+ if (k === "requestKey" || k === "singleFlight" || k === "fetch" || k === "signal") {
754
+ continue;
755
+ }
756
+ out[k] = sort(v[k]);
757
+ }
758
+ return out;
759
+ }
760
+ return v;
761
+ };
762
+ try {
763
+ return JSON.stringify(sort(value));
764
+ } catch {
765
+ return String(value);
766
+ }
767
+ }
699
768
  function normalizeAction(event, rawAction) {
700
769
  if (typeof rawAction === "string") {
701
770
  const a = rawAction.toLowerCase();
@@ -737,6 +806,7 @@ var CollectionService = class {
737
806
  cache,
738
807
  ttl,
739
808
  invalidate,
809
+ singleFlight,
740
810
  params,
741
811
  ...queryParams
742
812
  } = options ?? {};
@@ -761,6 +831,7 @@ var CollectionService = class {
761
831
  cache,
762
832
  ttl,
763
833
  invalidate,
834
+ singleFlight,
764
835
  params
765
836
  }
766
837
  );
@@ -773,6 +844,7 @@ var CollectionService = class {
773
844
  */
774
845
  async getFullList(options) {
775
846
  const { batch = 1e3, ...rest } = options ?? {};
847
+ const effectiveKey = typeof rest.requestKey === "string" ? rest.requestKey : `getFullList:${this.collectionName}:${stableStringify(rest)}`;
776
848
  const items = [];
777
849
  let page = 1;
778
850
  for (; ; ) {
@@ -780,9 +852,9 @@ var CollectionService = class {
780
852
  page,
781
853
  batch,
782
854
  {
783
- // disable auto-cancellation across pages — each page request is unique
784
855
  ...rest,
785
- requestKey: null
856
+ requestKey: effectiveKey,
857
+ singleFlight: true
786
858
  }
787
859
  );
788
860
  if (!res || !res.items || res.items.length === 0) break;
@@ -1305,14 +1377,40 @@ var CollectionsService = class {
1305
1377
  async getFullList(options) {
1306
1378
  if (!this.http) return [];
1307
1379
  const { batch = 1e3, ...rest } = options ?? {};
1380
+ const {
1381
+ requestKey: reqKey,
1382
+ singleFlight: _singleFlight,
1383
+ fetch: fetchFn,
1384
+ headers: hdrs,
1385
+ signal: sig,
1386
+ cache: cacheOpt,
1387
+ ttl: ttlOpt,
1388
+ invalidate: inval,
1389
+ params: passthroughParams,
1390
+ ...queryParams
1391
+ } = rest;
1392
+ const effectiveKey = typeof reqKey === "string" ? reqKey : `getFullList:collections:${stableStringify(rest)}`;
1308
1393
  const items = [];
1309
1394
  let page = 1;
1310
1395
  for (; ; ) {
1311
- const res = await this.getList({
1312
- ...rest,
1313
- page,
1314
- perPage: batch
1315
- });
1396
+ const res = await this.getList(
1397
+ {
1398
+ ...queryParams,
1399
+ ...passthroughParams ?? {},
1400
+ page,
1401
+ perPage: batch
1402
+ },
1403
+ {
1404
+ requestKey: effectiveKey,
1405
+ singleFlight: true,
1406
+ ...fetchFn ? { fetch: fetchFn } : {},
1407
+ ...hdrs ? { headers: hdrs } : {},
1408
+ ...sig ? { signal: sig } : {},
1409
+ ...cacheOpt !== void 0 ? { cache: cacheOpt } : {},
1410
+ ...ttlOpt !== void 0 ? { ttl: ttlOpt } : {},
1411
+ ...inval ? { invalidate: inval } : {}
1412
+ }
1413
+ );
1316
1414
  if (!res || !res.items || res.items.length === 0) break;
1317
1415
  items.push(...res.items);
1318
1416
  if (page >= (res.totalPages ?? page)) break;