@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,18 +1,28 @@
1
- /**
2
- * `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
3
- * that appends to the outbox and applies the optimistic overlay immediately
4
- * (§7.1); the referencing `useRawSql` re-runs on the resulting
5
- * invalidation batch, so optimistic writes appear without a manual refetch.
6
- * `mutate` resolves to the `clientCommitId` (track it against
7
- * `useConflicts`/status). `isPending`/`error` cover the (usually instant)
8
- * submit; server acceptance is observed through status + conflicts, not
9
- * here.
10
- */
11
1
  import type { MutationInput } from '@syncular/client';
2
+ export interface SyncTableDescriptor<Row, Insert, Update, Id> {
3
+ readonly name: string;
4
+ readonly primaryKey: keyof Row & string;
5
+ readonly physicalPrimaryKey: string;
6
+ readonly __row?: Row;
7
+ readonly __insert?: Insert;
8
+ readonly __update?: Update;
9
+ readonly __id?: Id;
10
+ }
11
+ export interface UseMutationOptions {
12
+ readonly onSuccess?: (clientCommitId: string) => void;
13
+ readonly onError?: (error: Error) => void;
14
+ }
12
15
  export interface UseMutationResult {
13
- /** Submit mutations; resolves to the clientCommitId. */
14
16
  mutate: (mutations: readonly MutationInput[]) => Promise<string>;
17
+ readonly pendingCount: number;
15
18
  readonly isPending: boolean;
16
19
  readonly error: Error | undefined;
20
+ readonly resetError: () => void;
21
+ }
22
+ export interface UseTableMutationResult<Insert, Update, Id> extends UseMutationResult {
23
+ readonly upsert: (values: Insert, baseVersion?: number) => Promise<string>;
24
+ readonly patch: (id: Id, partial: Partial<Update>, baseVersion?: number) => Promise<string>;
25
+ readonly remove: (id: Id, baseVersion?: number) => Promise<string>;
17
26
  }
18
- export declare function useMutation(): UseMutationResult;
27
+ export declare function useMutation(options?: UseMutationOptions): UseMutationResult;
28
+ export declare function useMutation<Row, Insert, Update, Id>(table: SyncTableDescriptor<Row, Insert, Update, Id>, options?: UseMutationOptions): UseTableMutationResult<Insert, Update, Id>;
@@ -1,34 +1,74 @@
1
- /**
2
- * `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
3
- * that appends to the outbox and applies the optimistic overlay immediately
4
- * (§7.1); the referencing `useRawSql` re-runs on the resulting
5
- * invalidation batch, so optimistic writes appear without a manual refetch.
6
- * `mutate` resolves to the `clientCommitId` (track it against
7
- * `useConflicts`/status). `isPending`/`error` cover the (usually instant)
8
- * submit; server acceptance is observed through status + conflicts, not
9
- * here.
10
- */
11
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useRef, useState } from 'react';
12
2
  import { useSyncClient } from './use-client.js';
13
- export function useMutation() {
3
+ export function useMutation(tableOrOptions, maybeOptions) {
14
4
  const client = useSyncClient();
15
- const [isPending, setIsPending] = useState(false);
5
+ const table = tableOrOptions !== undefined && 'name' in tableOrOptions
6
+ ? tableOrOptions
7
+ : undefined;
8
+ const options = table === undefined
9
+ ? tableOrOptions
10
+ : maybeOptions;
11
+ const optionsRef = useRef(options);
12
+ optionsRef.current = options;
13
+ const [pendingCount, setPendingCount] = useState(0);
16
14
  const [error, setError] = useState(undefined);
17
- const mutate = useCallback(async (mutations) => {
18
- setIsPending(true);
15
+ const resetError = useCallback(() => setError(undefined), []);
16
+ const run = useCallback(async (operation) => {
17
+ setPendingCount((count) => count + 1);
19
18
  setError(undefined);
20
19
  try {
21
- const id = await client.mutate(mutations);
20
+ const id = await operation();
21
+ optionsRef.current?.onSuccess?.(id);
22
22
  return id;
23
23
  }
24
- catch (err) {
25
- const wrapped = err instanceof Error ? err : new Error(String(err));
24
+ catch (caught) {
25
+ const wrapped = caught instanceof Error ? caught : new Error(String(caught));
26
26
  setError(wrapped);
27
+ optionsRef.current?.onError?.(wrapped);
27
28
  throw wrapped;
28
29
  }
29
30
  finally {
30
- setIsPending(false);
31
+ setPendingCount((count) => Math.max(0, count - 1));
31
32
  }
32
- }, [client]);
33
- return { mutate, isPending, error };
33
+ }, []);
34
+ const mutate = useCallback((mutations) => run(() => client.mutate(mutations)), [client, run]);
35
+ const base = {
36
+ mutate,
37
+ pendingCount,
38
+ isPending: pendingCount > 0,
39
+ error,
40
+ resetError,
41
+ };
42
+ const upsert = useCallback((values, baseVersion) => {
43
+ if (table === undefined)
44
+ throw new Error('table mutation descriptor missing');
45
+ return mutate([
46
+ {
47
+ table: table.name,
48
+ op: 'upsert',
49
+ values: values,
50
+ ...(baseVersion !== undefined ? { baseVersion } : {}),
51
+ },
52
+ ]);
53
+ }, [mutate, table]);
54
+ const patch = useCallback((id, partial, baseVersion) => {
55
+ if (table === undefined)
56
+ throw new Error('table mutation descriptor missing');
57
+ return run(() => client.patch(table.name, String(id), partial, baseVersion !== undefined ? { baseVersion } : undefined));
58
+ }, [client, run, table]);
59
+ const remove = useCallback((id, baseVersion) => {
60
+ if (table === undefined)
61
+ throw new Error('table mutation descriptor missing');
62
+ return mutate([
63
+ {
64
+ table: table.name,
65
+ op: 'delete',
66
+ rowId: String(id),
67
+ ...(baseVersion !== undefined ? { baseVersion } : {}),
68
+ },
69
+ ]);
70
+ }, [mutate, table]);
71
+ if (table === undefined)
72
+ return base;
73
+ return { ...base, upsert, patch, remove };
34
74
  }
@@ -18,10 +18,12 @@
18
18
  * (typegen emits its own `NamedQuery` type), so this hook depends only on the
19
19
  * descriptor's structural shape — no generated-file import coupling.
20
20
  */
21
- import type { SqlValue } from '@syncular/client';
21
+ import type { QueryDependency, SqlValue, WindowCoverage } from '@syncular/client';
22
22
  import { type UseRawSqlOptions, type UseRawSqlResult } from './use-raw-sql.js';
23
23
  /** The structural shape typegen's `NamedQuery<Row, Params>` satisfies. */
24
24
  export interface NamedQueryDescriptor<Row, Params> {
25
+ readonly id: string;
26
+ readonly hasParams: boolean;
25
27
  readonly sql: string;
26
28
  readonly tables: readonly string[];
27
29
  readonly bind: (params: Params) => readonly SqlValue[];
@@ -29,10 +31,15 @@ export interface NamedQueryDescriptor<Row, Params> {
29
31
  * generate-time-checked allowlist (identifiers never come from runtime
30
32
  * input). Absent on knob-less queries — `sql` is the whole statement. */
31
33
  readonly sqlFor?: (params: Params) => string;
34
+ readonly dependencies: (params: Params) => readonly QueryDependency[];
35
+ readonly coverage: (params: Params) => readonly WindowCoverage[];
36
+ readonly rowKey?: (row: Row) => readonly SqlValue[];
32
37
  /** Phantom row carrier (never read at runtime). */
33
38
  readonly __row?: Row;
34
39
  }
40
+ type NamedQueryOptions<Row> = Omit<UseRawSqlOptions<Row>, 'tables' | 'scopeKeys' | 'dependencies' | 'coverage' | 'rowKey' | 'id'>;
35
41
  /** Run a param-less named query live. */
36
- export declare function useQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?: Omit<UseRawSqlOptions, 'tables'>): UseRawSqlResult<Row>;
42
+ export declare function useQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?: NamedQueryOptions<Row>): UseRawSqlResult<Row>;
37
43
  /** Run a named query live with its typed params. */
38
- export declare function useQuery<Row, Params>(query: NamedQueryDescriptor<Row, Params>, params: Params, options?: Omit<UseRawSqlOptions, 'tables'>): UseRawSqlResult<Row>;
44
+ export declare function useQuery<Row, Params>(query: NamedQueryDescriptor<Row, Params>, params: Params, options?: NamedQueryOptions<Row>): UseRawSqlResult<Row>;
45
+ export {};
package/dist/use-query.js CHANGED
@@ -2,13 +2,18 @@ import { useRawSql, } from './use-raw-sql.js';
2
2
  export function useQuery(query, paramsOrOptions, maybeOptions) {
3
3
  // Overload disambiguation: a param-less query's second arg (if any) is the
4
4
  // options object; a parameterized query's second arg is the params.
5
- const hasParams = query.bind.length > 0;
5
+ const hasParams = query.hasParams;
6
6
  const params = (hasParams ? paramsOrOptions : undefined);
7
7
  const options = (hasParams ? maybeOptions : paramsOrOptions);
8
8
  const bound = query.bind(params);
9
9
  const sql = query.sqlFor === undefined ? query.sql : query.sqlFor(params);
10
+ const dependencies = query.dependencies(params);
11
+ const coverage = query.coverage(params);
10
12
  return useRawSql(sql, bound, {
11
13
  ...options,
12
- tables: query.tables,
14
+ id: query.id,
15
+ dependencies,
16
+ coverage,
17
+ ...(query.rowKey !== undefined ? { rowKey: query.rowKey } : {}),
13
18
  });
14
19
  }
@@ -1,39 +1,25 @@
1
- /**
2
- * `useRawSql` a live local SQL query with fine-grained invalidation
3
- * (TODO 3.1 / DESIGN-eviction I1–I4). The query runs once on mount, then
4
- * re-runs ONLY when an invalidation event (one per apply batch, from the
5
- * web-client choke point) touches a table this query depends on — never
6
- * "re-run everything". Unrelated commits leave the result untouched (I4).
7
- *
8
- * Dependencies default to a conservative scan of the SQL text (FROM/JOIN
9
- * identifiers, {@link inferTables}); pass the explicit `tables` option to
10
- * override when the text cannot be read (dynamic SQL, views). `scopeKeys`
11
- * further narrows re-runs to specific §3.1 scope keys when supplied — but
12
- * a matching TABLE always re-runs (the table is the honest floor; a segment
13
- * apply carries no per-row scope keys, so table-level is the safe default).
14
- */
15
- import type { SqlRow, SqlValue } from '@syncular/client';
16
- export interface UseRawSqlOptions {
17
- /**
18
- * Tables this query depends on. Defaults to a conservative scan of `sql`.
19
- * Pass explicitly to override the heuristic (the escape hatch).
20
- */
1
+ import { type LiveQueryPhase, type QueryDependency, type SqlRow, type SqlValue, type WindowCoverage } from '@syncular/client';
2
+ export interface UseRawSqlOptions<Row = SqlRow> {
3
+ /** Legacy table list; prefer table-associated `dependencies`. */
21
4
  readonly tables?: readonly string[];
22
- /**
23
- * When set, re-run only if the invalidation event's touched table is a
24
- * dependency AND (the event has no scope keys — table-level, e.g. a
25
- * bootstrap — OR it touches one of these §3.1 `prefix:value` keys). Leave
26
- * unset to re-run on any dependency-table touch.
27
- */
5
+ /** Legacy narrowing applied to every table in `tables`. */
28
6
  readonly scopeKeys?: readonly string[];
29
- /** Skip running the query (e.g. while inputs are not ready). */
7
+ readonly dependencies?: readonly QueryDependency[];
8
+ readonly coverage?: readonly WindowCoverage[];
9
+ readonly rowKey?: (row: Row) => readonly SqlValue[];
10
+ /** Generated coverage claims its windows by default. */
11
+ readonly claimCoverage?: boolean;
30
12
  readonly enabled?: boolean;
13
+ /** Stable identity override for a raw query cache entry. */
14
+ readonly id?: string;
31
15
  }
32
16
  export interface UseRawSqlResult<Row> {
33
17
  readonly rows: readonly Row[];
18
+ readonly phase: LiveQueryPhase;
19
+ readonly revision: bigint | undefined;
34
20
  readonly isLoading: boolean;
21
+ readonly isRefreshing: boolean;
35
22
  readonly error: Error | undefined;
36
- /** Force a re-run (identity-stable). */
37
23
  readonly refresh: () => void;
38
24
  }
39
- export declare function useRawSql<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseRawSqlOptions): UseRawSqlResult<Row>;
25
+ export declare function useRawSql<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseRawSqlOptions<Row>): UseRawSqlResult<Row>;
@@ -1,191 +1,59 @@
1
- /**
2
- * `useRawSql` a live local SQL query with fine-grained invalidation
3
- * (TODO 3.1 / DESIGN-eviction I1–I4). The query runs once on mount, then
4
- * re-runs ONLY when an invalidation event (one per apply batch, from the
5
- * web-client choke point) touches a table this query depends on — never
6
- * "re-run everything". Unrelated commits leave the result untouched (I4).
7
- *
8
- * Dependencies default to a conservative scan of the SQL text (FROM/JOIN
9
- * identifiers, {@link inferTables}); pass the explicit `tables` option to
10
- * override when the text cannot be read (dynamic SQL, views). `scopeKeys`
11
- * further narrows re-runs to specific §3.1 scope keys when supplied — but
12
- * a matching TABLE always re-runs (the table is the honest floor; a segment
13
- * apply carries no per-row scope keys, so table-level is the safe default).
14
- */
15
- import { useCallback, useEffect, useRef, useState } from 'react';
1
+ import { canonicalValue, } from '@syncular/client';
2
+ import { useCallback, useMemo, useSyncExternalStore } from 'react';
16
3
  import { inferTables } from './infer-tables.js';
17
- import { FrameScheduler, reconcileRows } from './query-churn.js';
18
- import { useSyncClient } from './use-client.js';
19
- function paramsKey(params) {
20
- if (params === undefined || params.length === 0)
21
- return '';
22
- // Stable identity for the params array so a new array with equal contents
23
- // does not thrash the effect. Uint8Array is rare in query params; JSON of
24
- // its byte view is stable enough for a dependency key.
25
- return JSON.stringify(params.map((p) => (p instanceof Uint8Array ? [...p] : p)));
26
- }
27
- function eventMatches(event, tables, scopeKeys) {
28
- let tableHit = false;
29
- for (const table of event.tables) {
30
- if (tables.has(table)) {
31
- tableHit = true;
32
- break;
33
- }
34
- }
35
- if (!tableHit)
36
- return false;
37
- // Table matched. If the caller narrowed by scope keys, honor it — but a
38
- // table-level event (no scope keys, e.g. a segment bootstrap or reset)
39
- // always re-runs, because it carries no key to discriminate on.
40
- if (scopeKeys === undefined || scopeKeys.length === 0)
41
- return true;
42
- if (event.scopeKeys.size === 0)
43
- return true;
44
- for (const key of scopeKeys) {
45
- if (event.scopeKeys.has(key))
46
- return true;
47
- }
48
- return false;
49
- }
4
+ import { useReactiveStore } from './use-client.js';
5
+ const DISABLED = {
6
+ rows: [],
7
+ phase: 'ready',
8
+ revision: undefined,
9
+ error: undefined,
10
+ isRefreshing: false,
11
+ };
12
+ const noSubscribe = () => () => { };
50
13
  export function useRawSql(sql, params, options) {
51
- const client = useSyncClient();
14
+ const store = useReactiveStore();
52
15
  const enabled = options?.enabled ?? true;
53
- const [rows, setRows] = useState([]);
54
- const [isLoading, setIsLoading] = useState(enabled);
55
- const [error, setError] = useState(undefined);
56
- // The previous result plus its per-row content hashes, so a re-run can (a)
57
- // skip setRows entirely when nothing changed (zero re-render) and (b) reuse
58
- // unchanged row objects so memoized row components keep identity. Held in a
59
- // ref — it is not render state; it's the reconcile baseline.
60
- const prevRef = useRef(undefined);
61
- // The effects depend on stable PRIMITIVE keys (strings), never on the
62
- // array/object identities a caller may recreate each render. Latest
63
- // sql/params/scopeKeys/dep-tables are read inside the effects via refs.
64
- const explicitTables = options?.tables;
65
- const scopeKeys = options?.scopeKeys;
66
- const key = `${sql} ${paramsKey(params)}`;
67
- const scopeKeysKey = scopeKeys?.join(',') ?? '';
68
- const sqlRef = useRef(sql);
69
- sqlRef.current = sql;
70
- const paramsRef = useRef(params);
71
- paramsRef.current = params;
72
- const scopeKeysRef = useRef(scopeKeys);
73
- scopeKeysRef.current = scopeKeys;
74
- const depTablesRef = useRef(new Set());
75
- depTablesRef.current =
76
- explicitTables !== undefined ? new Set(explicitTables) : inferTables(sql);
77
- // The single re-run routine, held in a ref so the scheduler and the
78
- // subscription can call the LATEST closure without re-subscribing. It queries
79
- // once, reconciles the fresh result against the previous, and only sets state
80
- // when something actually changed (levers 1a/1b). A `cancelled` guard (reset
81
- // per effect commit) makes a re-query from a stale mount a no-op.
82
- const runRef = useRef();
83
- const cancelledRef = useRef(false);
84
- // `key`/`tick` are re-run triggers read via refs, not values used in the
85
- // body — the pinned dep list re-runs the query on identity change.
86
- const [tick, setTick] = useState(0);
87
- const refresh = useCallback(() => setTick((n) => n + 1), []);
88
- // One scheduler per hook instance, created lazily and re-run through the ref
89
- // so a burst of invalidations coalesces to one query per frame (lever 2).
90
- const schedulerRef = useRef();
91
- if (schedulerRef.current === undefined) {
92
- schedulerRef.current = new FrameScheduler(() => runRef.current?.());
93
- }
94
- // Latest committed loading/error, tracked in refs so the run can set state
95
- // ONLY on a real transition. React does not reliably bail on a no-op
96
- // `setState(sameValue)` when other setters fire in the same batch (it can
97
- // still commit a render), so lever 1a — zero re-render on unchanged data —
98
- // requires us to guard every setter, not just setRows.
99
- const isLoadingValueRef = useRef(isLoading);
100
- isLoadingValueRef.current = isLoading;
101
- const errorValueRef = useRef(error);
102
- errorValueRef.current = error;
103
- const setLoadingIfChanged = (value) => {
104
- if (isLoadingValueRef.current !== value) {
105
- isLoadingValueRef.current = value;
106
- setIsLoading(value);
107
- }
108
- };
109
- const setErrorIfChanged = (value) => {
110
- if (errorValueRef.current !== value) {
111
- errorValueRef.current = value;
112
- setError(value);
113
- }
114
- };
115
- // Keep the run closure current every render (it closes over client via ref).
116
- runRef.current = async () => {
117
- if (cancelledRef.current)
118
- return;
119
- try {
120
- const result = await client.query(sqlRef.current, paramsRef.current);
121
- if (cancelledRef.current)
122
- return;
123
- const fresh = result;
124
- const { next } = reconcileRows(prevRef.current, fresh);
125
- if (next !== undefined) {
126
- prevRef.current = next;
127
- setRows(next.rows);
128
- }
129
- // next === undefined → whole result unchanged: NO setRows (zero re-render).
130
- setErrorIfChanged(undefined);
131
- setLoadingIfChanged(false);
132
- }
133
- catch (err) {
134
- if (cancelledRef.current)
135
- return;
136
- setErrorIfChanged(err instanceof Error ? err : new Error(String(err)));
137
- setLoadingIfChanged(false);
138
- }
16
+ const inferred = options?.tables ?? [...inferTables(sql)];
17
+ const dependencies = options?.dependencies ??
18
+ inferred.map((table) => ({
19
+ table,
20
+ ...(options?.scopeKeys !== undefined
21
+ ? { scopeKeys: options.scopeKeys }
22
+ : {}),
23
+ }));
24
+ const coverage = options?.coverage ?? [];
25
+ const identity = canonicalValue({
26
+ sql,
27
+ params: params ?? [],
28
+ dependencies,
29
+ coverage,
30
+ ...(options?.id !== undefined ? { id: options.id } : {}),
31
+ });
32
+ // `identity` canonically contains every value-shaped input. Depending on
33
+ // the caller's array/object references would defeat value-stable query
34
+ // identity; executable rowKey intentionally follows function identity.
35
+ // biome-ignore lint/correctness/useExhaustiveDependencies: canonical value identity is the dependency
36
+ const entry = useMemo(() => store.query({
37
+ id: options?.id ?? `raw:${identity}`,
38
+ sql,
39
+ ...(params !== undefined ? { params } : {}),
40
+ dependencies,
41
+ ...(coverage.length > 0 ? { coverage } : {}),
42
+ ...(options?.rowKey !== undefined ? { rowKey: options.rowKey } : {}),
43
+ claimCoverage: options?.claimCoverage ?? true,
44
+ }), [store, identity, options?.rowKey]);
45
+ const snapshot = useSyncExternalStore(enabled ? entry.subscribe : noSubscribe, enabled ? entry.getSnapshot : () => DISABLED, enabled ? entry.getSnapshot : () => DISABLED);
46
+ const refresh = useCallback(() => {
47
+ if (enabled)
48
+ entry.refresh();
49
+ }, [enabled, entry]);
50
+ return {
51
+ rows: snapshot.rows,
52
+ phase: snapshot.phase,
53
+ revision: snapshot.revision,
54
+ isLoading: snapshot.phase === 'loading',
55
+ isRefreshing: snapshot.isRefreshing,
56
+ error: snapshot.error,
57
+ refresh,
139
58
  };
140
- // Mount / query-identity / refresh: run immediately (not frame-coalesced —
141
- // the caller changed the query, so it must reflect at once). Invalidations go
142
- // through the scheduler instead.
143
- // biome-ignore lint/correctness/useExhaustiveDependencies: key/tick are intentional re-run triggers
144
- useEffect(() => {
145
- cancelledRef.current = false;
146
- if (!enabled) {
147
- setLoadingIfChanged(false);
148
- return;
149
- }
150
- // A query-identity change invalidates the reconcile baseline (a different
151
- // query's rows are not comparable to this one's), so reset it.
152
- prevRef.current = undefined;
153
- setLoadingIfChanged(true);
154
- void runRef.current?.();
155
- return () => {
156
- cancelledRef.current = true;
157
- };
158
- }, [client, key, enabled, tick]);
159
- // Subscribe once per client/enabled/query identity; a matching event asks the
160
- // scheduler for a run (coalesced). `key`/`scopeKeysKey` re-key the sub. The
161
- // scheduler is read from the ref AT EVENT TIME (never captured at effect
162
- // setup): the lifecycle effect below may replace it on a remount, and a
163
- // captured disposed instance would swallow every schedule() silently.
164
- // biome-ignore lint/correctness/useExhaustiveDependencies: key/scopeKeysKey re-key the subscription intentionally
165
- useEffect(() => {
166
- if (!enabled)
167
- return;
168
- const unsubscribe = client.onInvalidate((event) => {
169
- if (eventMatches(event, depTablesRef.current, scopeKeysRef.current)) {
170
- schedulerRef.current?.schedule();
171
- }
172
- });
173
- return unsubscribe;
174
- }, [client, enabled, key, scopeKeysKey]);
175
- // Scheduler lifecycle. Under StrictMode (and any future fiber remount)
176
- // React runs mount → cleanup → mount on the SAME hook instance: the cleanup
177
- // disposes the scheduler, so the setup must RE-CREATE it — the render-time
178
- // lazy init above never runs again (the ref is non-undefined), and a
179
- // disposed scheduler turns every later invalidation into a silent no-op
180
- // that freezes the live query forever.
181
- useEffect(() => {
182
- if (schedulerRef.current === undefined) {
183
- schedulerRef.current = new FrameScheduler(() => runRef.current?.());
184
- }
185
- return () => {
186
- schedulerRef.current?.dispose();
187
- schedulerRef.current = undefined;
188
- };
189
- }, []);
190
- return { rows, isLoading, error, refresh };
191
59
  }
@@ -1,29 +1,12 @@
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
1
  import type { LeaseState, SchemaFloor } from '@syncular/client';
14
2
  export interface SyncStatus {
15
- /** Pending outbox commits (unpushed local writes). */
16
3
  readonly outbox: number;
17
- /** §7.4.5: a schema-bump reset + first re-bootstrap is in flight. */
18
4
  readonly upgrading: boolean;
19
- /** §7.3.5: opaque auth-lease state, or undefined. */
20
5
  readonly leaseState: LeaseState | undefined;
21
- /** §1.6: server-declared schema floor (syncing stopped), or undefined. */
22
6
  readonly schemaFloor: SchemaFloor | undefined;
23
- /** §8.4: the host loop should run a pull soon. */
24
7
  readonly syncNeeded: boolean;
25
- /** True until the first status read resolves. */
26
8
  readonly isLoading: boolean;
9
+ readonly error: Error | undefined;
27
10
  readonly refresh: () => void;
28
11
  }
29
12
  export declare function useSyncStatus(): SyncStatus;
@@ -1,67 +1,17 @@
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
- import { useCallback, useEffect, useRef, useState } from 'react';
14
- import { useSyncClient } from './use-client.js';
15
- const INITIAL = {
16
- outbox: 0,
17
- upgrading: false,
18
- leaseState: undefined,
19
- schemaFloor: undefined,
20
- syncNeeded: false,
21
- isLoading: true,
22
- };
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
+ import { useReactiveStore } from './use-client.js';
23
3
  export function useSyncStatus() {
24
- const client = useSyncClient();
25
- const [state, setState] = useState(INITIAL);
26
- // `refresh` re-reads through a ref set by the effect (identity-stable, no
27
- // tick state — biome-clean deps).
28
- const readRef = useRef(() => { });
29
- const refresh = useCallback(() => readRef.current(), []);
30
- useEffect(() => {
31
- let cancelled = false;
32
- const read = () => {
33
- Promise.all([
34
- client.pendingCommits(),
35
- client.upgrading(),
36
- client.leaseState(),
37
- client.schemaFloor(),
38
- client.syncNeeded(),
39
- ])
40
- .then(([pending, upgrading, leaseState, schemaFloor, syncNeeded]) => {
41
- if (cancelled)
42
- return;
43
- setState({
44
- outbox: pending.length,
45
- upgrading,
46
- leaseState,
47
- schemaFloor,
48
- syncNeeded,
49
- isLoading: false,
50
- });
51
- })
52
- .catch(() => {
53
- if (!cancelled)
54
- setState((s) => ({ ...s, isLoading: false }));
55
- });
56
- };
57
- readRef.current = read;
58
- read();
59
- // Re-read on every apply batch — the "state changed" edge.
60
- const unsubscribe = client.onInvalidate(read);
61
- return () => {
62
- cancelled = true;
63
- unsubscribe();
64
- };
65
- }, [client]);
66
- return { ...state, refresh };
4
+ const entry = useReactiveStore().status;
5
+ const snapshot = useSyncExternalStore(entry.subscribe, entry.getSnapshot, entry.getSnapshot);
6
+ const refresh = useCallback(() => entry.refresh(), [entry]);
7
+ return {
8
+ outbox: snapshot.status?.outbox ?? 0,
9
+ upgrading: snapshot.status?.upgrading ?? false,
10
+ leaseState: snapshot.status?.leaseState,
11
+ schemaFloor: snapshot.status?.schemaFloor,
12
+ syncNeeded: snapshot.status?.syncNeeded ?? false,
13
+ isLoading: snapshot.isLoading,
14
+ error: snapshot.error,
15
+ refresh,
16
+ };
67
17
  }