lazypock 0.2.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 +135 -0
- package/dist/index.cjs +509 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +284 -1
- package/dist/index.d.ts +284 -1
- package/dist/index.global.js +507 -10
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +507 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cache.ts +314 -0
- package/src/collection.ts +71 -5
- package/src/collections.ts +41 -5
- package/src/http.ts +190 -0
- package/src/index.ts +5 -0
- package/src/lazypock.ts +144 -0
- package/src/types.ts +23 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// ── Cache Store ──────────────────────────────────────────
|
|
2
|
+
// Pluggable query-cache for GET responses. Mirrors the AuthStore pattern:
|
|
3
|
+
// a default memory store (Map) with an optional custom storage adapter
|
|
4
|
+
// (localStorage, AsyncStorage, IndexedDB, ...) for cross-page persistence.
|
|
5
|
+
|
|
6
|
+
import type { StorageAdapter } from "./auth";
|
|
7
|
+
|
|
8
|
+
/** Options for enabling/customising the client's query cache. */
|
|
9
|
+
export interface CacheConfig {
|
|
10
|
+
/**
|
|
11
|
+
* Master switch. When `true`, readable GET requests are cached with the
|
|
12
|
+
* default TTL unless a request opts out via `{ cache: false }`.
|
|
13
|
+
*
|
|
14
|
+
* When `false` (default), caching is disabled unless a request opts in
|
|
15
|
+
* via `{ cache: true }` or `{ ttl: <ms> }`. Opt-in works regardless.
|
|
16
|
+
*/
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Default time-to-live for cached entries, in milliseconds.
|
|
20
|
+
* @default 60_000 (1 minute)
|
|
21
|
+
*/
|
|
22
|
+
defaultTTL?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Optional persistence backend (same interface as AuthStore's storage).
|
|
25
|
+
* Defaults to an in-memory Map — swap for `localStorage` / `AsyncStorage`
|
|
26
|
+
* to keep the cache across page reloads / app restarts.
|
|
27
|
+
*/
|
|
28
|
+
store?: StorageAdapter;
|
|
29
|
+
/**
|
|
30
|
+
* Max number of entries to keep in memory (LRU eviction).
|
|
31
|
+
* @default 500
|
|
32
|
+
*/
|
|
33
|
+
maxEntries?: number;
|
|
34
|
+
/**
|
|
35
|
+
* When true and the client has an active realtime subscription for a
|
|
36
|
+
* collection, inbound create/update/delete events invalidate that
|
|
37
|
+
* collection's cached entries automatically.
|
|
38
|
+
* @default false — only local mutations invalidate (explicit + predictable)
|
|
39
|
+
*/
|
|
40
|
+
invalidateOnRealtime?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Per-request cache controls (mixed into {@link RequestOptions}). */
|
|
44
|
+
export interface CacheRequestOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Cache control for this request:
|
|
47
|
+
* - `true` — cache with the default (or global) TTL
|
|
48
|
+
* - `false` — always fetch fresh, bypass cache (and don't store the result)
|
|
49
|
+
* - a number — cache with this TTL in milliseconds
|
|
50
|
+
* - an object — `{ ttl, key }` for finer control
|
|
51
|
+
*
|
|
52
|
+
* When unset, the global `cache.enabled` flag decides.
|
|
53
|
+
*/
|
|
54
|
+
cache?: boolean | number | { ttl?: number; key?: string };
|
|
55
|
+
/** Alias of `cache: <ms>` (convenience, reads naturally). */
|
|
56
|
+
ttl?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Extra cache namespaces to invalidate when this mutation succeeds.
|
|
59
|
+
* The current collection is always invalidated automatically.
|
|
60
|
+
* @example create({ ... }, { invalidate: ['users'] })
|
|
61
|
+
*/
|
|
62
|
+
invalidate?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A single cached entry. */
|
|
66
|
+
interface CacheEntry<T = unknown> {
|
|
67
|
+
value: T;
|
|
68
|
+
expiresAt: number;
|
|
69
|
+
/** Namespace (collection name) this entry belongs to — for invalidation. */
|
|
70
|
+
namespace?: string;
|
|
71
|
+
/** Prefix tags (e.g. `getList:posts`) for deleteByPrefix. */
|
|
72
|
+
tags?: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** LRU-ish memory store + optional persistent adapter hybrid. */
|
|
76
|
+
export class CacheStore {
|
|
77
|
+
private memory = new Map<string, CacheEntry>();
|
|
78
|
+
private readonly ttl: number;
|
|
79
|
+
private readonly persistence?: StorageAdapter;
|
|
80
|
+
private readonly maxEntries: number;
|
|
81
|
+
private hits = 0;
|
|
82
|
+
private misses = 0;
|
|
83
|
+
private namespaceEntries = new Map<string, Set<string>>();
|
|
84
|
+
/** Key → set of prefix tags registered for that key (e.g. `getList:posts`). */
|
|
85
|
+
private prefixEntries = new Map<string, Set<string>>();
|
|
86
|
+
|
|
87
|
+
constructor(config: {
|
|
88
|
+
defaultTTL?: number;
|
|
89
|
+
store?: StorageAdapter;
|
|
90
|
+
maxEntries?: number;
|
|
91
|
+
} = {}) {
|
|
92
|
+
this.ttl = config.defaultTTL ?? 60_000;
|
|
93
|
+
this.persistence = config.store;
|
|
94
|
+
this.maxEntries = config.maxEntries ?? 500;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Resolve the effective TTL: request override → global default. */
|
|
98
|
+
private resolveTTL(ttl?: number): number {
|
|
99
|
+
return ttl && ttl > 0 ? ttl : this.ttl;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a cached value. Fast sync path (memory) with async persistence
|
|
104
|
+
* fallback for adapters whose `get` returns a Promise.
|
|
105
|
+
* @param key Cache key (e.g. `"GET /posts?page=1"`).
|
|
106
|
+
* @returns The cached value, or undefined when absent/expired (the hit is
|
|
107
|
+
* cleared on expiry so a stale value is never served).
|
|
108
|
+
*/
|
|
109
|
+
async get<T = unknown>(key: string): Promise<T | undefined> {
|
|
110
|
+
const mem = this.memory.get(key);
|
|
111
|
+
if (mem !== undefined) {
|
|
112
|
+
if (Date.now() > mem.expiresAt) {
|
|
113
|
+
this.delete(key);
|
|
114
|
+
this.misses++;
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
// refresh recency for LRU eviction
|
|
118
|
+
this.memory.delete(key);
|
|
119
|
+
this.memory.set(key, mem);
|
|
120
|
+
this.hits++;
|
|
121
|
+
return mem.value as T;
|
|
122
|
+
}
|
|
123
|
+
if (this.persistence) {
|
|
124
|
+
const entry = await this.readPersisted(key);
|
|
125
|
+
if (entry) {
|
|
126
|
+
if (Date.now() > entry.expiresAt) {
|
|
127
|
+
this.delete(key);
|
|
128
|
+
this.misses++;
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
this.hits++;
|
|
132
|
+
return entry.value as T;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
this.misses++;
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Store a value.
|
|
141
|
+
* @param key Cache key.
|
|
142
|
+
* @param value The response payload.
|
|
143
|
+
* @param ttlOverride Optional TTL override (ms).
|
|
144
|
+
* @param namespace Optional namespace for group invalidation.
|
|
145
|
+
*/
|
|
146
|
+
set(
|
|
147
|
+
key: string,
|
|
148
|
+
value: unknown,
|
|
149
|
+
ttlOverride?: number,
|
|
150
|
+
namespace?: string,
|
|
151
|
+
tags?: string[],
|
|
152
|
+
): void {
|
|
153
|
+
const expiresAt = Date.now() + this.resolveTTL(ttlOverride);
|
|
154
|
+
const entry: CacheEntry = { value, expiresAt, namespace, tags };
|
|
155
|
+
this.memory.set(key, entry);
|
|
156
|
+
|
|
157
|
+
// LRU eviction when over capacity
|
|
158
|
+
if (this.memory.size > this.maxEntries) {
|
|
159
|
+
const oldest = this.memory.keys().next().value as string | undefined;
|
|
160
|
+
if (oldest !== undefined) this.delete(oldest);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (namespace) {
|
|
164
|
+
let keys = this.namespaceEntries.get(namespace);
|
|
165
|
+
if (!keys) {
|
|
166
|
+
keys = new Set();
|
|
167
|
+
this.namespaceEntries.set(namespace, keys);
|
|
168
|
+
}
|
|
169
|
+
keys.add(key);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const tag of tags ?? []) {
|
|
173
|
+
let keys = this.prefixEntries.get(tag);
|
|
174
|
+
if (!keys) {
|
|
175
|
+
keys = new Set();
|
|
176
|
+
this.prefixEntries.set(tag, keys);
|
|
177
|
+
}
|
|
178
|
+
keys.add(key);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this.persistence) {
|
|
182
|
+
void this.persistence.set(this.persistKey(key), JSON.stringify(entry));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Invalidate entries belonging to a namespace (e.g. a collection name).
|
|
188
|
+
* Also clears the namespace index entry.
|
|
189
|
+
*/
|
|
190
|
+
invalidate(namespace: string): void {
|
|
191
|
+
const keys = Array.from(this.namespaceEntries.get(namespace) ?? []);
|
|
192
|
+
for (const key of keys) this.delete(key);
|
|
193
|
+
this.namespaceEntries.delete(namespace);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Remove a single key. */
|
|
197
|
+
delete(key: string): void {
|
|
198
|
+
const entry = this.memory.get(key);
|
|
199
|
+
if (entry?.namespace) {
|
|
200
|
+
const set = this.namespaceEntries.get(entry.namespace);
|
|
201
|
+
if (set) {
|
|
202
|
+
set.delete(key);
|
|
203
|
+
if (set.size === 0) this.namespaceEntries.delete(entry.namespace);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const tag of entry?.tags ?? []) {
|
|
207
|
+
const set = this.prefixEntries.get(tag);
|
|
208
|
+
if (set) {
|
|
209
|
+
set.delete(key);
|
|
210
|
+
if (set.size === 0) this.prefixEntries.delete(tag);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
this.memory.delete(key);
|
|
214
|
+
if (this.persistence) {
|
|
215
|
+
void this.persistence.remove(this.persistKey(key));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Delete every entry whose key starts with `prefix`.
|
|
221
|
+
*
|
|
222
|
+
* Useful for fine-grained invalidation, e.g.:
|
|
223
|
+
* ```ts
|
|
224
|
+
* client.cache.deleteByPrefix('getList:posts'); // delete all getList cache
|
|
225
|
+
* client.cache.deleteByPrefix('getOne:posts'); // delete all getOne cache
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
deleteByPrefix(prefix: string): void {
|
|
229
|
+
if (!prefix) return;
|
|
230
|
+
// exact tag match (fast path — the common `op:collection` case)
|
|
231
|
+
const tagged = this.prefixEntries.get(prefix);
|
|
232
|
+
if (tagged) {
|
|
233
|
+
for (const key of Array.from(tagged)) this.delete(key);
|
|
234
|
+
this.prefixEntries.delete(prefix);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// general prefix scan (e.g. `posts` matches any `op:posts`/`GET /posts`)
|
|
238
|
+
for (const key of Array.from(this.memory.keys())) {
|
|
239
|
+
if (key.startsWith(prefix)) this.delete(key);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Drop every cached entry (memory + persistence). */
|
|
244
|
+
clear(): void {
|
|
245
|
+
this.memory.clear();
|
|
246
|
+
this.namespaceEntries.clear();
|
|
247
|
+
this.prefixEntries.clear();
|
|
248
|
+
// Best-effort: clear all persisted keys via the adapter. The adapter has
|
|
249
|
+
// no list API, so we track a prefix index in memory only — a full
|
|
250
|
+
// persistence wipe is only possible if the adapter supports enumeration.
|
|
251
|
+
// Most use localStorage directly; callers may also recreate the client.
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Cache hit/miss/entry statistics. */
|
|
255
|
+
stats(): { hits: number; misses: number; entries: number } {
|
|
256
|
+
return { hits: this.hits, misses: this.misses, entries: this.memory.size };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private persistKey(key: string): string {
|
|
260
|
+
return "lazypock:cache:" + key;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async readPersisted(key: string): Promise<CacheEntry | undefined> {
|
|
264
|
+
if (!this.persistence) return undefined;
|
|
265
|
+
const raw = await this.persistence.get(this.persistKey(key));
|
|
266
|
+
if (raw == null) return undefined;
|
|
267
|
+
try {
|
|
268
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
269
|
+
// Re-hydrate a copy in memory (TTL checked by caller)
|
|
270
|
+
this.memory.set(key, entry);
|
|
271
|
+
if (entry.namespace) {
|
|
272
|
+
let keys = this.namespaceEntries.get(entry.namespace);
|
|
273
|
+
if (!keys) {
|
|
274
|
+
keys = new Set();
|
|
275
|
+
this.namespaceEntries.set(entry.namespace, keys);
|
|
276
|
+
}
|
|
277
|
+
keys.add(key);
|
|
278
|
+
}
|
|
279
|
+
for (const tag of entry.tags ?? []) {
|
|
280
|
+
let keys = this.prefixEntries.get(tag);
|
|
281
|
+
if (!keys) {
|
|
282
|
+
keys = new Set();
|
|
283
|
+
this.prefixEntries.set(tag, keys);
|
|
284
|
+
}
|
|
285
|
+
keys.add(key);
|
|
286
|
+
}
|
|
287
|
+
return entry;
|
|
288
|
+
} catch {
|
|
289
|
+
void this.persistence.remove(this.persistKey(key));
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Helpers ──
|
|
296
|
+
|
|
297
|
+
/** Resolve per-request cache options into a usable directive. */
|
|
298
|
+
export function resolveCacheDirective(opts?: {
|
|
299
|
+
cache?: boolean | number | { ttl?: number; key?: string };
|
|
300
|
+
ttl?: number;
|
|
301
|
+
}): { enabled: boolean; ttl?: number; key?: string } | null {
|
|
302
|
+
if (!opts) return null;
|
|
303
|
+
// convenience alias: ttl: 5000 → cache for 5s
|
|
304
|
+
if (typeof opts.ttl === "number" && opts.ttl > 0) {
|
|
305
|
+
return { enabled: true, ttl: opts.ttl };
|
|
306
|
+
}
|
|
307
|
+
const c = opts.cache;
|
|
308
|
+
if (c === undefined) return null; // use global enabled flag
|
|
309
|
+
if (c === true) return { enabled: true };
|
|
310
|
+
if (c === false) return { enabled: false };
|
|
311
|
+
if (typeof c === "number") return { enabled: true, ttl: c > 0 ? c : undefined };
|
|
312
|
+
// object form
|
|
313
|
+
return { enabled: true, ttl: c.ttl, key: c.key };
|
|
314
|
+
}
|
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`).
|
|
@@ -90,19 +120,44 @@ export class CollectionService<T = ApiRecord> {
|
|
|
90
120
|
perPage = 30,
|
|
91
121
|
options?: Record<string, unknown> & RequestOptions,
|
|
92
122
|
): Promise<ListResult<T2> | null> {
|
|
93
|
-
const {
|
|
123
|
+
const {
|
|
124
|
+
requestKey,
|
|
125
|
+
autoCancel,
|
|
126
|
+
cancelKey,
|
|
127
|
+
fetch,
|
|
128
|
+
headers,
|
|
129
|
+
signal,
|
|
130
|
+
cache,
|
|
131
|
+
ttl,
|
|
132
|
+
invalidate,
|
|
133
|
+
singleFlight,
|
|
134
|
+
params,
|
|
135
|
+
...queryParams
|
|
136
|
+
} = options ?? {};
|
|
94
137
|
const qs = new URLSearchParams(
|
|
95
138
|
Object.fromEntries(
|
|
96
139
|
Object.entries({
|
|
97
140
|
page: String(page),
|
|
98
141
|
perPage: String(perPage),
|
|
99
|
-
...
|
|
142
|
+
...queryParams,
|
|
100
143
|
}).map(([k, v]) => [k, String(v)]),
|
|
101
144
|
),
|
|
102
145
|
).toString();
|
|
103
146
|
return this.http.get<ListResult<T2>>(
|
|
104
147
|
"/" + this.encodeId(this.collectionName) + "?" + qs,
|
|
105
|
-
{
|
|
148
|
+
{
|
|
149
|
+
requestKey,
|
|
150
|
+
autoCancel,
|
|
151
|
+
cancelKey,
|
|
152
|
+
fetch,
|
|
153
|
+
headers,
|
|
154
|
+
signal,
|
|
155
|
+
cache,
|
|
156
|
+
ttl,
|
|
157
|
+
invalidate,
|
|
158
|
+
singleFlight,
|
|
159
|
+
params,
|
|
160
|
+
} as RequestOptions,
|
|
106
161
|
);
|
|
107
162
|
}
|
|
108
163
|
|
|
@@ -116,6 +171,17 @@ export class CollectionService<T = ApiRecord> {
|
|
|
116
171
|
options?: Record<string, unknown> & RequestOptions,
|
|
117
172
|
): Promise<Array<T2>> {
|
|
118
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
|
+
|
|
119
185
|
const items: T2[] = [];
|
|
120
186
|
let page = 1;
|
|
121
187
|
for (;;) {
|
|
@@ -123,9 +189,9 @@ export class CollectionService<T = ApiRecord> {
|
|
|
123
189
|
page,
|
|
124
190
|
batch as number,
|
|
125
191
|
{
|
|
126
|
-
// disable auto-cancellation across pages — each page request is unique
|
|
127
192
|
...rest,
|
|
128
|
-
requestKey:
|
|
193
|
+
requestKey: effectiveKey,
|
|
194
|
+
singleFlight: true,
|
|
129
195
|
} as Record<string, unknown> & RequestOptions,
|
|
130
196
|
);
|
|
131
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;
|