@taladb/react 0.9.2 → 0.9.4

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/dist/index.mjs CHANGED
@@ -1,12 +1,39 @@
1
1
  'use client';
2
2
 
3
3
  // src/context.tsx
4
- import { createContext, useContext, useEffect, useState } from "react";
4
+ import {
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState
11
+ } from "react";
5
12
  import { Fragment, jsx } from "react/jsx-runtime";
6
13
  var TalaDBContext = createContext(null);
14
+ var CollectionOptionsContext = createContext({
15
+ get: () => void 0
16
+ });
17
+ function useCollectionOptions() {
18
+ return useContext(CollectionOptionsContext);
19
+ }
20
+ function CollectionOptionsProvider({
21
+ collections,
22
+ children
23
+ }) {
24
+ const latest = useRef(collections);
25
+ latest.current = collections;
26
+ const resolver = useMemo(
27
+ () => ({
28
+ get: (name) => latest.current?.[name]
29
+ }),
30
+ []
31
+ );
32
+ return /* @__PURE__ */ jsx(CollectionOptionsContext.Provider, { value: resolver, children });
33
+ }
7
34
  function TalaDBProvider(props) {
8
35
  if ("db" in props && props.db) {
9
- return /* @__PURE__ */ jsx(TalaDBContext.Provider, { value: props.db, children: props.children });
36
+ return /* @__PURE__ */ jsx(TalaDBContext.Provider, { value: props.db, children: /* @__PURE__ */ jsx(CollectionOptionsProvider, { collections: props.collections, children: props.children }) });
10
37
  }
11
38
  return /* @__PURE__ */ jsx(NamedProvider, { ...props });
12
39
  }
@@ -14,6 +41,7 @@ function NamedProvider({
14
41
  name,
15
42
  options,
16
43
  fallback = null,
44
+ collections,
17
45
  children
18
46
  }) {
19
47
  const [db, setDb] = useState(null);
@@ -41,7 +69,7 @@ function NamedProvider({
41
69
  }, [name, optionsKey]);
42
70
  if (error !== null) throw error;
43
71
  if (db === null) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
44
- return /* @__PURE__ */ jsx(TalaDBContext.Provider, { value: db, children });
72
+ return /* @__PURE__ */ jsx(TalaDBContext.Provider, { value: db, children: /* @__PURE__ */ jsx(CollectionOptionsProvider, { collections, children }) });
45
73
  }
46
74
  function useTalaDB() {
47
75
  const db = useContext(TalaDBContext);
@@ -52,16 +80,22 @@ function useTalaDB() {
52
80
  }
53
81
 
54
82
  // src/useCollection.ts
55
- import { useMemo } from "react";
56
- function useCollection(name) {
83
+ import { useMemo as useMemo2, useRef as useRef2 } from "react";
84
+ function useCollection(name, options) {
57
85
  const db = useTalaDB();
58
- return useMemo(() => db.collection(name), [db, name]);
86
+ const registry = useCollectionOptions();
87
+ const explicit = useRef2(options);
88
+ explicit.current = options;
89
+ return useMemo2(
90
+ () => db.collection(name, explicit.current ?? registry.get(name)),
91
+ [db, name, registry]
92
+ );
59
93
  }
60
94
 
61
95
  // src/useFind.ts
62
- import { useCallback, useRef, useSyncExternalStore } from "react";
96
+ import { useCallback, useRef as useRef3, useSyncExternalStore } from "react";
63
97
  function useFind(collection, filter) {
64
- const snapshotRef = useRef({ data: [], loading: true, error: null });
98
+ const snapshotRef = useRef3({ data: [], loading: true, error: null });
65
99
  const filterKey = JSON.stringify(filter ?? null);
66
100
  const subscribe = useCallback(
67
101
  (notify) => {
@@ -83,9 +117,9 @@ function useFind(collection, filter) {
83
117
  }
84
118
 
85
119
  // src/useFindOne.ts
86
- import { useCallback as useCallback2, useRef as useRef2, useSyncExternalStore as useSyncExternalStore2 } from "react";
120
+ import { useCallback as useCallback2, useRef as useRef4, useSyncExternalStore as useSyncExternalStore2 } from "react";
87
121
  function useFindOne(collection, filter) {
88
- const snapshotRef = useRef2({ data: null, loading: true, error: null });
122
+ const snapshotRef = useRef4({ data: null, loading: true, error: null });
89
123
  const filterKey = JSON.stringify(filter);
90
124
  const subscribe = useCallback2(
91
125
  (notify) => {
@@ -105,13 +139,40 @@ function useFindOne(collection, filter) {
105
139
  return useSyncExternalStore2(subscribe, getSnapshot, getSnapshot);
106
140
  }
107
141
 
142
+ // src/useAggregate.ts
143
+ import { useCallback as useCallback3, useRef as useRef5, useSyncExternalStore as useSyncExternalStore3 } from "react";
144
+ function useAggregate(collection, pipeline) {
145
+ const snapshotRef = useRef5({ data: [], loading: true, error: null });
146
+ const pipelineKey = JSON.stringify(pipeline);
147
+ const subscribe = useCallback3(
148
+ (notify) => {
149
+ snapshotRef.current = { data: snapshotRef.current.data, loading: true, error: null };
150
+ return collection.subscribeAggregate(
151
+ pipeline,
152
+ (docs) => {
153
+ snapshotRef.current = { data: docs, loading: false, error: null };
154
+ notify();
155
+ },
156
+ (error) => {
157
+ snapshotRef.current = { ...snapshotRef.current, loading: false, error };
158
+ notify();
159
+ }
160
+ );
161
+ },
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ [collection, pipelineKey]
164
+ );
165
+ const getSnapshot = useCallback3(() => snapshotRef.current, []);
166
+ return useSyncExternalStore3(subscribe, getSnapshot, getSnapshot);
167
+ }
168
+
108
169
  // src/replication/config.tsx
109
170
  import {
110
- createContext as createContext2,
111
- useContext as useContext2,
112
- useEffect as useEffect2,
113
- useMemo as useMemo2,
114
- useRef as useRef3
171
+ createContext as createContext3,
172
+ useContext as useContext3,
173
+ useEffect as useEffect3,
174
+ useMemo as useMemo4,
175
+ useRef as useRef7
115
176
  } from "react";
116
177
 
117
178
  // src/replication/engine.ts
@@ -165,20 +226,150 @@ async function replicateWithRetry(db, config, collection, direction) {
165
226
  throw lastError;
166
227
  }
167
228
 
168
- // src/replication/config.tsx
169
- import { jsx as jsx2, jsxs } from "react/jsx-runtime";
229
+ // src/replication/provider.tsx
230
+ import {
231
+ createContext as createContext2,
232
+ useContext as useContext2,
233
+ useEffect as useEffect2,
234
+ useMemo as useMemo3,
235
+ useRef as useRef6,
236
+ useState as useState2
237
+ } from "react";
238
+ import {
239
+ ReplicationCoordinator,
240
+ createRestSource
241
+ } from "taladb";
242
+ import { jsx as jsx2 } from "react/jsx-runtime";
170
243
  var ReplicationContext = createContext2(null);
171
- function ReplicationProvider({ children, ...config }) {
172
- const key = `${config.endpoint}|${config.pollMs ?? ""}|${JSON.stringify(config.paths ?? null)}|${JSON.stringify(config.prefetch ?? null)}|${config.prefetchMode ?? ""}|${config.prefetchConcurrency ?? ""}`;
173
- const value = useMemo2(
174
- () => config,
244
+ function whenIdle(fn) {
245
+ const ric = globalThis.requestIdleCallback;
246
+ if (typeof ric === "function") {
247
+ const handle = ric(fn, { timeout: 2e3 });
248
+ return () => {
249
+ const cic = globalThis.cancelIdleCallback;
250
+ cic?.(handle);
251
+ };
252
+ }
253
+ const t = setTimeout(fn, 0);
254
+ return () => clearTimeout(t);
255
+ }
256
+ var yieldToUi = () => new Promise((resolve) => setTimeout(resolve, 0));
257
+ function ReplicationScopes({ replicate: replicate2, children }) {
258
+ const db = useTalaDB();
259
+ const collectionOptions = useCollectionOptions();
260
+ const [coverage, setCoverage] = useState2({});
261
+ const registryKey = JSON.stringify(
262
+ Object.fromEntries(
263
+ Object.entries(replicate2).map(([name, s]) => [
264
+ name,
265
+ {
266
+ endpoint: s.endpoint,
267
+ origin: s.origin,
268
+ scope: s.scope,
269
+ projectionVersion: s.projectionVersion,
270
+ schemaVersion: s.schemaVersion,
271
+ key: s.key,
272
+ hydrate: s.hydrate,
273
+ pageSize: s.pageSize,
274
+ refreshMs: s.refreshMs,
275
+ bridge: s.bridge,
276
+ source: s.source ? {
277
+ origin: s.source.origin,
278
+ collection: s.source.collection,
279
+ scope: s.source.scope,
280
+ projectionVersion: s.source.projectionVersion,
281
+ schemaVersion: s.source.schemaVersion,
282
+ configVersion: s.source.configVersion
283
+ } : null
284
+ }
285
+ ])
286
+ )
287
+ );
288
+ const latest = useRef6(replicate2);
289
+ latest.current = replicate2;
290
+ const coordinators = useMemo3(() => {
291
+ const map = /* @__PURE__ */ new Map();
292
+ for (const [collection, scope] of Object.entries(latest.current)) {
293
+ const source = scope.source ?? createRestSource({ ...scope, collection });
294
+ map.set(
295
+ collection,
296
+ new ReplicationCoordinator(db, source, {
297
+ pageSize: scope.pageSize,
298
+ yieldFn: yieldToUi,
299
+ onProgress: (state) => setCoverage((prev) => ({ ...prev, [collection]: state })),
300
+ collectionOptions: collectionOptions.get(collection)
301
+ })
302
+ );
303
+ }
304
+ return map;
305
+ }, [db, registryKey, collectionOptions]);
306
+ useEffect2(() => {
307
+ let cancelled = false;
308
+ void (async () => {
309
+ const seeded = {};
310
+ for (const [collection, coord] of coordinators) {
311
+ seeded[collection] = await coord.getCoverage();
312
+ }
313
+ if (!cancelled) setCoverage(seeded);
314
+ })();
315
+ return () => {
316
+ cancelled = true;
317
+ };
318
+ }, [coordinators]);
319
+ useEffect2(() => {
320
+ const cancels = [];
321
+ for (const [collection, coord] of coordinators) {
322
+ const mode = latest.current[collection]?.hydrate ?? "idle";
323
+ if (mode === "manual") continue;
324
+ const start = () => {
325
+ void coord.hydrate().catch(() => {
326
+ });
327
+ };
328
+ if (mode === "eager") start();
329
+ else cancels.push(whenIdle(start));
330
+ }
331
+ return () => cancels.forEach((c) => c());
332
+ }, [coordinators]);
333
+ useEffect2(() => {
334
+ const timers = [];
335
+ for (const [collection, coord] of coordinators) {
336
+ const ms = latest.current[collection]?.refreshMs ?? 0;
337
+ if (ms > 0) {
338
+ timers.push(setInterval(() => void coord.refresh().catch(() => {
339
+ }), ms));
340
+ }
341
+ }
342
+ return () => timers.forEach(clearInterval);
343
+ }, [coordinators]);
344
+ const value = useMemo3(
345
+ () => ({ coordinators, scopes: latest.current, coverage }),
346
+ [coordinators, coverage]
347
+ );
348
+ return /* @__PURE__ */ jsx2(ReplicationContext.Provider, { value, children });
349
+ }
350
+ function useReplication() {
351
+ return useContext2(ReplicationContext);
352
+ }
353
+
354
+ // src/replication/config.tsx
355
+ import { jsx as jsx3, jsxs } from "react/jsx-runtime";
356
+ var ReplicationContext2 = createContext3(null);
357
+ function ReplicationProvider({
358
+ children,
359
+ replicate: replicate2,
360
+ ...config
361
+ }) {
362
+ const key = `${config.endpoint ?? ""}|${config.pollMs ?? ""}|${JSON.stringify(config.paths ?? null)}|${JSON.stringify(config.prefetch ?? null)}|${config.prefetchMode ?? ""}|${config.prefetchConcurrency ?? ""}`;
363
+ const value = useMemo4(
364
+ () => config.endpoint ? config : null,
175
365
  // eslint-disable-next-line react-hooks/exhaustive-deps
176
366
  [key]
177
367
  );
178
- return /* @__PURE__ */ jsxs(ReplicationContext.Provider, { value, children: [
179
- value.prefetch && value.prefetch.length > 0 ? /* @__PURE__ */ jsx2(PrefetchRunner, {}) : null,
368
+ const inner = /* @__PURE__ */ jsxs(ReplicationContext2.Provider, { value, children: [
369
+ value?.prefetch && value.prefetch.length > 0 ? /* @__PURE__ */ jsx3(PrefetchRunner, {}) : null,
180
370
  children
181
371
  ] });
372
+ return replicate2 ? /* @__PURE__ */ jsx3(ReplicationScopes, { replicate: replicate2, children: inner }) : inner;
182
373
  }
183
374
  function resolveReplicationConfig(base, overrides) {
184
375
  const endpoint = overrides?.endpoint ?? base?.endpoint;
@@ -195,10 +386,10 @@ function resolveReplicationConfig(base, overrides) {
195
386
  };
196
387
  }
197
388
  function useReplicationBase() {
198
- return useContext2(ReplicationContext);
389
+ return useContext3(ReplicationContext2);
199
390
  }
200
391
  function useReplicationConfig(overrides) {
201
- return resolveReplicationConfig(useContext2(ReplicationContext), overrides);
392
+ return resolveReplicationConfig(useContext3(ReplicationContext2), overrides);
202
393
  }
203
394
  var CURSOR_COLLECTION = "__taladb_sync";
204
395
  function normalizePrefetch(entries) {
@@ -228,10 +419,10 @@ function PrefetchRunner() {
228
419
  const slices = normalizePrefetch(base?.prefetch);
229
420
  const mode = base?.prefetchMode ?? "once";
230
421
  const concurrency = Math.max(1, base?.prefetchConcurrency ?? 2);
231
- const baseRef = useRef3(base);
422
+ const baseRef = useRef7(base);
232
423
  baseRef.current = base;
233
424
  const sig = JSON.stringify({ slices, mode, concurrency, endpoint: base?.endpoint ?? null });
234
- useEffect2(() => {
425
+ useEffect3(() => {
235
426
  if (slices.length === 0) return void 0;
236
427
  let cancelled = false;
237
428
  const cancelSchedule = schedule(() => {
@@ -266,167 +457,251 @@ function PrefetchRunner() {
266
457
  return null;
267
458
  }
268
459
 
460
+ // src/useCoverage.ts
461
+ import { isAuthoritative, progress as progressOf, rowsApplied } from "taladb";
462
+ function useCoverage(collection) {
463
+ const replication = useReplication();
464
+ const state = replication?.coverage[collection] ?? { status: "empty" };
465
+ return {
466
+ status: state.status,
467
+ ready: isAuthoritative(state),
468
+ rows: rowsApplied(state),
469
+ total: "total" in state ? state.total : void 0,
470
+ progress: progressOf(state),
471
+ reason: state.status === "error" ? state.error : state.status === "best-effort" || state.status === "stale" ? state.reason : void 0
472
+ };
473
+ }
474
+ var useHydrationProgress = useCoverage;
475
+
269
476
  // src/useQuery.ts
270
- import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef4, useState as useState2 } from "react";
477
+ import { useCallback as useCallback4, useEffect as useEffect4, useMemo as useMemo5, useRef as useRef8, useState as useState3 } from "react";
271
478
  function useQuery(options) {
272
- const { collection, filter, source = "local-first" } = options;
273
- const networked = source !== "local-only";
274
- const db = useTalaDB();
479
+ const { collection, filter, sort, page, limit, skip, enabled = true } = options;
275
480
  const col = useCollection(collection);
276
- const read = useFind(col, filter);
277
- const { config, pollMs } = useReplicationConfig({
278
- endpoint: options.endpoint,
279
- getAuth: options.getAuth,
280
- fetch: options.fetch,
281
- paths: options.paths,
282
- pollMs: options.pollMs
283
- });
284
- const configRef = useRef4(config);
285
- configRef.current = config;
286
- const [syncing, setSyncing] = useState2(false);
287
- const [syncError, setSyncError] = useState2(null);
288
- const [firstSyncDone, setFirstSyncDone] = useState2(false);
289
- const endpoint = config?.endpoint;
290
- const refetch = useCallback3(async () => {
291
- const cfg = configRef.current;
292
- if (!networked || !cfg) return;
481
+ const db = useTalaDB();
482
+ const coverage = useCoverage(collection);
483
+ const replication = useReplication();
484
+ const coord = replication?.coordinators.get(collection);
485
+ const legacyNetworked = !coord && options.source !== "local-only";
486
+ const { config: legacyConfig, pollMs } = useReplicationConfig(options);
487
+ const legacyConfigRef = useRef8(legacyConfig);
488
+ legacyConfigRef.current = legacyConfig;
489
+ const [syncing, setSyncing] = useState3(false);
490
+ const [syncError, setSyncError] = useState3(null);
491
+ const [firstSyncDone, setFirstSyncDone] = useState3(false);
492
+ const legacyRefetch = useCallback4(async () => {
493
+ const cfg = legacyConfigRef.current;
494
+ if (!legacyNetworked || !cfg) return;
293
495
  setSyncing(true);
294
496
  setSyncError(null);
295
497
  try {
296
498
  await replicate(db, cfg, collection, "pull");
297
- } catch (e) {
298
- setSyncError(e);
499
+ } catch (error) {
500
+ setSyncError(error);
299
501
  } finally {
300
502
  setSyncing(false);
301
503
  setFirstSyncDone(true);
302
504
  }
303
- }, [db, collection, networked, endpoint]);
304
- useEffect3(() => {
305
- if (!networked) return;
306
- void refetch();
505
+ }, [db, collection, legacyNetworked, legacyConfig?.endpoint]);
506
+ useEffect4(() => {
507
+ if (!enabled || !legacyNetworked || !legacyConfig) return;
508
+ void legacyRefetch();
307
509
  if (pollMs > 0) {
308
- const id = setInterval(() => void refetch(), pollMs);
309
- return () => clearInterval(id);
510
+ const timer = setInterval(() => void legacyRefetch(), pollMs);
511
+ return () => clearInterval(timer);
310
512
  }
311
513
  return void 0;
312
- }, [refetch, networked, pollMs]);
313
- if (networked && !config) {
514
+ }, [enabled, legacyNetworked, legacyConfig?.endpoint, pollMs, legacyRefetch]);
515
+ const offset = page !== void 0 && limit !== void 0 ? (page - 1) * limit : skip ?? 0;
516
+ const filterKey = JSON.stringify(filter ?? null);
517
+ const sortKey = JSON.stringify(sort ?? null);
518
+ const [bridgeIds, setBridgeIds] = useState3([]);
519
+ const [fetchError, setFetchError] = useState3(null);
520
+ const scopeValue = coord?.replicaScope;
521
+ const bridgeIdKey = (bridgeIds ?? []).join("|");
522
+ const pipeline = useMemo5(() => {
523
+ const stages = [];
524
+ const scoped = scopeValue ? { _replica_scope: scopeValue } : void 0;
525
+ const bridgeOnly = !coverage.ready ? { _id: { $in: bridgeIds ?? [] } } : void 0;
526
+ const matches = [scoped, bridgeOnly, filter].filter(Boolean);
527
+ if (matches.length === 1) stages.push({ $match: matches[0] });
528
+ else if (matches.length > 1) stages.push({ $match: { $and: matches } });
529
+ if (sort) stages.push({ $sort: sort });
530
+ if (coverage.ready && offset > 0) stages.push({ $skip: offset });
531
+ if (limit !== void 0) stages.push({ $limit: limit });
532
+ return stages;
533
+ }, [filterKey, sortKey, offset, limit, coverage.ready, scopeValue, bridgeIdKey]);
534
+ const read = useAggregate(col, enabled ? pipeline : [{ $limit: 0 }]);
535
+ const [fetching, setFetching] = useState3(false);
536
+ const bridgeKey = `${collection}|${filterKey}|${sortKey}|${offset}|${limit}`;
537
+ const canBridge = replication?.scopes[collection]?.bridge !== false;
538
+ useEffect4(() => {
539
+ if (!enabled || coverage.ready || !canBridge) return;
540
+ if (!coord) return;
541
+ let cancelled = false;
542
+ setFetching(true);
543
+ setFetchError(null);
544
+ setBridgeIds([]);
545
+ void coord.bridge({
546
+ filter,
547
+ sort,
548
+ page,
549
+ limit
550
+ }).then((result) => setBridgeIds(result.ids ?? [])).catch((error) => {
551
+ if (!cancelled) setFetchError(error);
552
+ }).finally(() => {
553
+ if (!cancelled) setFetching(false);
554
+ });
555
+ return () => {
556
+ cancelled = true;
557
+ };
558
+ }, [bridgeKey, coverage.ready, canBridge, enabled, coord]);
559
+ const refetch = async () => {
560
+ if (coord) await coord.refresh();
561
+ else await legacyRefetch();
562
+ };
563
+ if (enabled && legacyNetworked && !legacyConfig) {
314
564
  throw new Error(
315
- `useQuery({ collection: '${collection}' }) needs an endpoint for source '${source}'. Wrap the tree in <ReplicationProvider endpoint="\u2026">, pass { endpoint }, or use source: "local-only".`
565
+ `useQuery({ collection: '${collection}' }) needs either a coverage-first replicate scope or a legacy sync endpoint. Use source: 'local-only' for a purely local query.`
316
566
  );
317
567
  }
318
- const loading = source === "remote-first" ? read.loading || !firstSyncDone : read.loading;
319
- return { data: read.data, loading, error: read.error, syncing, syncError, refetch };
568
+ return {
569
+ data: read.data,
570
+ total: coverage.total,
571
+ loading: options.source === "remote-first" && legacyNetworked ? read.loading || !firstSyncDone : read.loading,
572
+ error: read.error ?? fetchError,
573
+ fetchError,
574
+ coverage,
575
+ fetching,
576
+ syncing,
577
+ syncError,
578
+ refetch
579
+ };
320
580
  }
321
581
 
322
582
  // src/useQueries.ts
323
- import { useEffect as useEffect4, useRef as useRef5, useState as useState3 } from "react";
324
- var NOOP_REFETCH = async () => {
325
- };
326
- function emptyResult() {
327
- return { data: [], loading: true, error: null, syncing: false, syncError: null, refetch: NOOP_REFETCH };
328
- }
583
+ import { useEffect as useEffect5, useMemo as useMemo6, useRef as useRef9, useState as useState4 } from "react";
329
584
  function useQueries(queries) {
330
585
  const db = useTalaDB();
331
- const base = useReplicationBase();
332
- for (const q of queries) {
333
- const networked = (q.source ?? "local-first") !== "local-only";
334
- if (networked && !(q.endpoint ?? base?.endpoint)) {
335
- throw new Error(
336
- `useQueries: the query for '${q.collection}' needs an endpoint for source '${q.source ?? "local-first"}'. Provide <ReplicationProvider endpoint="\u2026">, pass { endpoint }, or use source: "local-only".`
337
- );
338
- }
339
- }
340
- const sig = JSON.stringify(
586
+ const registry = useCollectionOptions();
587
+ const replication = useReplication();
588
+ const [results, setResults] = useState4(() => queries.map(() => ({ data: [], loading: true, error: null })));
589
+ const [bridgeIds, setBridgeIds] = useState4({});
590
+ const [fetchErrors, setFetchErrors] = useState4({});
591
+ const signature = JSON.stringify(
341
592
  queries.map((q) => ({
342
593
  collection: q.collection,
343
594
  filter: q.filter ?? null,
344
- source: q.source ?? "local-first",
345
- endpoint: q.endpoint ?? null,
346
- pollMs: q.pollMs ?? null
595
+ sort: q.sort ?? null,
596
+ page: q.page ?? null,
597
+ limit: q.limit ?? null,
598
+ skip: q.skip ?? null,
599
+ enabled: q.enabled ?? true
347
600
  }))
348
601
  );
349
- const queriesRef = useRef5(queries);
350
- queriesRef.current = queries;
351
- const baseRef = useRef5(base);
352
- baseRef.current = base;
353
- const [results, setResults] = useState3(
354
- () => queries.map(() => emptyResult())
602
+ const latest = useRef9(queries);
603
+ latest.current = queries;
604
+ const bridgeManifestKey = JSON.stringify(bridgeIds);
605
+ const replicationReadKey = JSON.stringify(
606
+ queries.map((q) => ({
607
+ scope: replication?.coordinators.get(q.collection)?.replicaScope ?? null,
608
+ ready: replication?.coverage[q.collection]?.status === "complete"
609
+ }))
355
610
  );
356
- useEffect4(() => {
357
- const qs = queriesRef.current;
358
- const b = baseRef.current;
359
- let cancelled = false;
360
- const setAt = (i, fn) => {
361
- setResults((prev) => {
362
- if (i >= prev.length) return prev;
363
- const copy = prev.slice();
364
- copy[i] = fn(copy[i]);
365
- return copy;
366
- });
367
- };
368
- const resolved = qs.map((q, i) => {
369
- const { config } = resolveReplicationConfig(b, {
370
- endpoint: q.endpoint,
371
- getAuth: q.getAuth,
372
- fetch: q.fetch,
373
- paths: q.paths,
374
- pollMs: q.pollMs
375
- });
376
- const networked = (q.source ?? "local-first") !== "local-only";
377
- const pollMs = q.pollMs ?? b?.pollMs ?? 0;
378
- const refetch = async () => {
379
- if (!networked || !config) return;
380
- setAt(i, (r) => ({ ...r, syncing: true, syncError: null }));
381
- try {
382
- await replicate(db, config, q.collection, "pull");
383
- } catch (e) {
384
- if (!cancelled) setAt(i, (r) => ({ ...r, syncError: e }));
385
- } finally {
386
- if (!cancelled) setAt(i, (r) => ({ ...r, syncing: false }));
387
- }
611
+ useEffect5(() => {
612
+ const current = latest.current;
613
+ setResults(current.map(() => ({ data: [], loading: true, error: null })));
614
+ const unsubs = current.map((q, i) => {
615
+ if (q.enabled === false) return () => {
388
616
  };
389
- return { config, networked, pollMs, refetch };
617
+ const col = db.collection(q.collection, registry.get(q.collection));
618
+ const offset = q.page !== void 0 && q.limit !== void 0 ? (q.page - 1) * q.limit : q.skip ?? 0;
619
+ const pipeline = [];
620
+ const coord = replication?.coordinators.get(q.collection);
621
+ const covered = replication?.coverage[q.collection]?.status === "complete";
622
+ const matches = [
623
+ coord ? { _replica_scope: coord.replicaScope } : void 0,
624
+ !covered ? { _id: { $in: bridgeIds[i] ?? [] } } : void 0,
625
+ q.filter
626
+ ].filter(Boolean);
627
+ if (matches.length === 1) pipeline.push({ $match: matches[0] });
628
+ else if (matches.length > 1) pipeline.push({ $match: { $and: matches } });
629
+ if (q.sort) pipeline.push({ $sort: q.sort });
630
+ if (covered && offset > 0) pipeline.push({ $skip: offset });
631
+ if (q.limit !== void 0) pipeline.push({ $limit: q.limit });
632
+ return col.subscribeAggregate(
633
+ pipeline,
634
+ (docs) => setResults((prev) => {
635
+ const next = [...prev];
636
+ next[i] = { data: docs, loading: false, error: null };
637
+ return next;
638
+ }),
639
+ (error) => setResults((prev) => {
640
+ const next = [...prev];
641
+ next[i] = { ...next[i], loading: false, error };
642
+ return next;
643
+ })
644
+ );
390
645
  });
391
- setResults(
392
- qs.map((_q, i) => ({
393
- data: [],
394
- loading: true,
395
- error: null,
646
+ return () => unsubs.forEach((u) => u());
647
+ }, [db, registry, signature, replicationReadKey, bridgeManifestKey]);
648
+ useEffect5(() => {
649
+ for (const [i, q] of latest.current.entries()) {
650
+ if (q.enabled === false) continue;
651
+ const coord = replication?.coordinators.get(q.collection);
652
+ if (!coord || replication?.scopes[q.collection]?.bridge === false) continue;
653
+ void coord.getCoverage().then((state) => {
654
+ if (state.status === "complete") return;
655
+ return coord.bridge({
656
+ filter: q.filter,
657
+ sort: q.sort,
658
+ page: q.page,
659
+ limit: q.limit
660
+ }).then((result) => {
661
+ setBridgeIds((prev) => ({ ...prev, [i]: result.ids }));
662
+ setFetchErrors((prev) => {
663
+ const next = { ...prev };
664
+ delete next[i];
665
+ return next;
666
+ });
667
+ }).catch((error) => setFetchErrors((prev) => ({ ...prev, [i]: error })));
668
+ });
669
+ }
670
+ }, [replication, signature]);
671
+ return useMemo6(
672
+ () => latest.current.map((q, i) => {
673
+ const state = replication?.coverage[q.collection] ?? { status: "empty" };
674
+ const coverage = {
675
+ status: state.status,
676
+ // Only `complete` licenses a local-only read — see `useCoverage`.
677
+ ready: state.status === "complete",
678
+ rows: "rowsApplied" in state ? state.rowsApplied ?? 0 : 0,
679
+ total: "total" in state ? state.total : void 0,
680
+ progress: state.status === "complete" ? 1 : void 0,
681
+ reason: state.status === "error" ? state.error : state.status === "best-effort" || state.status === "stale" ? state.reason : void 0
682
+ };
683
+ return {
684
+ data: results[i]?.data ?? [],
685
+ total: coverage.total,
686
+ loading: results[i]?.loading ?? true,
687
+ error: results[i]?.error ?? fetchErrors[i] ?? null,
688
+ fetchError: fetchErrors[i] ?? null,
689
+ coverage,
690
+ fetching: false,
396
691
  syncing: false,
397
692
  syncError: null,
398
- refetch: resolved[i].refetch
399
- }))
400
- );
401
- const unsubs = qs.map((q, i) => {
402
- const col = db.collection(q.collection);
403
- return col.subscribe(
404
- q.filter ?? {},
405
- (docs) => {
406
- if (!cancelled) setAt(i, (r) => ({ ...r, data: docs, loading: false, error: null }));
407
- },
408
- (error) => {
409
- if (!cancelled) setAt(i, (r) => ({ ...r, loading: false, error }));
693
+ refetch: async () => {
694
+ await replication?.coordinators.get(q.collection)?.refresh();
410
695
  }
411
- );
412
- });
413
- const intervals = [];
414
- resolved.forEach((res) => {
415
- if (!res.networked || !res.config) return;
416
- void res.refetch();
417
- if (res.pollMs > 0) intervals.push(setInterval(() => void res.refetch(), res.pollMs));
418
- });
419
- return () => {
420
- cancelled = true;
421
- unsubs.forEach((u) => u());
422
- intervals.forEach((id) => clearInterval(id));
423
- };
424
- }, [db, sig]);
425
- return queries.map((_q, i) => results[i] ?? emptyResult());
696
+ };
697
+ }),
698
+ // eslint-disable-next-line react-hooks/exhaustive-deps
699
+ [results, signature, replication]
700
+ );
426
701
  }
427
702
 
428
703
  // src/useMutation.ts
429
- import { useCallback as useCallback4, useEffect as useEffect5, useRef as useRef6, useState as useState4 } from "react";
704
+ import { useCallback as useCallback5, useEffect as useEffect6, useRef as useRef10, useState as useState5 } from "react";
430
705
  function useMutation(options) {
431
706
  const { collection, direction = "push", drainOnMount = true } = options;
432
707
  const db = useTalaDB();
@@ -437,12 +712,12 @@ function useMutation(options) {
437
712
  fetch: options.fetch,
438
713
  paths: options.paths
439
714
  });
440
- const configRef = useRef6(config);
715
+ const configRef = useRef10(config);
441
716
  configRef.current = config;
442
- const [pending, setPending] = useState4(false);
443
- const [error, setError] = useState4(null);
717
+ const [pending, setPending] = useState5(false);
718
+ const [error, setError] = useState5(null);
444
719
  const endpoint = config?.endpoint;
445
- const applyLocal = useCallback4(
720
+ const applyLocal = useCallback5(
446
721
  async (op) => {
447
722
  switch (op.type) {
448
723
  case "insert":
@@ -458,12 +733,12 @@ function useMutation(options) {
458
733
  },
459
734
  [col]
460
735
  );
461
- const drain = useCallback4(async () => {
736
+ const drain = useCallback5(async () => {
462
737
  const cfg = configRef.current;
463
738
  if (!cfg) return;
464
739
  await replicateWithRetry(db, cfg, collection, direction);
465
740
  }, [db, collection, direction, endpoint]);
466
- const mutateAsync = useCallback4(
741
+ const mutateAsync = useCallback5(
467
742
  async (op) => {
468
743
  setPending(true);
469
744
  setError(null);
@@ -479,14 +754,14 @@ function useMutation(options) {
479
754
  },
480
755
  [applyLocal, drain]
481
756
  );
482
- const mutate = useCallback4(
757
+ const mutate = useCallback5(
483
758
  (op) => {
484
759
  void mutateAsync(op).catch(() => {
485
760
  });
486
761
  },
487
762
  [mutateAsync]
488
763
  );
489
- useEffect5(() => {
764
+ useEffect6(() => {
490
765
  if (!drainOnMount || !configRef.current) return;
491
766
  void drain().catch(() => {
492
767
  });
@@ -501,9 +776,13 @@ function useMutation(options) {
501
776
  export {
502
777
  ReplicationProvider,
503
778
  TalaDBProvider,
779
+ useAggregate,
504
780
  useCollection,
781
+ useCollectionOptions,
782
+ useCoverage,
505
783
  useFind,
506
784
  useFindOne,
785
+ useHydrationProgress,
507
786
  useMutation,
508
787
  useQueries,
509
788
  useQuery,