@syncular/react 0.4.1 → 0.5.1

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.
@@ -1,87 +1,34 @@
1
- /**
2
- * `useSyncStatus` — the client's operational status: outbox depth,
3
- * upgrading (§7.4.5), leaseState (§7.3.5), schemaFloor (§1.6), and
4
- * syncNeeded (§8.4). Status is derived by polling the normalized accessors
5
- * after every apply batch (invalidation is the natural "something changed"
6
- * signal) plus an initial read; `refresh()` re-reads on demand.
7
- *
8
- * `online` is not a protocol concept the client exposes directly (§1.3
9
- * transport-owned), so it is reported as `undefined` unless a future
10
- * accessor lands — the hook surfaces what the core actually knows, never a
11
- * guessed value.
12
- */
13
-
14
1
  import type { LeaseState, SchemaFloor } from '@syncular/client';
15
- import { useCallback, useEffect, useRef, useState } from 'react';
16
- import { useSyncClient } from './use-client';
2
+ import { useCallback, useSyncExternalStore } from 'react';
3
+ import { useReactiveStore } from './use-client';
17
4
 
18
5
  export interface SyncStatus {
19
- /** Pending outbox commits (unpushed local writes). */
20
6
  readonly outbox: number;
21
- /** §7.4.5: a schema-bump reset + first re-bootstrap is in flight. */
22
7
  readonly upgrading: boolean;
23
- /** §7.3.5: opaque auth-lease state, or undefined. */
24
8
  readonly leaseState: LeaseState | undefined;
25
- /** §1.6: server-declared schema floor (syncing stopped), or undefined. */
26
9
  readonly schemaFloor: SchemaFloor | undefined;
27
- /** §8.4: the host loop should run a pull soon. */
28
10
  readonly syncNeeded: boolean;
29
- /** True until the first status read resolves. */
30
11
  readonly isLoading: boolean;
12
+ readonly error: Error | undefined;
31
13
  readonly refresh: () => void;
32
14
  }
33
15
 
34
- const INITIAL: Omit<SyncStatus, 'refresh'> = {
35
- outbox: 0,
36
- upgrading: false,
37
- leaseState: undefined,
38
- schemaFloor: undefined,
39
- syncNeeded: false,
40
- isLoading: true,
41
- };
42
-
43
16
  export function useSyncStatus(): SyncStatus {
44
- const client = useSyncClient();
45
- const [state, setState] = useState(INITIAL);
46
- // `refresh` re-reads through a ref set by the effect (identity-stable, no
47
- // tick state — biome-clean deps).
48
- const readRef = useRef<() => void>(() => {});
49
- const refresh = useCallback(() => readRef.current(), []);
50
-
51
- useEffect(() => {
52
- let cancelled = false;
53
- const read = () => {
54
- Promise.all([
55
- client.pendingCommits(),
56
- client.upgrading(),
57
- client.leaseState(),
58
- client.schemaFloor(),
59
- client.syncNeeded(),
60
- ])
61
- .then(([pending, upgrading, leaseState, schemaFloor, syncNeeded]) => {
62
- if (cancelled) return;
63
- setState({
64
- outbox: pending.length,
65
- upgrading,
66
- leaseState,
67
- schemaFloor,
68
- syncNeeded,
69
- isLoading: false,
70
- });
71
- })
72
- .catch(() => {
73
- if (!cancelled) setState((s) => ({ ...s, isLoading: false }));
74
- });
75
- };
76
- readRef.current = read;
77
- read();
78
- // Re-read on every apply batch — the "state changed" edge.
79
- const unsubscribe = client.onInvalidate(read);
80
- return () => {
81
- cancelled = true;
82
- unsubscribe();
83
- };
84
- }, [client]);
85
-
86
- return { ...state, refresh };
17
+ const entry = useReactiveStore().status;
18
+ const snapshot = useSyncExternalStore(
19
+ entry.subscribe,
20
+ entry.getSnapshot,
21
+ entry.getSnapshot,
22
+ );
23
+ const refresh = useCallback(() => entry.refresh(), [entry]);
24
+ return {
25
+ outbox: snapshot.status?.outbox ?? 0,
26
+ upgrading: snapshot.status?.upgrading ?? false,
27
+ leaseState: snapshot.status?.leaseState,
28
+ schemaFloor: snapshot.status?.schemaFloor,
29
+ syncNeeded: snapshot.status?.syncNeeded ?? false,
30
+ isLoading: snapshot.isLoading,
31
+ error: snapshot.error,
32
+ refresh,
33
+ };
87
34
  }
package/src/use-window.ts CHANGED
@@ -1,129 +1,116 @@
1
- /**
2
- * `useWindow(base)` — the windowed-sync surface for a component
3
- * (SPEC.md §4.8 / DESIGN-eviction.md W1, I3). It manages the live window
4
- * units for a base and exposes the **completeness oracle**: which scope
5
- * values are held locally in full, so a consumer can render "this data may
6
- * be partial" honestly instead of silently serving a partial replica as
7
- * complete.
8
- *
9
- * - `setWindow(units)` swaps the live set (added units bootstrap via the
10
- * image lane; removed units are evicted, fused with unsubscription).
11
- * - `units` is the current windowed-in set, `pending` the subset whose
12
- * bootstrap has not yet landed (re-read on mount and whenever the base's
13
- * table is invalidated — a deferred eviction draining, a re-entry
14
- * bootstrapping, or a bootstrap completing all update the verdict).
15
- * - `isComplete(unit)` is the per-value verdict: registered AND
16
- * bootstrap-complete. A live query whose scope footprint includes a
17
- * non-`isComplete` unit is a **window miss or still loading** — widen,
18
- * wait, or show partial, never claim complete. Between `setWindow` and
19
- * the unit's bootstrap landing the verdict is `false` (the local replica
20
- * is empty or partial there — never a false "empty" render).
21
- */
22
1
  import {
2
+ canonicalValue,
23
3
  type WindowBase,
24
- type WindowState,
25
4
  windowComplete,
26
5
  } from '@syncular/client';
27
- import { useCallback, useEffect, useRef, useState } from 'react';
28
- import { FrameScheduler } from './query-churn';
29
- import { useSyncClient } from './use-client';
6
+ import {
7
+ useCallback,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ useSyncExternalStore,
13
+ } from 'react';
14
+ import { useReactiveStore } from './use-client';
30
15
 
31
16
  export interface UseWindowResult {
32
- /** The scope values currently windowed-in for this base. */
33
17
  readonly units: readonly string[];
34
- /** Registered units whose bootstrap has not yet completed (§4.8). */
35
18
  readonly pending: readonly string[];
36
- /** Set the live units (widen/shrink diff, §4.8). */
19
+ /** Update this component's claim; the store applies the union of all claims. */
37
20
  readonly setWindow: (units: readonly string[]) => Promise<void>;
38
- /** True iff `unit` is windowed-in AND bootstrapped (answerable, I3). */
39
21
  readonly isComplete: (unit: string) => boolean;
40
22
  }
41
23
 
42
- const EMPTY: WindowState = { units: [], pending: [] };
43
-
44
- export function useWindow(base: WindowBase): UseWindowResult {
45
- const client = useSyncClient();
46
- const [state, setState] = useState<WindowState>(EMPTY);
24
+ export interface UseRetainedWindowResult {
25
+ /** True until the retained working set has reached the core window. */
26
+ readonly isPending: boolean;
27
+ /** Registration failure, if the host rejects the window change. */
28
+ readonly error: Error | undefined;
29
+ }
47
30
 
48
- // A stable key so the effects re-run only when the base identity changes,
49
- // not on every render's fresh object. The latest `base` is read via a ref
50
- // inside the closures (the useRawSql pattern), so the dep list stays on
51
- // primitive keys.
52
- const baseKey = `${base.table} ${base.variable} ${JSON.stringify(
53
- base.fixedScopes ?? {},
54
- )} ${base.params ?? ''}`;
55
- const baseRef = useRef(base);
56
- baseRef.current = base;
31
+ /**
32
+ * Retain a known working set for the lifetime of this component. Retention
33
+ * composes with generated query coverage and other owners; cleanup releases
34
+ * only this hook's claim. Ordinary selected queries still claim themselves.
35
+ */
36
+ export function useRetainedWindow(
37
+ base: WindowBase,
38
+ units: readonly string[],
39
+ ): UseRetainedWindowResult {
40
+ const store = useReactiveStore();
41
+ const baseIdentity = canonicalValue(base);
42
+ const unitsIdentity = canonicalValue([...new Set(units)].sort());
43
+ // biome-ignore lint/correctness/useExhaustiveDependencies: canonical identities represent the complete values
44
+ const stableBase = useMemo(() => base, [baseIdentity]);
45
+ // biome-ignore lint/correctness/useExhaustiveDependencies: unitsIdentity represents the normalized unit set
46
+ const stableUnits = useMemo(
47
+ () => [...new Set(units)].sort(),
48
+ [unitsIdentity],
49
+ );
50
+ const [state, setState] = useState<UseRetainedWindowResult>({
51
+ isPending: true,
52
+ error: undefined,
53
+ });
57
54
 
58
- // `baseKey` re-keys the effect without being read in the body (biome cannot
59
- // see the ref indirection) — the dep list is pinned deliberately.
60
- // biome-ignore lint/correctness/useExhaustiveDependencies: baseKey re-keys the effect for a fresh base object
61
55
  useEffect(() => {
62
- let cancelled = false;
63
- const read = (): Promise<void> =>
64
- Promise.resolve(client.windowState(baseRef.current))
65
- .then((next) => {
66
- if (!cancelled) setState(next);
67
- })
68
- .catch(() => {
69
- /* transient — the next invalidation re-reads */
56
+ let active = true;
57
+ setState((current) =>
58
+ current.isPending && current.error === undefined
59
+ ? current
60
+ : { isPending: true, error: undefined },
61
+ );
62
+ const retention = store.retainWindow(stableBase, stableUnits);
63
+ void retention.ready.then(
64
+ () => {
65
+ if (active) setState({ isPending: false, error: undefined });
66
+ },
67
+ (caught: unknown) => {
68
+ if (!active) return;
69
+ setState({
70
+ isPending: false,
71
+ error: caught instanceof Error ? caught : new Error(String(caught)),
70
72
  });
71
- void read();
72
- // Re-read when the base's table changes locally: a deferred eviction
73
- // (E1) completing, a re-entry bootstrapping, or a bootstrap completing
74
- // all invalidate it. The re-read is frame-coalesced like the query
75
- // hooks' re-runs, then deferred ONE MORE boundary before issuing: every
76
- // query re-run for the same event has issued its read by then, so on
77
- // the in-order client channel the pendency verdict resolves AFTER the
78
- // rows it vouches for. Bootstrap-completion commit order is therefore
79
- // rows first, pending→complete second — a consumer gating "empty" on
80
- // `isComplete` never paints a false empty over a stale result (§4.8
81
- // honesty at the render boundary, not just in the oracle). Both
82
- // boundaries run INSIDE the scheduler (a two-phase callback re-arming
83
- // itself once) so a fire parked in a suspended rAF stays covered by the
84
- // hidden-document rescue — a bare second rAF would not be.
85
- let armed = false;
86
- const scheduler = new FrameScheduler(() => {
87
- if (!armed) {
88
- armed = true;
89
- scheduler.schedule(); // boundary two via the dirty/re-run contract
90
- return;
91
- }
92
- armed = false;
93
- return read();
94
- });
95
- const unsubscribe = client.onInvalidate((event) => {
96
- if (event.tables.has(baseRef.current.table)) scheduler.schedule();
97
- });
73
+ },
74
+ );
98
75
  return () => {
99
- cancelled = true;
100
- scheduler.dispose();
101
- unsubscribe();
76
+ active = false;
77
+ retention.release();
102
78
  };
103
- }, [client, baseKey]);
79
+ }, [stableBase, stableUnits, store]);
104
80
 
105
- const setWindow = useCallback(
106
- (next: readonly string[]) => {
107
- const result = Promise.resolve(client.setWindow(baseRef.current, next));
108
- // Optimistically reflect the new set; the invalidation-driven re-read
109
- // reconciles against the registry (e.g. a pinned unit lingering). The
110
- // optimistic update MUST NOT claim completeness: a unit stays (or
111
- // becomes) pending unless the previous snapshot already had it
112
- // complete entering units are mid-bootstrap until the re-read
113
- // confirms otherwise (§4.8: registration completeness).
114
- setState((prev) => ({
115
- units: next,
116
- pending: next.filter((unit) => !windowComplete(prev, unit)),
117
- }));
118
- return result;
119
- },
120
- [client],
81
+ return state;
82
+ }
83
+
84
+ export function useWindow(base: WindowBase): UseWindowResult {
85
+ const store = useReactiveStore();
86
+ const owner = useRef(Symbol('useWindow'));
87
+ const baseIdentity = canonicalValue(base);
88
+ // Window bases are value objects commonly recreated during render. Preserve
89
+ // the prior object while its canonical value is unchanged.
90
+ // biome-ignore lint/correctness/useExhaustiveDependencies: baseIdentity represents the complete value
91
+ const stableBase = useMemo(() => base, [baseIdentity]);
92
+ const entry = useMemo(() => store.window(stableBase), [store, stableBase]);
93
+ const state = useSyncExternalStore(
94
+ entry.subscribe,
95
+ entry.getSnapshot,
96
+ entry.getSnapshot,
121
97
  );
122
98
 
99
+ useEffect(() => () => store.releaseWindowClaims(owner.current), [store]);
100
+
101
+ const setWindow = useCallback(
102
+ (units: readonly string[]) =>
103
+ store.setWindowClaim(owner.current, stableBase, units),
104
+ [stableBase, store],
105
+ );
123
106
  const isComplete = useCallback(
124
107
  (unit: string) => windowComplete(state, unit),
125
108
  [state],
126
109
  );
127
-
128
- return { units: state.units, pending: state.pending, setWindow, isComplete };
110
+ return {
111
+ units: state.units,
112
+ pending: state.pending,
113
+ setWindow,
114
+ isComplete,
115
+ };
129
116
  }
@@ -1,109 +0,0 @@
1
- /**
2
- * Live-query churn hardening — the three cheap levers the query hooks share
3
- * (block 4 "live-query churn" item). Under constant sync churn a naive live
4
- * query re-renders and re-queries once per invalidation event; these levers
5
- * cap both without reaching for IVM:
6
- *
7
- * 1. {@link reconcileRows} — result stability. After a re-run, compare the new
8
- * result to the previous one. Whole-result equal → the caller skips
9
- * `setRows` entirely (zero re-render). Otherwise build the new array but
10
- * REUSE the previous row object for every row whose content is unchanged,
11
- * so `React.memo`'d row components keyed by row identity skip re-render.
12
- * 2. {@link FrameScheduler} — frame-coalesced re-query scheduling. Many
13
- * invalidation events between paints collapse to ONE re-run per query.
14
- * 3. scope-key filtering lives in {@link ../use-raw-sql} `eventMatches`
15
- * (it needs the event + the hook's options), documented there.
16
- *
17
- * Row identity mechanism (the honest key): the hook knows no primary key —
18
- * rows are plain JSON-able objects out of SQLite — so per-row content equality
19
- * IS the identity. We hash each row once with a stable JSON serialization
20
- * (sorted keys, so column order can't spuriously differ) and match by index:
21
- * a live query's ORDER BY makes index the stable position, and an unchanged
22
- * row at position i keeps its object so memoized components skip. This is O(n)
23
- * in the row count with one string hash per row — bounded and measured (~0.2ms
24
- * for 1k narrow rows in bun; see query-churn.test.ts).
25
- */
26
- /**
27
- * A stable content hash for one row: JSON with keys sorted, so two rows with
28
- * the same columns in a different order hash equal (SQLite projection order is
29
- * stable per query, but sorting removes any dependence on it and is cheap for
30
- * the narrow rows a row-component renders). Uint8Array values (rare in a
31
- * projection) serialize by byte view so a fresh copy of equal bytes hashes
32
- * equal rather than to `{}`.
33
- */
34
- export declare function hashRow(row: unknown): string;
35
- /** The precomputed hash carrier for the previous result, so we hash once. */
36
- export interface HashedRows<Row> {
37
- readonly rows: readonly Row[];
38
- readonly hashes: readonly string[];
39
- }
40
- export declare function hashRows<Row>(rows: readonly Row[]): HashedRows<Row>;
41
- export interface ReconcileResult<Row> {
42
- /**
43
- * `undefined` → the whole result is unchanged; the caller MUST NOT call
44
- * setRows (zero re-render, lever 1a). Otherwise the reconciled array to set,
45
- * with previous row objects reused wherever a row's content was unchanged
46
- * (lever 1b).
47
- */
48
- readonly next: HashedRows<Row> | undefined;
49
- }
50
- /**
51
- * Reconcile a freshly-queried result against the previous hashed result.
52
- * Returns `next: undefined` when nothing changed at all; otherwise a new
53
- * hashed result whose row objects are the PREVIOUS objects wherever content is
54
- * unchanged (matched by index), so identity-keyed memo components skip.
55
- */
56
- export declare function reconcileRows<Row>(prev: HashedRows<Row> | undefined, fresh: readonly Row[]): ReconcileResult<Row>;
57
- /**
58
- * A per-query re-run scheduler that coalesces bursts of invalidation events
59
- * into ONE run per paint. Multiple `schedule()` calls before the next flush
60
- * run the callback once. A `schedule()` that arrives WHILE the callback is
61
- * running (re-entrant, or an event during an async re-query) marks dirty and
62
- * runs the callback exactly once more after — never lost, never concurrent.
63
- *
64
- * Timing source: `requestAnimationFrame` when the host has it AND the document
65
- * is visible (a real browser paints one frame; the coalescing window is a
66
- * frame), else a microtask via a resolved promise (bun tests have no rAF —
67
- * this keeps them deterministic and timer-free, honoring the no-timers
68
- * doctrine: it's a readiness turn, not a wall-clock sleep). {@link flush} runs
69
- * any pending callback synchronously for tests, so no arbitrary sleeps are
70
- * needed to observe coalescing.
71
- *
72
- * Hidden documents: browsers SUSPEND rAF while a page is hidden (background
73
- * tab, occluded webview, headless embed), so a frame parked there fires only
74
- * when the page becomes visible again — and a page that is never visible would
75
- * freeze its live queries forever while invalidations keep arriving. Two
76
- * guards keep the schedule honest: a `schedule()` issued while hidden goes to
77
- * the microtask boundary (there is no paint to coalesce against anyway), and a
78
- * visible → hidden transition re-dispatches any frame already parked in rAF to
79
- * a microtask (the stale rAF callback later no-ops via the `#scheduled` guard
80
- * in {@link #fire}).
81
- */
82
- export declare class FrameScheduler {
83
- #private;
84
- constructor(callback: () => void | Promise<void>);
85
- /** Request a run. Coalesces until the next frame/microtask boundary. */
86
- schedule(): void;
87
- /**
88
- * @internal — the visible → hidden transition hands a frame parked in the
89
- * (now suspended) rAF to the microtask boundary, so live queries keep
90
- * converging off-screen. A no-op when nothing is pending.
91
- */
92
- redispatchPending(): void;
93
- /**
94
- * Synchronously run any pending scheduled callback NOW (test determinism).
95
- * Returns whatever the callback returned (a Promise for the async host path)
96
- * so a test can await the re-query settling without a sleep. A no-op when
97
- * nothing is pending.
98
- */
99
- flush(): void | Promise<void>;
100
- /** Drop the callback so a torn-down hook's pending frame is a no-op. */
101
- dispose(): void;
102
- }
103
- /**
104
- * TEST-ONLY: synchronously flush every live scheduler's pending frame, so a
105
- * test can observe the coalesced re-query without a wall-clock sleep (the
106
- * no-timers doctrine — a readiness flush, injected, not a timer). Returns a
107
- * Promise that settles when every flushed async re-query has settled.
108
- */
109
- export declare function flushQuerySchedulers(): Promise<void>;