@peerbit/react 0.0.25 → 0.0.27

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/src/useQuery.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useReducer } from "react";
1
+ import { useState, useEffect, useRef, useMemo } from "react";
2
2
  import {
3
3
  ClosedError,
4
4
  Documents,
@@ -10,79 +10,113 @@ import * as indexerTypes from "@peerbit/indexer-interface";
10
10
  import { AbortError } from "@peerbit/time";
11
11
  import { NoPeersError } from "@peerbit/shared-log";
12
12
  import { v4 as uuid } from "uuid";
13
- import { WithIndexedContext } from "@peerbit/document/dist/src/search";
14
- import { on } from "events";
15
- /* ────────────── helper types ────────────── */
16
- type QueryLike = {
17
- query?: indexerTypes.Query[] | indexerTypes.QueryLike;
18
- sort?: indexerTypes.Sort[] | indexerTypes.Sort | indexerTypes.SortLike;
19
- };
13
+ import { WithIndexedContext } from "@peerbit/document";
14
+
20
15
  type QueryOptions = { query: QueryLike; id?: string };
21
16
 
22
- type RemoteQueryOptions = {
23
- warmup?: number;
24
- joining?: { waitFor?: number };
25
- eager?: boolean;
17
+ /* ────────────── helper types ────────────── */
18
+ export type QueryLike = {
19
+ /** Mongo-style selector or array of selectors */
20
+ query?: indexerTypes.QueryLike | indexerTypes.Query[];
21
+ /** Sort definition compatible with `@peerbit/indexer-interface` */
22
+ sort?: indexerTypes.SortLike | indexerTypes.Sort | indexerTypes.Sort[];
26
23
  };
27
24
 
28
- /* ────────────── main hook ────────────── */
25
+ /**
26
+ * All the non-DB-specific options supported by the original single-DB hook.
27
+ * They stay fully backward-compatible.
28
+ */
29
+ export type UseQuerySharedOptions<T, I, R extends boolean | undefined, RT> = {
30
+ /* original behavioural flags */
31
+ resolve?: R;
32
+ transform?: (r: RT) => Promise<RT>;
33
+ debounce?: number;
34
+ debug?: boolean | { id: string };
35
+ reverse?: boolean;
36
+ batchSize?: number;
37
+ prefetch?: boolean;
38
+ ignoreUpdates?: boolean;
39
+ onChange?: {
40
+ merge?:
41
+ | boolean
42
+ | ((
43
+ c: DocumentsChange<T, I>
44
+ ) =>
45
+ | DocumentsChange<T, I>
46
+ | Promise<DocumentsChange<T, I>>
47
+ | undefined);
48
+ update?: (
49
+ prev: RT[],
50
+ change: DocumentsChange<T, I>
51
+ ) => RT[] | Promise<RT[]>;
52
+ };
53
+ local?: boolean;
54
+ remote?:
55
+ | boolean
56
+ | {
57
+ warmup?: number;
58
+ joining?: { waitFor?: number };
59
+ eager?: boolean;
60
+ };
61
+ } & QueryOptions;
62
+
63
+ /* ────────────────────────── Main Hook ────────────────────────── */
64
+ /**
65
+ * `useQuery` – unified hook that accepts **either**
66
+ * 1. a single `Documents` instance
67
+ * 2. an array of `Documents` instances
68
+ * 3. *or* omits the first argument and provides `dbs` inside the `options` object.
69
+ *
70
+ * It supersedes the original single-DB version as well as the experimental
71
+ * `useMultiQuery` so callers never have to choose between two APIs.
72
+ */
29
73
  export const useQuery = <
30
74
  T extends Record<string, any>,
31
75
  I extends Record<string, any>,
32
76
  R extends boolean | undefined = true,
33
77
  RT = R extends false ? WithContext<I> : WithIndexedContext<T, I>
34
78
  >(
35
- db?: Documents<T, I>,
36
- options?: {
37
- resolve?: R;
38
- transform?: (r: RT) => Promise<RT>;
39
- debounce?: number;
40
- debug?: boolean | { id: string };
41
- reverse?: boolean;
42
- batchSize?: number;
43
- prefetch?: boolean;
44
- onChange?: {
45
- merge?:
46
- | boolean
47
- | ((
48
- c: DocumentsChange<T, I>
49
- ) =>
50
- | DocumentsChange<T, I>
51
- | Promise<DocumentsChange<T, I>>
52
- | undefined);
53
- update?: (prev: RT[], change: DocumentsChange<T, I>) => RT[];
54
- };
55
- local?: boolean;
56
- remote?: boolean | RemoteQueryOptions;
57
- } & QueryOptions
79
+ /** Single DB or list of DBs. 100 % backward-compatible with the old single param. */
80
+ dbOrDbs: Documents<T, I> | Documents<T, I>[] | undefined,
81
+ options: UseQuerySharedOptions<T, I, R, RT>
58
82
  ) => {
59
- /* ── «Item» is the concrete element type flowing through the hook ── */
83
+ /* ─────── internal type alias for convenience ─────── */
60
84
  type Item = RT;
61
85
 
86
+ /* ────────────── normalise DBs input ────────────── */
87
+ const dbs = useMemo<(Documents<T, I> | undefined)[]>(() => {
88
+ if (Array.isArray(dbOrDbs)) return dbOrDbs;
89
+ if (dbOrDbs) return [dbOrDbs];
90
+ return [];
91
+ }, [dbOrDbs]);
92
+
62
93
  /* ────────────── state & refs ────────────── */
63
94
  const [all, setAll] = useState<Item[]>([]);
64
95
  const allRef = useRef<Item[]>([]);
65
96
  const [isLoading, setIsLoading] = useState(false);
66
- const loadingMoreRef = useRef<boolean>(false);
67
- const iteratorRef = useRef<{
68
- id?: string;
69
- iterator: ResultsIterator<Item>;
70
- itemsConsumed: number;
71
- } | null>(null);
97
+ const iteratorRefs = useRef<
98
+ {
99
+ id: string;
100
+ db: Documents<T, I>;
101
+ iterator: ResultsIterator<Item>;
102
+ itemsConsumed: number;
103
+ }[]
104
+ >([]);
72
105
  const emptyResultsRef = useRef(false);
73
106
  const closeControllerRef = useRef<AbortController | null>(null);
74
107
  const waitedOnceRef = useRef(false);
75
108
 
76
- const [id, setId] = useState<string | undefined>(undefined);
109
+ /* keep an id mostly for debugging mirrors original behaviour */
110
+ const [id, setId] = useState<string | undefined>(options.id);
77
111
 
78
- const reverseRef = useRef(options?.reverse);
112
+ const reverseRef = useRef(options.reverse);
79
113
  useEffect(() => {
80
- reverseRef.current = options?.reverse;
81
- }, [options?.reverse]);
114
+ reverseRef.current = options.reverse;
115
+ }, [options.reverse]);
82
116
 
83
- /* ────────────── util ────────────── */
117
+ /* ────────────── utilities ────────────── */
84
118
  const log = (...a: any[]) => {
85
- if (!options?.debug) return;
119
+ if (!options.debug) return;
86
120
  if (typeof options.debug === "boolean") console.log(...a);
87
121
  else console.log(options.debug.id, ...a);
88
122
  };
@@ -92,303 +126,215 @@ export const useQuery = <
92
126
  setAll(combined);
93
127
  };
94
128
 
95
- const reset = (
96
- fromRef: {
97
- id?: string;
98
- iterator: ResultsIterator<Item>;
99
- } | null
100
- ) => {
101
- const toClose = iteratorRef.current;
102
- if (toClose && fromRef && toClose !== fromRef) {
103
- return;
104
- }
105
-
106
- iteratorRef.current = null;
129
+ const reset = () => {
130
+ iteratorRefs.current.forEach(({ iterator }) => iterator.close());
131
+ iteratorRefs.current = [];
107
132
 
108
133
  closeControllerRef.current?.abort();
109
134
  closeControllerRef.current = new AbortController();
110
135
  emptyResultsRef.current = false;
111
-
112
- toClose?.iterator.close();
136
+ waitedOnceRef.current = false;
113
137
 
114
138
  allRef.current = [];
115
139
  setAll([]);
116
-
117
140
  setIsLoading(false);
118
- loadingMoreRef.current = false;
119
- log("Iterator reset", toClose?.id, fromRef?.id);
120
- setId(undefined);
141
+ log("Iterators reset");
121
142
  };
122
143
 
144
+ /* ────────── rebuild iterators when db list / query etc. change ────────── */
123
145
  useEffect(() => {
124
- waitedOnceRef.current = false;
125
- }, [db, options?.id ?? options?.query, options?.resolve, options?.reverse]);
126
-
127
- /* ────────────── effect: (re)create iterator ────────────── */
128
- useEffect(() => {
129
- if (!db || db.closed || options?.query == null) {
130
- reset(null);
146
+ /* derive canonical list of open DBs */
147
+ const openDbs = dbs.filter((d): d is Documents<T, I> =>
148
+ Boolean(d && !d.closed)
149
+ );
150
+ const { query, resolve } = options;
151
+
152
+ if (!openDbs.length || query == null) {
153
+ reset();
131
154
  return;
132
155
  }
133
156
 
134
- const initIterator = () => {
135
- let id = options?.id ?? uuid();
136
- let remoteQueryOptions =
137
- options.remote == null || options.remote === false
138
- ? false
139
- : {
140
- ...(typeof options.remote === "object"
141
- ? options.remote
142
- : {}),
143
- joining:
144
- typeof options.remote === "object" &&
145
- options.remote.joining?.waitFor !== undefined
146
- ? {
147
- waitFor:
148
- options.remote.joining?.waitFor ??
149
- 5e3,
150
- onMissedResults: ({ amount }) => {
151
- loadMore(amount, true);
152
- },
153
- }
154
- : undefined,
155
- };
156
- const ref = {
157
- id,
158
- iterator: db.index.iterate(options.query ?? {}, {
159
- local: options?.local ?? true,
160
- remote: remoteQueryOptions,
161
- resolve: options?.resolve,
162
- signal: closeControllerRef.current?.signal,
163
- }) as ResultsIterator<Item>,
164
- itemsConsumed: 0,
165
- };
166
- iteratorRef.current = ref;
167
- if (options?.prefetch) {
168
- loadMore();
169
- }
170
- setId(id);
157
+ reset();
158
+ const abortSignal = closeControllerRef.current?.signal;
171
159
 
172
- log("Iterator initialised", ref.id);
173
- return ref;
174
- };
175
-
176
- reset(iteratorRef.current);
177
- const newIteratorRef = initIterator();
160
+ iteratorRefs.current = openDbs.map((db) => {
161
+ const iterator = db.index.iterate(query ?? {}, {
162
+ local: options.local ?? true,
163
+ remote: options.remote ?? undefined,
164
+ resolve,
165
+ signal: abortSignal,
166
+ }) as ResultsIterator<Item>;
178
167
 
179
- /* live-merge listener (optional) */
180
- let handleChange:
181
- | ((e: CustomEvent<DocumentsChange<T, I>>) => void | Promise<void>)
182
- | undefined;
183
-
184
- if (options?.onChange && options.onChange.merge !== false) {
185
- const mergeFn =
186
- typeof options.onChange.merge === "function"
187
- ? options.onChange.merge
188
- : (c: DocumentsChange<T, I>) => c;
189
-
190
- handleChange = async (e: CustomEvent<DocumentsChange<T, I>>) => {
191
- log("Merge change", e.detail, "iterator", newIteratorRef.id);
192
- const filtered = await mergeFn(e.detail);
193
- if (
194
- !filtered ||
195
- (filtered.added.length === 0 &&
196
- filtered.removed.length === 0)
197
- )
198
- return;
199
-
200
- let merged: Item[];
201
- if (options.onChange?.update) {
202
- merged = [
203
- ...options.onChange?.update(allRef.current, filtered),
204
- ];
205
- } else {
206
- merged = await db.index.updateResults(
207
- allRef.current as WithContext<RT>[],
208
- filtered,
209
- options.query || {},
210
- options.resolve ?? true
211
- );
212
-
213
- log("After update", allRef.current, merged);
214
- const expectedDiff =
215
- filtered.added.length - filtered.removed.length;
216
-
217
- if (
218
- merged === allRef.current ||
219
- (expectedDiff !== 0 &&
220
- merged.length === allRef.current.length)
221
- ) {
222
- // no change
223
- log("no change after merge");
224
- return;
225
- }
226
- }
227
-
228
- updateAll(options?.reverse ? merged.reverse() : merged);
229
- };
230
-
231
- db.events.addEventListener("change", handleChange);
232
- }
168
+ const ref = { id: uuid(), db, iterator, itemsConsumed: 0 };
169
+ log("Iterator init", ref.id, "db", db.address);
170
+ return ref;
171
+ });
233
172
 
234
- return () => {
235
- handleChange &&
236
- db.events.removeEventListener("change", handleChange);
237
- reset(newIteratorRef);
238
- };
173
+ /* prefetch if requested */
174
+ if (options.prefetch) void loadMore();
175
+ /* store a deterministic id (useful for external keys) */
176
+ setId(uuid());
177
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
178
  }, [
240
- db?.closed ? undefined : db?.address,
241
- options?.id ?? options?.query,
242
- options?.resolve,
243
- options?.reverse,
179
+ dbs.map((d) => d?.address).join("|"),
180
+ options.query,
181
+ options.resolve,
182
+ options.reverse,
244
183
  ]);
245
184
 
246
- /* ────────────── loadMore (once-wait aware) ────────────── */
247
- const batchSize = options?.batchSize ?? 10;
185
+ /* ────────────── loadMore implementation ────────────── */
186
+ const batchSize = options.batchSize ?? 10;
248
187
 
249
188
  const shouldWait = (): boolean => {
250
- if (waitedOnceRef.current) {
251
- return false;
252
- }
253
- if (options?.remote === false) return false;
254
- if (options?.remote === true) return true;
255
- if (options?.remote == null) return true;
256
- if (typeof options?.remote === "object") {
257
- return true;
258
- }
259
- return true;
189
+ if (waitedOnceRef.current) return false;
190
+ if (options.remote === false) return false;
191
+ return true; // mimic original behaviour – wait once if remote allowed
260
192
  };
261
193
 
262
194
  const markWaited = () => {
263
195
  waitedOnceRef.current = true;
264
196
  };
265
197
 
266
- const loadMore = async (
267
- n: number = batchSize,
268
- pollEvenIfWasEmpty = false
269
- ) => {
270
- const iterator = iteratorRef.current;
271
- if (
272
- !iterator ||
273
- (emptyResultsRef.current && !pollEvenIfWasEmpty) ||
274
- iterator.iterator.done() ||
275
- loadingMoreRef.current
276
- ) {
277
- return false;
278
- }
198
+ const loadMore = async (n: number = batchSize): Promise<boolean> => {
199
+ const iterators = iteratorRefs.current;
200
+ if (!iterators.length || emptyResultsRef.current) return false;
279
201
 
280
202
  setIsLoading(true);
281
- loadingMoreRef.current = true;
282
-
283
203
  try {
284
- /* ── optional replicate-wait ── */
204
+ /* one-time replicator warm-up across all DBs */
285
205
  if (shouldWait()) {
286
- log("Wait for replicators", iterator.id);
287
- let t0 = Date.now();
288
-
289
- const warmup =
290
- typeof options?.remote === "object" &&
291
- typeof options?.remote.warmup === "number"
292
- ? options?.remote.warmup
293
- : undefined;
294
-
295
- if (warmup) {
296
- await db?.log
297
- .waitForReplicators({
298
- timeout: warmup,
299
- signal: closeControllerRef.current?.signal,
300
- })
301
- .catch((e) => {
302
- if (
303
- e instanceof AbortError ||
304
- e instanceof NoPeersError
305
- )
306
- return;
307
- console.warn("Remote replicators not ready", e);
206
+ if (
207
+ typeof options.remote === "object" &&
208
+ options.remote.warmup
209
+ ) {
210
+ await Promise.all(
211
+ iterators.map(async ({ db }) => {
212
+ try {
213
+ await db.log.waitForReplicators({
214
+ timeout: (options.remote as { warmup })
215
+ .warmup,
216
+ signal: closeControllerRef.current?.signal,
217
+ });
218
+ } catch (e) {
219
+ if (
220
+ e instanceof AbortError ||
221
+ e instanceof NoPeersError
222
+ )
223
+ return;
224
+ console.warn("Remote replicators not ready", e);
225
+ }
308
226
  })
309
- .finally(() => {
310
- log(
311
- "Wait for replicators done",
312
- iterator.id,
313
- "time",
314
- Date.now() - t0
315
- );
316
- markWaited();
317
- });
227
+ );
318
228
  }
319
- } else {
320
- log("Skip wait for replicators", iterator.id);
229
+ markWaited();
321
230
  }
322
231
 
323
- /* ── fetch next batch ── */
324
- log("Retrieve next batch", iterator.id);
325
-
326
- let newItems = await iterator.iterator.next(n);
327
-
328
- if (options?.transform) {
329
- log("Transform start", iterator.id);
330
-
331
- newItems = await Promise.all(newItems.map(options.transform));
332
- log("Transform end", iterator.id);
232
+ /* pull items round-robin */
233
+ const newlyFetched: Item[] = [];
234
+ for (const ref of iterators) {
235
+ if (ref.iterator.done()) continue;
236
+ const batch = await ref.iterator.next(n); // pull up to <n> at once
237
+ if (batch.length) {
238
+ ref.itemsConsumed += batch.length;
239
+ newlyFetched.push(...batch);
240
+ }
333
241
  }
334
242
 
335
- /* iterator might have been reset while we were async… */
336
-
337
- if (iteratorRef.current !== iterator) {
338
- log("Iterator reset while loading more");
339
- return false;
243
+ if (!newlyFetched.length) {
244
+ emptyResultsRef.current = iterators.every((i) =>
245
+ i.iterator.done()
246
+ );
247
+ return !emptyResultsRef.current;
340
248
  }
341
249
 
342
- iterator.itemsConsumed += newItems.length;
343
-
344
- emptyResultsRef.current = newItems.length === 0;
345
-
346
- if (newItems.length) {
347
- log(
348
- "Loaded more items for iterator",
349
- iterator.id,
350
- "current id",
351
- iteratorRef.current?.id,
352
- "new items",
353
- newItems.length,
354
- "previous results",
355
- allRef.current.length,
356
- "batchSize",
357
- batchSize,
358
- "items consumed",
359
- iterator.itemsConsumed
360
- );
361
- const prev = allRef.current;
362
- const dedup = new Set(
363
- prev.map((x) => (x as any).__context.head)
364
- );
365
- const unique = newItems.filter(
366
- (x) => !dedup.has((x as any).__context.head)
367
- );
368
- if (!unique.length) return;
369
-
370
- const combined = reverseRef.current
371
- ? [...unique.reverse(), ...prev]
372
- : [...prev, ...unique];
373
- updateAll(combined);
374
- } else {
375
- log("No new items", iterator.id);
250
+ /* optional transform */
251
+ let processed = newlyFetched;
252
+ if (options.transform) {
253
+ processed = await Promise.all(processed.map(options.transform));
376
254
  }
377
- return !iterator.iterator.done();
255
+
256
+ /* deduplicate & merge */
257
+ const prev = allRef.current;
258
+ const dedupHeads = new Set(
259
+ prev.map((x) => (x as any).__context.head)
260
+ );
261
+ const unique = processed.filter(
262
+ (x) => !dedupHeads.has((x as any).__context.head)
263
+ );
264
+ if (!unique.length)
265
+ return !iterators.every((i) => i.iterator.done());
266
+
267
+ const combined = reverseRef.current
268
+ ? [...unique.reverse(), ...prev]
269
+ : [...prev, ...unique];
270
+ updateAll(combined);
271
+
272
+ emptyResultsRef.current = iterators.every((i) => i.iterator.done());
273
+ return !emptyResultsRef.current;
378
274
  } catch (e) {
379
275
  if (!(e instanceof ClosedError)) throw e;
276
+ return false;
380
277
  } finally {
381
278
  setIsLoading(false);
382
- loadingMoreRef.current = false;
383
279
  }
384
280
  };
385
281
 
386
- /* ────────────── public API ────────────── */
282
+ /* ────────────── live-merge listeners ────────────── */
283
+ useEffect(() => {
284
+ if (!options.onChange || options.onChange.merge === false) return;
285
+
286
+ const listeners = iteratorRefs.current.map(({ db, id: itId }) => {
287
+ const mergeFn =
288
+ typeof options.onChange?.merge === "function"
289
+ ? options.onChange.merge
290
+ : (c: DocumentsChange<T, I>) => c;
291
+
292
+ const handler = async (e: CustomEvent<DocumentsChange<T, I>>) => {
293
+ log("Merge change", e.detail, "it", itId);
294
+ const filtered = await mergeFn(e.detail);
295
+ if (
296
+ !filtered ||
297
+ (!filtered.added.length && !filtered.removed.length)
298
+ )
299
+ return;
300
+
301
+ let merged: Item[];
302
+ if (options.onChange?.update) {
303
+ merged = await options.onChange.update(
304
+ allRef.current,
305
+ filtered
306
+ );
307
+ } else {
308
+ merged = await db.index.updateResults(
309
+ allRef.current as WithContext<RT>[],
310
+ filtered,
311
+ options.query || {},
312
+ options.resolve ?? true
313
+ );
314
+ }
315
+ updateAll(options.reverse ? merged.reverse() : merged);
316
+ };
317
+ db.events.addEventListener("change", handler);
318
+ return { db, handler };
319
+ });
320
+
321
+ return () => {
322
+ listeners.forEach(({ db, handler }) =>
323
+ db.events.removeEventListener("change", handler)
324
+ );
325
+ };
326
+ // eslint-disable-next-line react-hooks/exhaustive-deps
327
+ }, [
328
+ iteratorRefs.current.map((r) => r.db.address).join("|"),
329
+ options.onChange,
330
+ ]);
331
+
332
+ /* ────────────── public API – unchanged from the caller's perspective ────────────── */
387
333
  return {
388
334
  items: all,
389
335
  loadMore,
390
336
  isLoading,
391
337
  empty: () => emptyResultsRef.current,
392
- id: id,
338
+ id,
393
339
  };
394
340
  };