@syncular/react 0.2.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.
Files changed (47) hide show
  1. package/README.md +182 -0
  2. package/dist/client.d.ts +58 -0
  3. package/dist/client.js +31 -0
  4. package/dist/index.d.ts +21 -0
  5. package/dist/index.js +18 -0
  6. package/dist/infer-tables.d.ts +20 -0
  7. package/dist/infer-tables.js +33 -0
  8. package/dist/provider.d.ts +16 -0
  9. package/dist/provider.js +15 -0
  10. package/dist/query-churn.d.ts +92 -0
  11. package/dist/query-churn.js +216 -0
  12. package/dist/typed.d.ts +6 -0
  13. package/dist/typed.js +6 -0
  14. package/dist/use-client.d.ts +3 -0
  15. package/dist/use-client.js +10 -0
  16. package/dist/use-conflicts.d.ts +12 -0
  17. package/dist/use-conflicts.js +37 -0
  18. package/dist/use-mutation.d.ts +18 -0
  19. package/dist/use-mutation.js +34 -0
  20. package/dist/use-named-query.d.ts +34 -0
  21. package/dist/use-named-query.js +13 -0
  22. package/dist/use-presence.d.ts +10 -0
  23. package/dist/use-presence.js +37 -0
  24. package/dist/use-sync-query.d.ts +39 -0
  25. package/dist/use-sync-query.js +179 -0
  26. package/dist/use-sync-status.d.ts +29 -0
  27. package/dist/use-sync-status.js +67 -0
  28. package/dist/use-typed-query.d.ts +32 -0
  29. package/dist/use-typed-query.js +44 -0
  30. package/dist/use-window.d.ts +27 -0
  31. package/dist/use-window.js +49 -0
  32. package/package.json +85 -0
  33. package/src/client.ts +130 -0
  34. package/src/index.ts +38 -0
  35. package/src/infer-tables.ts +35 -0
  36. package/src/provider.ts +36 -0
  37. package/src/query-churn.ts +248 -0
  38. package/src/typed.ts +6 -0
  39. package/src/use-client.ts +14 -0
  40. package/src/use-conflicts.ts +47 -0
  41. package/src/use-mutation.ts +47 -0
  42. package/src/use-named-query.ts +66 -0
  43. package/src/use-presence.ts +40 -0
  44. package/src/use-sync-query.ts +223 -0
  45. package/src/use-sync-status.ts +87 -0
  46. package/src/use-typed-query.ts +67 -0
  47. package/src/use-window.ts +88 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * `useSyncQuery` — 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
+
16
+ import type { InvalidationEvent, SqlRow, SqlValue } from '@syncular/client';
17
+ import { useCallback, useEffect, useRef, useState } from 'react';
18
+ import { inferTables } from './infer-tables';
19
+ import { FrameScheduler, type HashedRows, reconcileRows } from './query-churn';
20
+ import { useSyncClient } from './use-client';
21
+
22
+ export interface UseSyncQueryOptions {
23
+ /**
24
+ * Tables this query depends on. Defaults to a conservative scan of `sql`.
25
+ * Pass explicitly to override the heuristic (the escape hatch).
26
+ */
27
+ readonly tables?: readonly string[];
28
+ /**
29
+ * When set, re-run only if the invalidation event's touched table is a
30
+ * dependency AND (the event has no scope keys — table-level, e.g. a
31
+ * bootstrap — OR it touches one of these §3.1 `prefix:value` keys). Leave
32
+ * unset to re-run on any dependency-table touch.
33
+ */
34
+ readonly scopeKeys?: readonly string[];
35
+ /** Skip running the query (e.g. while inputs are not ready). */
36
+ readonly enabled?: boolean;
37
+ }
38
+
39
+ export interface UseSyncQueryResult<Row> {
40
+ readonly rows: readonly Row[];
41
+ readonly isLoading: boolean;
42
+ readonly error: Error | undefined;
43
+ /** Force a re-run (identity-stable). */
44
+ readonly refresh: () => void;
45
+ }
46
+
47
+ function paramsKey(params: readonly SqlValue[] | undefined): string {
48
+ if (params === undefined || params.length === 0) return '';
49
+ // Stable identity for the params array so a new array with equal contents
50
+ // does not thrash the effect. Uint8Array is rare in query params; JSON of
51
+ // its byte view is stable enough for a dependency key.
52
+ return JSON.stringify(
53
+ params.map((p) => (p instanceof Uint8Array ? [...p] : p)),
54
+ );
55
+ }
56
+
57
+ function eventMatches(
58
+ event: InvalidationEvent,
59
+ tables: ReadonlySet<string>,
60
+ scopeKeys: readonly string[] | undefined,
61
+ ): boolean {
62
+ let tableHit = false;
63
+ for (const table of event.tables) {
64
+ if (tables.has(table)) {
65
+ tableHit = true;
66
+ break;
67
+ }
68
+ }
69
+ if (!tableHit) return false;
70
+ // Table matched. If the caller narrowed by scope keys, honor it — but a
71
+ // table-level event (no scope keys, e.g. a segment bootstrap or reset)
72
+ // always re-runs, because it carries no key to discriminate on.
73
+ if (scopeKeys === undefined || scopeKeys.length === 0) return true;
74
+ if (event.scopeKeys.size === 0) return true;
75
+ for (const key of scopeKeys) {
76
+ if (event.scopeKeys.has(key)) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ export function useSyncQuery<Row = SqlRow>(
82
+ sql: string,
83
+ params?: readonly SqlValue[],
84
+ options?: UseSyncQueryOptions,
85
+ ): UseSyncQueryResult<Row> {
86
+ const client = useSyncClient();
87
+ const enabled = options?.enabled ?? true;
88
+
89
+ const [rows, setRows] = useState<readonly Row[]>([]);
90
+ const [isLoading, setIsLoading] = useState<boolean>(enabled);
91
+ const [error, setError] = useState<Error | undefined>(undefined);
92
+
93
+ // The previous result plus its per-row content hashes, so a re-run can (a)
94
+ // skip setRows entirely when nothing changed (zero re-render) and (b) reuse
95
+ // unchanged row objects so memoized row components keep identity. Held in a
96
+ // ref — it is not render state; it's the reconcile baseline.
97
+ const prevRef = useRef<HashedRows<Row> | undefined>(undefined);
98
+
99
+ // The effects depend on stable PRIMITIVE keys (strings), never on the
100
+ // array/object identities a caller may recreate each render. Latest
101
+ // sql/params/scopeKeys/dep-tables are read inside the effects via refs.
102
+ const explicitTables = options?.tables;
103
+ const scopeKeys = options?.scopeKeys;
104
+ const key = `${sql} ${paramsKey(params)}`;
105
+ const scopeKeysKey = scopeKeys?.join(',') ?? '';
106
+
107
+ const sqlRef = useRef(sql);
108
+ sqlRef.current = sql;
109
+ const paramsRef = useRef(params);
110
+ paramsRef.current = params;
111
+ const scopeKeysRef = useRef(scopeKeys);
112
+ scopeKeysRef.current = scopeKeys;
113
+ const depTablesRef = useRef<Set<string>>(new Set());
114
+ depTablesRef.current =
115
+ explicitTables !== undefined ? new Set(explicitTables) : inferTables(sql);
116
+
117
+ // The single re-run routine, held in a ref so the scheduler and the
118
+ // subscription can call the LATEST closure without re-subscribing. It queries
119
+ // once, reconciles the fresh result against the previous, and only sets state
120
+ // when something actually changed (levers 1a/1b). A `cancelled` guard (reset
121
+ // per effect commit) makes a re-query from a stale mount a no-op.
122
+ const runRef = useRef<() => Promise<void>>();
123
+ const cancelledRef = useRef(false);
124
+
125
+ // `key`/`tick` are re-run triggers read via refs, not values used in the
126
+ // body — the pinned dep list re-runs the query on identity change.
127
+ const [tick, setTick] = useState(0);
128
+ const refresh = useCallback(() => setTick((n) => n + 1), []);
129
+
130
+ // One scheduler per hook instance, created lazily and re-run through the ref
131
+ // so a burst of invalidations coalesces to one query per frame (lever 2).
132
+ const schedulerRef = useRef<FrameScheduler>();
133
+ if (schedulerRef.current === undefined) {
134
+ schedulerRef.current = new FrameScheduler(() => runRef.current?.());
135
+ }
136
+
137
+ // Latest committed loading/error, tracked in refs so the run can set state
138
+ // ONLY on a real transition. React does not reliably bail on a no-op
139
+ // `setState(sameValue)` when other setters fire in the same batch (it can
140
+ // still commit a render), so lever 1a — zero re-render on unchanged data —
141
+ // requires us to guard every setter, not just setRows.
142
+ const isLoadingValueRef = useRef(isLoading);
143
+ isLoadingValueRef.current = isLoading;
144
+ const errorValueRef = useRef(error);
145
+ errorValueRef.current = error;
146
+
147
+ const setLoadingIfChanged = (value: boolean) => {
148
+ if (isLoadingValueRef.current !== value) {
149
+ isLoadingValueRef.current = value;
150
+ setIsLoading(value);
151
+ }
152
+ };
153
+ const setErrorIfChanged = (value: Error | undefined) => {
154
+ if (errorValueRef.current !== value) {
155
+ errorValueRef.current = value;
156
+ setError(value);
157
+ }
158
+ };
159
+
160
+ // Keep the run closure current every render (it closes over client via ref).
161
+ runRef.current = async () => {
162
+ if (cancelledRef.current) return;
163
+ try {
164
+ const result = await client.query(sqlRef.current, paramsRef.current);
165
+ if (cancelledRef.current) return;
166
+ const fresh = result as readonly unknown[] as readonly Row[];
167
+ const { next } = reconcileRows(prevRef.current, fresh);
168
+ if (next !== undefined) {
169
+ prevRef.current = next;
170
+ setRows(next.rows);
171
+ }
172
+ // next === undefined → whole result unchanged: NO setRows (zero re-render).
173
+ setErrorIfChanged(undefined);
174
+ setLoadingIfChanged(false);
175
+ } catch (err: unknown) {
176
+ if (cancelledRef.current) return;
177
+ setErrorIfChanged(err instanceof Error ? err : new Error(String(err)));
178
+ setLoadingIfChanged(false);
179
+ }
180
+ };
181
+
182
+ // Mount / query-identity / refresh: run immediately (not frame-coalesced —
183
+ // the caller changed the query, so it must reflect at once). Invalidations go
184
+ // through the scheduler instead.
185
+ // biome-ignore lint/correctness/useExhaustiveDependencies: key/tick are intentional re-run triggers
186
+ useEffect(() => {
187
+ cancelledRef.current = false;
188
+ if (!enabled) {
189
+ setLoadingIfChanged(false);
190
+ return;
191
+ }
192
+ // A query-identity change invalidates the reconcile baseline (a different
193
+ // query's rows are not comparable to this one's), so reset it.
194
+ prevRef.current = undefined;
195
+ setLoadingIfChanged(true);
196
+ void runRef.current?.();
197
+ return () => {
198
+ cancelledRef.current = true;
199
+ };
200
+ }, [client, key, enabled, tick]);
201
+
202
+ // Subscribe once per client/enabled/query identity; a matching event asks the
203
+ // scheduler for a run (coalesced). `key`/`scopeKeysKey` re-key the sub.
204
+ // biome-ignore lint/correctness/useExhaustiveDependencies: key/scopeKeysKey re-key the subscription intentionally
205
+ useEffect(() => {
206
+ if (!enabled) return;
207
+ const scheduler = schedulerRef.current;
208
+ const unsubscribe = client.onInvalidate((event) => {
209
+ if (eventMatches(event, depTablesRef.current, scopeKeysRef.current)) {
210
+ scheduler?.schedule();
211
+ }
212
+ });
213
+ return unsubscribe;
214
+ }, [client, enabled, key, scopeKeysKey]);
215
+
216
+ // Dispose the scheduler when the hook unmounts (drop any pending frame).
217
+ useEffect(() => {
218
+ const scheduler = schedulerRef.current;
219
+ return () => scheduler?.dispose();
220
+ }, []);
221
+
222
+ return { rows, isLoading, error, refresh };
223
+ }
@@ -0,0 +1,87 @@
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
+ import type { LeaseState, SchemaFloor } from '@syncular/client';
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+ import { useSyncClient } from './use-client';
17
+
18
+ export interface SyncStatus {
19
+ /** Pending outbox commits (unpushed local writes). */
20
+ readonly outbox: number;
21
+ /** §7.4.5: a schema-bump reset + first re-bootstrap is in flight. */
22
+ readonly upgrading: boolean;
23
+ /** §7.3.5: opaque auth-lease state, or undefined. */
24
+ readonly leaseState: LeaseState | undefined;
25
+ /** §1.6: server-declared schema floor (syncing stopped), or undefined. */
26
+ readonly schemaFloor: SchemaFloor | undefined;
27
+ /** §8.4: the host loop should run a pull soon. */
28
+ readonly syncNeeded: boolean;
29
+ /** True until the first status read resolves. */
30
+ readonly isLoading: boolean;
31
+ readonly refresh: () => void;
32
+ }
33
+
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
+ 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 };
87
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `useTypedQuery` — the typed twin of {@link useSyncQuery}. You write a
3
+ * Kysely query builder; the hook compiles it to SQL, runs it live against the
4
+ * client, and extracts its `{tables}` dependency set from the compiled query's
5
+ * AST automatically — so invalidation is exact (no SQL-text heuristic) and
6
+ * fully typed by the schema's generated `Database` interface.
7
+ *
8
+ * ```ts
9
+ * const { rows } = useTypedQuery<Database, Pick<TodosRow, 'id' | 'title'>>(
10
+ * (db) => db.selectFrom('todos').select(['id', 'title']).where('list_id', '=', listId),
11
+ * [listId],
12
+ * );
13
+ * ```
14
+ *
15
+ * It reuses {@link useSyncQuery}'s invalidation machinery verbatim — the only
16
+ * additions are compilation and AST-based table extraction. Read-only, like
17
+ * the dialect: a write builder throws at execution (SPEC §7.1 → use
18
+ * `useMutation`). `@syncular/kysely` and `kysely` are PEER dependencies of
19
+ * this package (both are `optional`), so apps that only use `useSyncQuery`
20
+ * never pull Kysely in.
21
+ */
22
+
23
+ import type { SqlRow, SqlValue } from '@syncular/client';
24
+ import { createSyncularKysely, extractTables } from '@syncular/kysely';
25
+ import type { Compilable, CompiledQuery, Kysely } from 'kysely';
26
+ import { useMemo } from 'react';
27
+ import { useSyncClient } from './use-client';
28
+ import {
29
+ type UseSyncQueryOptions,
30
+ type UseSyncQueryResult,
31
+ useSyncQuery,
32
+ } from './use-sync-query';
33
+
34
+ /**
35
+ * Build a live typed query. `build` receives a `Kysely<Database>` bound to the
36
+ * context client and returns any compilable query builder. `deps` re-keys the
37
+ * builder the same way a `useEffect` dep array does (values the query closes
38
+ * over, e.g. filter inputs). `options.enabled`/`scopeKeys` pass through; the
39
+ * `{tables}` set is derived from the compiled query, never guessed.
40
+ */
41
+ export function useTypedQuery<Database, Row = SqlRow>(
42
+ build: (db: Kysely<Database>) => Compilable<Row>,
43
+ deps: readonly unknown[] = [],
44
+ options?: Omit<UseSyncQueryOptions, 'tables'>,
45
+ ): UseSyncQueryResult<Row> {
46
+ const client = useSyncClient();
47
+
48
+ // One Kysely instance per client identity — the dialect drives the same
49
+ // normalized `query` surface the other hooks use, so every host works.
50
+ const db = useMemo(() => createSyncularKysely<Database>(client), [client]);
51
+
52
+ // Compile on the deps the caller declared. The compiled query yields SQL +
53
+ // parameters (for execution) and its AST (for exact table extraction).
54
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `deps` is the caller-declared re-key, `build`/`db` are stable-by-design
55
+ const compiled: CompiledQuery<Row> = useMemo(
56
+ () => build(db).compile(),
57
+ [db, ...deps],
58
+ );
59
+
60
+ const tables = useMemo(() => extractTables(compiled), [compiled]);
61
+
62
+ return useSyncQuery<Row>(
63
+ compiled.sql,
64
+ compiled.parameters as readonly SqlValue[],
65
+ { ...options, tables },
66
+ );
67
+ }
@@ -0,0 +1,88 @@
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 (re-read on mount and whenever
12
+ * the base's table is invalidated — so a deferred eviction draining, or
13
+ * a re-entry bootstrapping, updates the verdict).
14
+ * - `isComplete(unit)` is the per-value verdict: a live query whose scope
15
+ * footprint includes a non-`isComplete` unit is a **window miss** — widen
16
+ * or show partial, never claim complete.
17
+ */
18
+ import type { WindowBase } from '@syncular/client';
19
+ import { useCallback, useEffect, useRef, useState } from 'react';
20
+ import { useSyncClient } from './use-client';
21
+
22
+ export interface UseWindowResult {
23
+ /** The scope values currently windowed-in for this base. */
24
+ readonly units: readonly string[];
25
+ /** Set the live units (widen/shrink diff, §4.8). */
26
+ readonly setWindow: (units: readonly string[]) => Promise<void>;
27
+ /** True iff `unit` is windowed-in (answerable in full locally, I3). */
28
+ readonly isComplete: (unit: string) => boolean;
29
+ }
30
+
31
+ export function useWindow(base: WindowBase): UseWindowResult {
32
+ const client = useSyncClient();
33
+ const [units, setUnits] = useState<readonly string[]>([]);
34
+
35
+ // A stable key so the effects re-run only when the base identity changes,
36
+ // not on every render's fresh object. The latest `base` is read via a ref
37
+ // inside the closures (the useSyncQuery pattern), so the dep list stays on
38
+ // primitive keys.
39
+ const baseKey = `${base.table} ${base.variable} ${JSON.stringify(
40
+ base.fixedScopes ?? {},
41
+ )} ${base.params ?? ''}`;
42
+ const baseRef = useRef(base);
43
+ baseRef.current = base;
44
+
45
+ // `baseKey` re-keys the effect without being read in the body (biome cannot
46
+ // see the ref indirection) — the dep list is pinned deliberately.
47
+ // biome-ignore lint/correctness/useExhaustiveDependencies: baseKey re-keys the effect for a fresh base object
48
+ useEffect(() => {
49
+ let cancelled = false;
50
+ const read = () => {
51
+ Promise.resolve(client.windowState(baseRef.current))
52
+ .then((state) => {
53
+ if (!cancelled) setUnits(state.units);
54
+ })
55
+ .catch(() => {
56
+ /* transient — the next invalidation re-reads */
57
+ });
58
+ };
59
+ read();
60
+ // Re-read when the base's table changes locally: a deferred eviction
61
+ // (E1) completing or a re-entry bootstrapping both invalidate it.
62
+ const unsubscribe = client.onInvalidate((event) => {
63
+ if (event.tables.has(baseRef.current.table)) read();
64
+ });
65
+ return () => {
66
+ cancelled = true;
67
+ unsubscribe();
68
+ };
69
+ }, [client, baseKey]);
70
+
71
+ const setWindow = useCallback(
72
+ (next: readonly string[]) => {
73
+ const result = Promise.resolve(client.setWindow(baseRef.current, next));
74
+ // Optimistically reflect the new set; the invalidation-driven re-read
75
+ // reconciles against the registry (e.g. a pinned unit lingering).
76
+ setUnits(next);
77
+ return result;
78
+ },
79
+ [client],
80
+ );
81
+
82
+ const isComplete = useCallback(
83
+ (unit: string) => units.includes(unit),
84
+ [units],
85
+ );
86
+
87
+ return { units, setWindow, isComplete };
88
+ }