@peerbit/document-react 0.1.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.
@@ -0,0 +1,120 @@
1
+ import { ClosedError, Documents } from "@peerbit/document";
2
+ import type { WithContext } from "@peerbit/document";
3
+ import * as indexerTypes from "@peerbit/indexer-interface";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { debounceLeadingTrailing } from "./utils.js";
6
+
7
+ type QueryLike = {
8
+ query?: indexerTypes.Query[] | indexerTypes.QueryLike;
9
+ sort?: indexerTypes.Sort[] | indexerTypes.Sort | indexerTypes.SortLike;
10
+ };
11
+ type QueryOptons = {
12
+ query: QueryLike;
13
+ id: string;
14
+ };
15
+
16
+ const logWithId = (
17
+ options: { debug?: boolean | { id: string } } | undefined,
18
+ ...args: any[]
19
+ ) => {
20
+ if (!options?.debug) return;
21
+
22
+ if (typeof options.debug === "boolean") {
23
+ console.log(...args);
24
+ } else if (typeof options.debug.id === "string") {
25
+ console.log(options.debug.id, ...args);
26
+ }
27
+ };
28
+
29
+ export const useLocal = <
30
+ T extends Record<string, any>,
31
+ I extends Record<string, any>,
32
+ R extends boolean | undefined = true,
33
+ RT = R extends false ? WithContext<I> : WithContext<T>,
34
+ >(
35
+ db?: Documents<T, I>,
36
+ options?: {
37
+ resolve?: R;
38
+ transform?: (result: RT) => Promise<RT>;
39
+ onChanges?: (all: RT[]) => void;
40
+ debounce?: number;
41
+ debug?: boolean | { id: string };
42
+ } & QueryOptons,
43
+ ) => {
44
+ const [all, setAll] = useState<RT[]>([]);
45
+ const emptyResultsRef = useRef(false);
46
+
47
+ useEffect(() => {
48
+ if (!db || db.closed || options?.query === null) {
49
+ // null query means no query at all
50
+ return;
51
+ }
52
+
53
+ logWithId(
54
+ options,
55
+ "reset local",
56
+ db?.closed ? undefined : db?.rootAddress,
57
+ options?.id,
58
+ options?.resolve,
59
+ options?.onChanges,
60
+ options?.transform,
61
+ );
62
+
63
+ const _l = async (args?: any) => {
64
+ try {
65
+ const iterator = db.index.iterate(options?.query ?? {}, {
66
+ local: true,
67
+ remote: false,
68
+ resolve: options?.resolve as any,
69
+ });
70
+
71
+ let results: RT[] = (await iterator.all()) as any;
72
+
73
+ if (options?.transform) {
74
+ results = await Promise.all(
75
+ results.map((x) => options.transform!(x)),
76
+ );
77
+ }
78
+ logWithId(options, options?.id, results, "query", options?.query);
79
+
80
+ emptyResultsRef.current = results.length === 0;
81
+ setAll(() => {
82
+ options?.onChanges?.(results);
83
+ return results;
84
+ });
85
+ } catch (error) {
86
+ if (error instanceof ClosedError) {
87
+ return;
88
+ }
89
+ throw error;
90
+ }
91
+ };
92
+
93
+ const debounced = debounceLeadingTrailing(_l, options?.debounce ?? 123);
94
+
95
+ const handleChange = () => {
96
+ if (emptyResultsRef.current) {
97
+ debounced.cancel();
98
+ _l();
99
+ } else {
100
+ debounced();
101
+ }
102
+ };
103
+
104
+ debounced();
105
+ db.events.addEventListener("change", handleChange);
106
+
107
+ return () => {
108
+ db.events.removeEventListener("change", handleChange);
109
+ debounced.cancel();
110
+ };
111
+ }, [
112
+ db?.closed ? undefined : db?.rootAddress,
113
+ options?.id,
114
+ options?.resolve,
115
+ options?.onChanges,
116
+ options?.transform,
117
+ ]);
118
+
119
+ return all;
120
+ };
@@ -0,0 +1,15 @@
1
+ import { type EffectCallback, useEffect, useRef } from "react";
2
+
3
+ export const useMount = (effect: EffectCallback) => {
4
+ const mounted = useRef(false);
5
+
6
+ useEffect(() => {
7
+ if (!mounted.current) {
8
+ effect();
9
+ }
10
+
11
+ mounted.current = true;
12
+
13
+ return () => {};
14
+ }, [mounted.current]);
15
+ };
@@ -0,0 +1,561 @@
1
+ import { ClosedError, Documents } from "@peerbit/document";
2
+ import type {
3
+ AbstractSearchRequest,
4
+ AbstractSearchResult,
5
+ Context,
6
+ RemoteQueryOptions,
7
+ ResultsIterator,
8
+ WithContext,
9
+ } from "@peerbit/document";
10
+ import type { UpdateOptions, WithIndexedContext } from "@peerbit/document";
11
+ import * as indexerTypes from "@peerbit/indexer-interface";
12
+ import { useEffect, useMemo, useRef, useState } from "react";
13
+ import { v4 as uuid } from "uuid";
14
+
15
+ type QueryOptions = { query: QueryLike; id?: string };
16
+
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[];
23
+ };
24
+
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<
30
+ T,
31
+ I,
32
+ R extends boolean | undefined,
33
+ RT = R extends false ? WithContext<I> : WithIndexedContext<T, I>,
34
+ > = {
35
+ /* original behavioural flags */
36
+ resolve?: R;
37
+ transform?: (r: RT) => Promise<RT>;
38
+ debounce?: number;
39
+ debug?: boolean | string;
40
+ reverse?: boolean;
41
+ batchSize?: number;
42
+ prefetch?: boolean;
43
+ /* onChange?: {
44
+ merge?:
45
+ | boolean
46
+ | ((
47
+ c: DocumentsChange<T, I>
48
+ ) =>
49
+ | DocumentsChange<T, I>
50
+ | Promise<DocumentsChange<T, I>>
51
+ | undefined);
52
+ update?: (
53
+ prev: RT[],
54
+ change: DocumentsChange<T, I>
55
+ ) => RT[] | Promise<RT[]>;
56
+ }; */
57
+ updates?: UpdateOptions<T, I, R>;
58
+ local?: boolean;
59
+ remote?:
60
+ | boolean
61
+ | RemoteQueryOptions<AbstractSearchRequest, AbstractSearchResult, any>;
62
+ } & QueryOptions;
63
+
64
+ /* ────────────────────────── Main Hook ────────────────────────── */
65
+ /**
66
+ * `useQuery` – unified hook that accepts **either**
67
+ * 1. a single `Documents` instance
68
+ * 2. an array of `Documents` instances
69
+ * 3. *or* omits the first argument and provides `dbs` inside the `options` object.
70
+ *
71
+ * It supersedes the original single-DB version as well as the experimental
72
+ * `useMultiQuery` so callers never have to choose between two APIs.
73
+ */
74
+ export const useQuery = <
75
+ T extends Record<string, any>,
76
+ I extends Record<string, any>,
77
+ R extends boolean | undefined = true,
78
+ RT = R extends false ? WithContext<I> : WithIndexedContext<T, I>,
79
+ >(
80
+ /** Single DB or list of DBs. 100 % backward-compatible with the old single param. */
81
+ dbOrDbs: Documents<T, I> | Documents<T, I>[] | undefined,
82
+ options: UseQuerySharedOptions<T, I, R, RT>,
83
+ ) => {
84
+ /* ─────── internal type alias for convenience ─────── */
85
+ type Item = RT;
86
+ type IteratorRef = {
87
+ id: string;
88
+ db: Documents<T, I>;
89
+ iterator: ResultsIterator<Item>;
90
+ itemsConsumed: number;
91
+ };
92
+
93
+ /* ────────────── normalise DBs input ────────────── */
94
+ const dbs = useMemo<(Documents<T, I> | undefined)[]>(() => {
95
+ if (Array.isArray(dbOrDbs)) return dbOrDbs;
96
+ if (dbOrDbs) return [dbOrDbs];
97
+ return [];
98
+ }, [dbOrDbs]);
99
+
100
+ /* ────────────── state & refs ────────────── */
101
+ const [all, setAll] = useState<Item[]>([]);
102
+ const allRef = useRef<Item[]>([]);
103
+ const [isLoading, setIsLoading] = useState(false);
104
+ const iteratorRefs = useRef<IteratorRef[]>([]);
105
+ const itemIdRef = useRef(new WeakMap<object, string>());
106
+ const emptyResultsRef = useRef(false);
107
+ const closeControllerRef = useRef<AbortController | null>(null);
108
+ const waitedOnceRef = useRef(false);
109
+
110
+ /* keep an id mostly for debugging – mirrors original behaviour */
111
+ const [id, setId] = useState<string | undefined>(options.id);
112
+
113
+ const reverseRef = useRef(options.reverse);
114
+ useEffect(() => {
115
+ reverseRef.current = options.reverse;
116
+ }, [options.reverse]);
117
+
118
+ /* ────────────── utilities ────────────── */
119
+ const log = (...a: any[]) => {
120
+ if (!options.debug) return;
121
+ if (typeof options.debug === "boolean") console.log(...a);
122
+ else console.log(options.debug, ...a);
123
+ };
124
+
125
+ const updateAll = (combined: Item[]) => {
126
+ allRef.current = combined;
127
+ setAll(combined);
128
+ };
129
+
130
+ const reset = () => {
131
+ iteratorRefs.current?.forEach(({ iterator }) => iterator.close());
132
+ iteratorRefs.current = [];
133
+
134
+ closeControllerRef.current?.abort(new Error("Reset"));
135
+ closeControllerRef.current = new AbortController();
136
+ emptyResultsRef.current = false;
137
+ waitedOnceRef.current = false;
138
+
139
+ allRef.current = [];
140
+ itemIdRef.current = new WeakMap();
141
+ setAll([]);
142
+ setIsLoading(false);
143
+ log("Iterators reset");
144
+ };
145
+
146
+ /* ────────── rebuild iterators when db list / query etc. change ────────── */
147
+ useEffect(() => {
148
+ /* derive canonical list of open DBs */
149
+ const openDbs = dbs.filter((d): d is Documents<T, I> =>
150
+ Boolean(d && !d.closed),
151
+ );
152
+ const { query, resolve } = options;
153
+
154
+ if (!openDbs.length || query == null) {
155
+ reset();
156
+ return;
157
+ }
158
+
159
+ reset();
160
+ const abortSignal = closeControllerRef.current?.signal;
161
+ const onMissedResults = (evt: { amount: number }) => {
162
+ console.error("Not effective yet: missed results", evt);
163
+ /* if (allRef.current.length > 0 || typeof options.remote !== "object" || !options.updates) {
164
+ return;
165
+ }
166
+ console.log("Missed results, loading more", evt.amount);
167
+ loadMore(evt.amount); */
168
+ };
169
+ let draining = false;
170
+ const scheduleDrain = (
171
+ ref: ResultsIterator<RT>,
172
+ amount: number,
173
+ opts?: { force?: boolean },
174
+ ) => {
175
+ log("Schedule drain", draining, ref, amount);
176
+ if (draining) return;
177
+ draining = true;
178
+ loadMore(amount, opts)
179
+ .catch((e) => {
180
+ if (!(e instanceof ClosedError)) throw e;
181
+ })
182
+ .finally(() => {
183
+ draining = false;
184
+ });
185
+ };
186
+
187
+ iteratorRefs.current = openDbs.map((db) => {
188
+ let currentRef: IteratorRef | undefined;
189
+ const iterator = db.index.iterate(query ?? {}, {
190
+ closePolicy: "manual",
191
+ local: options.local ?? true,
192
+ remote: options.remote
193
+ ? {
194
+ ...(typeof options?.remote === "object"
195
+ ? {
196
+ ...options.remote,
197
+ onLateResults: onMissedResults,
198
+ wait: {
199
+ ...options?.remote?.wait,
200
+ timeout: options?.remote?.wait?.timeout ?? 5000,
201
+ },
202
+ }
203
+ : options?.remote
204
+ ? {
205
+ onLateResults: onMissedResults,
206
+ }
207
+ : undefined),
208
+ }
209
+ : undefined,
210
+ resolve,
211
+ signal: abortSignal,
212
+ updates: {
213
+ push:
214
+ typeof options.updates === "boolean"
215
+ ? options.updates
216
+ : typeof options.updates === "object" && options.updates.push
217
+ ? true
218
+ : false,
219
+ merge:
220
+ typeof options.updates === "boolean" && options.updates
221
+ ? true
222
+ : typeof options.updates === "object" && options.updates.merge
223
+ ? true
224
+ : false,
225
+ notify: (reason) => {
226
+ log("notify", { reason, currentRef: !!currentRef });
227
+ if (reason === "change" || reason === "push" || reason === "join") {
228
+ const drainAmount = options.batchSize ?? 10;
229
+ scheduleDrain(iterator as ResultsIterator<RT>, drainAmount, {
230
+ force: true,
231
+ });
232
+ }
233
+ },
234
+ onBatch: (batch, props) => {
235
+ log("onBatch", { batch, props, currentRef: !!currentRef });
236
+ if (
237
+ props.reason === "join" ||
238
+ props.reason === "change" ||
239
+ props.reason === "push"
240
+ ) {
241
+ if (!currentRef) return;
242
+ handleBatch(iteratorRefs.current, [
243
+ { ref: currentRef, items: batch as Item[] },
244
+ ]);
245
+ }
246
+ },
247
+ },
248
+ }) as ResultsIterator<Item>;
249
+
250
+ const ref: IteratorRef = {
251
+ id: uuid(),
252
+ db,
253
+ iterator,
254
+ itemsConsumed: 0,
255
+ };
256
+ currentRef = ref;
257
+ log("Iterator init", ref.id, "db", db.address);
258
+ return ref;
259
+ });
260
+
261
+ /* store a deterministic id (useful for external keys) */
262
+ setId(uuid());
263
+
264
+ /* prefetch if requested */
265
+ if (options.prefetch) void loadMore();
266
+ // eslint-disable-next-line react-hooks/exhaustive-deps
267
+ }, [
268
+ dbs.map((d) => d?.address).join("|"),
269
+ options.query,
270
+ options.resolve,
271
+ options.reverse,
272
+ options.batchSize,
273
+ ]);
274
+
275
+ /* ────────────── loadMore implementation ────────────── */
276
+ const batchSize = options.batchSize ?? 10;
277
+
278
+ const shouldWait = (): boolean => {
279
+ if (waitedOnceRef.current) return false;
280
+ if (options.remote === false) return false;
281
+ return true; // mimic original behaviour – wait once if remote allowed
282
+ };
283
+
284
+ const markWaited = () => {
285
+ waitedOnceRef.current = true;
286
+ };
287
+
288
+ /* helper to turn primitive ids into stable map keys */
289
+ const idToKey = (value: indexerTypes.IdPrimitive): string => {
290
+ switch (typeof value) {
291
+ case "string":
292
+ return `s:${value}`;
293
+ case "number":
294
+ return `n:${value}`;
295
+ default:
296
+ return `b:${value.toString()}`;
297
+ }
298
+ };
299
+
300
+ const handleBatch = async (
301
+ iterators: IteratorRef[],
302
+ batches: { ref: IteratorRef; items: Item[] }[],
303
+ ): Promise<boolean> => {
304
+ if (!iterators.length) {
305
+ log("No iterators in handleBatch");
306
+ return false;
307
+ }
308
+
309
+ const totalFetched = batches.reduce(
310
+ (sum, batch) => sum + batch.items.length,
311
+ 0,
312
+ );
313
+ if (totalFetched === 0) {
314
+ log("No items fetched");
315
+ emptyResultsRef.current = iterators.every((i) => i.iterator.done());
316
+ return !emptyResultsRef.current;
317
+ }
318
+
319
+ let processed = batches;
320
+ if (options.transform) {
321
+ const transform = options.transform;
322
+ processed = await Promise.all(
323
+ batches.map(async ({ ref, items }) => ({
324
+ ref,
325
+ items: await Promise.all(items.map(transform)),
326
+ })),
327
+ );
328
+ }
329
+
330
+ const prev = allRef.current;
331
+ const next = [...prev];
332
+ const keyIndex = new Map<string, number>();
333
+ prev.forEach((item, idx) => {
334
+ const key = itemIdRef.current.get(item as object);
335
+ if (key) keyIndex.set(key, idx);
336
+ });
337
+
338
+ const seenHeads = new Set(prev.map((x) => (x as any).__context?.head));
339
+ const freshItems: Item[] = [];
340
+ let hasMutations = false;
341
+
342
+ log("Processing batches", { processed, keyIndex });
343
+ for (const { ref, items } of processed) {
344
+ const db = ref.db;
345
+ for (const item of items) {
346
+ const ctx = (item as WithContext<any>).__context;
347
+ const head = ctx?.head;
348
+
349
+ let key: string | null = null;
350
+ try {
351
+ key = idToKey(
352
+ db.index.resolveId(
353
+ item as WithContext<I> | WithIndexedContext<T, I>,
354
+ ).primitive,
355
+ );
356
+ } catch (error) {
357
+ log("useQuery: failed to resolve id", error);
358
+ }
359
+
360
+ if (key && keyIndex.has(key)) {
361
+ const existingIndex = keyIndex.get(key)!;
362
+ const current = next[existingIndex];
363
+ const currentContext: Context | undefined = (
364
+ current as WithContext<any>
365
+ )?.__context;
366
+ const incomingContext: Context | undefined = ctx;
367
+ const shouldReplace =
368
+ !currentContext ||
369
+ !incomingContext ||
370
+ currentContext.modified <= incomingContext.modified;
371
+
372
+ if (shouldReplace && current !== item) {
373
+ itemIdRef.current.delete(current as object);
374
+ next[existingIndex] = item;
375
+ hasMutations = true;
376
+ }
377
+
378
+ if (key) {
379
+ itemIdRef.current.set(item as object, key);
380
+ keyIndex.set(key, existingIndex);
381
+ }
382
+ if (head != null) seenHeads.add(head);
383
+ continue;
384
+ }
385
+
386
+ if (head != null && seenHeads.has(head)) continue;
387
+ if (head != null) seenHeads.add(head);
388
+
389
+ freshItems.push(item);
390
+ if (key) {
391
+ itemIdRef.current.set(item as object, key);
392
+ keyIndex.set(key, prev.length + freshItems.length - 1);
393
+ }
394
+ }
395
+ }
396
+
397
+ if (!freshItems.length && !hasMutations) {
398
+ emptyResultsRef.current = iterators.every((i) => i.iterator.done());
399
+ log("No new items or mutations");
400
+ return !emptyResultsRef.current;
401
+ }
402
+
403
+ const combined = reverseRef.current
404
+ ? [...freshItems.reverse(), ...next]
405
+ : [...next, ...freshItems];
406
+
407
+ log("Updating all with", {
408
+ prevLength: prev.length,
409
+ freshLength: freshItems.length,
410
+ combinedLength: combined.length,
411
+ });
412
+ updateAll(combined);
413
+
414
+ emptyResultsRef.current = iterators.every((i) => i.iterator.done());
415
+ return !emptyResultsRef.current;
416
+ };
417
+
418
+ const drainRoundRobin = async (
419
+ iterators: IteratorRef[],
420
+ n: number,
421
+ ): Promise<boolean> => {
422
+ const batches: { ref: IteratorRef; items: Item[] }[] = [];
423
+ for (const ref of iterators) {
424
+ if (ref.iterator.done()) continue;
425
+ const batch = await ref.iterator.next(n);
426
+ log("Iterator", ref.id, "fetched", batch.length, "items");
427
+ if (batch.length) {
428
+ ref.itemsConsumed += batch.length;
429
+ batches.push({ ref, items: batch });
430
+ }
431
+ }
432
+ return handleBatch(iterators, batches);
433
+ };
434
+
435
+ /* maybe make the rule that if results are empty and we get results from joining
436
+ set the results to the joining results
437
+ when results are not empty use onMerge option to merge the results ? */
438
+
439
+ const loadMore = async (
440
+ n: number = batchSize,
441
+ opts?: { force?: boolean },
442
+ ): Promise<boolean> => {
443
+ const iterators = iteratorRefs.current;
444
+ if (!iterators.length) {
445
+ log("No iterators or already empty", {
446
+ length: iterators.length,
447
+ emptyResultsRef: emptyResultsRef.current,
448
+ });
449
+ return false;
450
+ }
451
+ if (emptyResultsRef.current && !opts?.force) {
452
+ log("Skipping loadMore due to empty state");
453
+ return false;
454
+ } else if (opts?.force) {
455
+ emptyResultsRef.current = false;
456
+ }
457
+
458
+ setIsLoading(true);
459
+ try {
460
+ /* one-time replicator warm-up across all DBs */
461
+ if (shouldWait()) {
462
+ /* if (
463
+ typeof options.remote === "object" &&
464
+ options.remote.wait
465
+ ) {
466
+ await Promise.all(
467
+ iterators.map(async ({ db }) => {
468
+ try {
469
+ await db.log.waitForReplicators({
470
+ timeout: (options.remote as { warmup })
471
+ .warmup,
472
+ signal: closeControllerRef.current?.signal,
473
+ });
474
+ } catch (e) {
475
+ if (
476
+ e instanceof AbortError ||
477
+ e instanceof NoPeersError
478
+ )
479
+ return;
480
+ console.warn("Remote replicators not ready", e);
481
+ }
482
+ })
483
+ );
484
+ }*/
485
+ markWaited();
486
+ }
487
+
488
+ return drainRoundRobin(iterators, n);
489
+ } catch (e) {
490
+ if (!(e instanceof ClosedError)) throw e;
491
+ return false;
492
+ } finally {
493
+ setIsLoading(false);
494
+ }
495
+ };
496
+
497
+ /* ────────────── live-merge listeners ────────────── */
498
+ useEffect(() => {
499
+ if (!options.updates) {
500
+ return;
501
+ }
502
+
503
+ /* const listeners = iteratorRefs.current.map(({ db, id: itId }) => {
504
+ const mergeFn =
505
+ typeof options.onChange?.merge === "function"
506
+ ? options.onChange.merge
507
+ : (c: DocumentsChange<T, I>) => c;
508
+
509
+ const handler = async (e: CustomEvent<DocumentsChange<T, I>>) => {
510
+ log("Merge change", e.detail, "it", itId);
511
+ const filtered = await mergeFn(e.detail);
512
+ if (
513
+ !filtered ||
514
+ (!filtered.added.length && !filtered.removed.length)
515
+ )
516
+ return;
517
+
518
+ let merged: Item[];
519
+ if (options.onChange?.update) {
520
+ merged = await options.onChange.update(
521
+ allRef.current,
522
+ filtered
523
+ );
524
+ } else {
525
+ merged = await db.index.updateResults(
526
+ allRef.current as WithContext<RT>[],
527
+ filtered,
528
+ options.query || {},
529
+ options.resolve ?? true
530
+ );
531
+ }
532
+ updateAll(options.reverse ? merged.reverse() : merged);
533
+ };
534
+ db.events.addEventListener("change", handler);
535
+ return { db, handler };
536
+ });
537
+
538
+ return () => {
539
+ listeners.forEach(({ db, handler }) =>
540
+ db.events.removeEventListener("change", handler)
541
+ );
542
+ }; */
543
+
544
+ // eslint-disable-next-line react-hooks/exhaustive-deps
545
+ }, [
546
+ iteratorRefs.current.map((r) => r.db.address).join("|"),
547
+ options.updates,
548
+ options.query,
549
+ options.resolve,
550
+ options.reverse,
551
+ ]);
552
+
553
+ /* ────────────── public API – unchanged from the caller's perspective ────────────── */
554
+ return {
555
+ items: all,
556
+ loadMore,
557
+ isLoading,
558
+ empty: () => emptyResultsRef.current,
559
+ id,
560
+ };
561
+ };