@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,216 @@
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-sync-query} `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 function hashRow(row) {
35
+ return JSON.stringify(row, replacer);
36
+ }
37
+ function replacer(_key, value) {
38
+ if (value instanceof Uint8Array)
39
+ return { __u8: [...value] };
40
+ if (value !== null &&
41
+ typeof value === 'object' &&
42
+ !Array.isArray(value) &&
43
+ Object.getPrototypeOf(value) === Object.prototype) {
44
+ // Sort keys so column/field order never spuriously changes the hash.
45
+ const sorted = {};
46
+ for (const k of Object.keys(value).sort()) {
47
+ sorted[k] = value[k];
48
+ }
49
+ return sorted;
50
+ }
51
+ return value;
52
+ }
53
+ export function hashRows(rows) {
54
+ const hashes = new Array(rows.length);
55
+ for (let i = 0; i < rows.length; i++)
56
+ hashes[i] = hashRow(rows[i]);
57
+ return { rows, hashes };
58
+ }
59
+ /**
60
+ * Reconcile a freshly-queried result against the previous hashed result.
61
+ * Returns `next: undefined` when nothing changed at all; otherwise a new
62
+ * hashed result whose row objects are the PREVIOUS objects wherever content is
63
+ * unchanged (matched by index), so identity-keyed memo components skip.
64
+ */
65
+ export function reconcileRows(prev, fresh) {
66
+ const freshHashes = new Array(fresh.length);
67
+ for (let i = 0; i < fresh.length; i++)
68
+ freshHashes[i] = hashRow(fresh[i]);
69
+ if (prev !== undefined && prev.rows.length === fresh.length) {
70
+ let identical = true;
71
+ for (let i = 0; i < fresh.length; i++) {
72
+ if (prev.hashes[i] !== freshHashes[i]) {
73
+ identical = false;
74
+ break;
75
+ }
76
+ }
77
+ if (identical)
78
+ return { next: undefined };
79
+ }
80
+ // Changed: reuse the previous row object at each index whose hash matches,
81
+ // so unchanged rows keep their identity for memoized row components.
82
+ const rows = new Array(fresh.length);
83
+ for (let i = 0; i < fresh.length; i++) {
84
+ const reuse = prev !== undefined &&
85
+ i < prev.rows.length &&
86
+ prev.hashes[i] === freshHashes[i];
87
+ // `i < fresh.length` bounds this loop, so `fresh[i]` is defined; the reuse
88
+ // branch is additionally guarded by `i < prev.rows.length`.
89
+ rows[i] = reuse ? prev.rows[i] : fresh[i];
90
+ }
91
+ return { next: { rows, hashes: freshHashes } };
92
+ }
93
+ /**
94
+ * A per-query re-run scheduler that coalesces bursts of invalidation events
95
+ * into ONE run per paint. Multiple `schedule()` calls before the next flush
96
+ * run the callback once. A `schedule()` that arrives WHILE the callback is
97
+ * running (re-entrant, or an event during an async re-query) marks dirty and
98
+ * runs the callback exactly once more after — never lost, never concurrent.
99
+ *
100
+ * Timing source: `requestAnimationFrame` when the host has it (a real browser
101
+ * paints one frame; the coalescing window is a frame), else a microtask via a
102
+ * resolved promise (bun tests have no rAF — this keeps them deterministic and
103
+ * timer-free, honoring the no-timers doctrine: it's a readiness turn, not a
104
+ * wall-clock sleep). {@link flush} runs any pending callback synchronously for
105
+ * tests, so no arbitrary sleeps are needed to observe coalescing.
106
+ */
107
+ export class FrameScheduler {
108
+ #scheduled = false;
109
+ #running = false;
110
+ #dirty = false;
111
+ #callback;
112
+ constructor(callback) {
113
+ this.#callback = callback;
114
+ liveSchedulers.add(this);
115
+ }
116
+ /** Request a run. Coalesces until the next frame/microtask boundary. */
117
+ schedule() {
118
+ if (this.#running) {
119
+ // An event arrived during a run — remember it and re-run once after.
120
+ this.#dirty = true;
121
+ return;
122
+ }
123
+ if (this.#scheduled)
124
+ return;
125
+ this.#scheduled = true;
126
+ scheduleFrame(() => this.#fire());
127
+ }
128
+ #fire() {
129
+ this.#run();
130
+ }
131
+ /**
132
+ * Run the callback once, honoring the running/dirty contract: a `schedule()`
133
+ * during the run marks `#dirty`, and on completion we re-schedule exactly
134
+ * one more run — never lost, never concurrent. Returns the callback's result
135
+ * (a Promise for the async host path) so `flush` can hand it to a test.
136
+ */
137
+ #run() {
138
+ this.#scheduled = false;
139
+ if (this.#callback === undefined)
140
+ return;
141
+ this.#running = true;
142
+ this.#dirty = false;
143
+ const done = () => {
144
+ this.#running = false;
145
+ if (this.#dirty && this.#callback !== undefined) {
146
+ this.#dirty = false;
147
+ // An invalidation landed mid-run: re-run once more, coalesced.
148
+ this.schedule();
149
+ }
150
+ };
151
+ let result;
152
+ try {
153
+ result = this.#callback();
154
+ }
155
+ catch {
156
+ done();
157
+ return;
158
+ }
159
+ if (result && typeof result.then === 'function') {
160
+ return result.then(done, done);
161
+ }
162
+ done();
163
+ return;
164
+ }
165
+ /**
166
+ * Synchronously run any pending scheduled callback NOW (test determinism).
167
+ * Returns whatever the callback returned (a Promise for the async host path)
168
+ * so a test can await the re-query settling without a sleep. A no-op when
169
+ * nothing is pending.
170
+ */
171
+ flush() {
172
+ if (!this.#scheduled || this.#callback === undefined)
173
+ return;
174
+ return this.#run();
175
+ }
176
+ /** Drop the callback so a torn-down hook's pending frame is a no-op. */
177
+ dispose() {
178
+ this.#callback = undefined;
179
+ liveSchedulers.delete(this);
180
+ }
181
+ }
182
+ /**
183
+ * Live schedulers, weakly tracked for the test-only flush below. A Set (not
184
+ * WeakSet) so we can iterate; entries are removed on `dispose()`, so a mounted
185
+ * hook holds at most one entry and unmount clears it.
186
+ */
187
+ const liveSchedulers = new Set();
188
+ /**
189
+ * TEST-ONLY: synchronously flush every live scheduler's pending frame, so a
190
+ * test can observe the coalesced re-query without a wall-clock sleep (the
191
+ * no-timers doctrine — a readiness flush, injected, not a timer). Returns a
192
+ * Promise that settles when every flushed async re-query has settled.
193
+ */
194
+ export function flushQuerySchedulers() {
195
+ const pending = [];
196
+ for (const s of liveSchedulers) {
197
+ const r = s.flush();
198
+ if (r && typeof r.then === 'function') {
199
+ pending.push(r);
200
+ }
201
+ }
202
+ return Promise.all(pending).then(() => undefined);
203
+ }
204
+ const raf = typeof globalThis.requestAnimationFrame === 'function'
205
+ ? globalThis.requestAnimationFrame.bind(globalThis)
206
+ : undefined;
207
+ function scheduleFrame(cb) {
208
+ if (raf !== undefined) {
209
+ raf(cb);
210
+ return;
211
+ }
212
+ // No rAF (bun test / worker): a microtask is the deterministic, timer-free
213
+ // coalescing boundary. Everything queued in the current synchronous run
214
+ // (a burst of emits) has already called schedule() before this drains.
215
+ queueMicrotask(cb);
216
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@syncular/react/typed` — the Kysely-typed live-query hook, kept behind
3
+ * this subpath so the main barrel never imports Kysely. Requires the
4
+ * `@syncular/kysely` and `kysely` peer dependencies.
5
+ */
6
+ export { useTypedQuery } from './use-typed-query.js';
package/dist/typed.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@syncular/react/typed` — the Kysely-typed live-query hook, kept behind
3
+ * this subpath so the main barrel never imports Kysely. Requires the
4
+ * `@syncular/kysely` and `kysely` peer dependencies.
5
+ */
6
+ export { useTypedQuery } from './use-typed-query.js';
@@ -0,0 +1,3 @@
1
+ import type { NormalizedClient } from './client.js';
2
+ /** Read the normalized client from context; throws outside a `SyncProvider`. */
3
+ export declare function useSyncClient(): NormalizedClient;
@@ -0,0 +1,10 @@
1
+ import { useContext } from 'react';
2
+ import { SyncContext } from './provider.js';
3
+ /** Read the normalized client from context; throws outside a `SyncProvider`. */
4
+ export function useSyncClient() {
5
+ const client = useContext(SyncContext);
6
+ if (client === undefined) {
7
+ throw new Error('@syncular/react: no client in context — wrap your tree in <SyncProvider client={…}>');
8
+ }
9
+ return client;
10
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `useConflicts` — the accumulated conflict records (§6.2/§6.5) and
3
+ * rejections (§6.3) the client has surfaced. Re-read after every apply
4
+ * batch (a push result lands through the same choke point) plus on mount.
5
+ */
6
+ import type { ConflictRecord, RejectionRecord } from '@syncular/client';
7
+ export interface UseConflictsResult {
8
+ readonly conflicts: readonly ConflictRecord[];
9
+ readonly rejections: readonly RejectionRecord[];
10
+ readonly refresh: () => void;
11
+ }
12
+ export declare function useConflicts(): UseConflictsResult;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `useConflicts` — the accumulated conflict records (§6.2/§6.5) and
3
+ * rejections (§6.3) the client has surfaced. Re-read after every apply
4
+ * batch (a push result lands through the same choke point) plus on mount.
5
+ */
6
+ import { useCallback, useEffect, useRef, useState } from 'react';
7
+ import { useSyncClient } from './use-client.js';
8
+ export function useConflicts() {
9
+ const client = useSyncClient();
10
+ const [conflicts, setConflicts] = useState([]);
11
+ const [rejections, setRejections] = useState([]);
12
+ const readRef = useRef(() => { });
13
+ const refresh = useCallback(() => readRef.current(), []);
14
+ useEffect(() => {
15
+ let cancelled = false;
16
+ const read = () => {
17
+ Promise.all([client.conflicts(), client.rejections()])
18
+ .then(([c, r]) => {
19
+ if (cancelled)
20
+ return;
21
+ setConflicts(c);
22
+ setRejections(r);
23
+ })
24
+ .catch(() => {
25
+ /* transient read failure — the next batch re-reads */
26
+ });
27
+ };
28
+ readRef.current = read;
29
+ read();
30
+ const unsubscribe = client.onInvalidate(read);
31
+ return () => {
32
+ cancelled = true;
33
+ unsubscribe();
34
+ };
35
+ }, [client]);
36
+ return { conflicts, rejections, refresh };
37
+ }
@@ -0,0 +1,18 @@
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 `useSyncQuery` 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 type { MutationInput } from '@syncular/client';
12
+ export interface UseMutationResult {
13
+ /** Submit mutations; resolves to the clientCommitId. */
14
+ mutate: (mutations: readonly MutationInput[]) => Promise<string>;
15
+ readonly isPending: boolean;
16
+ readonly error: Error | undefined;
17
+ }
18
+ export declare function useMutation(): UseMutationResult;
@@ -0,0 +1,34 @@
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 `useSyncQuery` 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';
12
+ import { useSyncClient } from './use-client.js';
13
+ export function useMutation() {
14
+ const client = useSyncClient();
15
+ const [isPending, setIsPending] = useState(false);
16
+ const [error, setError] = useState(undefined);
17
+ const mutate = useCallback(async (mutations) => {
18
+ setIsPending(true);
19
+ setError(undefined);
20
+ try {
21
+ const id = await client.mutate(mutations);
22
+ return id;
23
+ }
24
+ catch (err) {
25
+ const wrapped = err instanceof Error ? err : new Error(String(err));
26
+ setError(wrapped);
27
+ throw wrapped;
28
+ }
29
+ finally {
30
+ setIsPending(false);
31
+ }
32
+ }, [client]);
33
+ return { mutate, isPending, error };
34
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `useNamedQuery` — the live-query hook for the generated NAMED-query tier
3
+ * (typegen's sqlc/SQLDelight rung). You author a `.sql` file; typegen emits a
4
+ * typed `NamedQuery` descriptor (`{ sql, tables, bind }`) + its `Row` type.
5
+ * This hook runs that descriptor live and reuses {@link useSyncQuery}'s
6
+ * invalidation machinery verbatim — the descriptor's `tables` set is the EXACT
7
+ * dependency set (typegen resolved it from the query's FROM/JOIN against the
8
+ * schema IR), so invalidation is precise with zero SQL-text heuristic and the
9
+ * row type is the query's own projection.
10
+ *
11
+ * ```ts
12
+ * import { listProjectTasksQuery } from './syncular.queries.js';
13
+ * const { rows } = useNamedQuery(listProjectTasksQuery, { projectId });
14
+ * // ^ ListProjectTasksRow[]
15
+ * ```
16
+ *
17
+ * A param-less query takes no second argument. The descriptor is import-free
18
+ * (typegen emits its own `NamedQuery` type), so this hook depends only on the
19
+ * descriptor's structural shape — no generated-file import coupling.
20
+ */
21
+ import type { SqlValue } from '@syncular/client';
22
+ import { type UseSyncQueryOptions, type UseSyncQueryResult } from './use-sync-query.js';
23
+ /** The structural shape typegen's `NamedQuery<Row, Params>` satisfies. */
24
+ export interface NamedQueryDescriptor<Row, Params> {
25
+ readonly sql: string;
26
+ readonly tables: readonly string[];
27
+ readonly bind: (params: Params) => readonly SqlValue[];
28
+ /** Phantom row carrier (never read at runtime). */
29
+ readonly __row?: Row;
30
+ }
31
+ /** Run a param-less named query live. */
32
+ export declare function useNamedQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?: Omit<UseSyncQueryOptions, 'tables'>): UseSyncQueryResult<Row>;
33
+ /** Run a named query live with its typed params. */
34
+ export declare function useNamedQuery<Row, Params>(query: NamedQueryDescriptor<Row, Params>, params: Params, options?: Omit<UseSyncQueryOptions, 'tables'>): UseSyncQueryResult<Row>;
@@ -0,0 +1,13 @@
1
+ import { useSyncQuery, } from './use-sync-query.js';
2
+ export function useNamedQuery(query, paramsOrOptions, maybeOptions) {
3
+ // Overload disambiguation: a param-less query's second arg (if any) is the
4
+ // options object; a parameterized query's second arg is the params.
5
+ const hasParams = query.bind.length > 0;
6
+ const params = (hasParams ? paramsOrOptions : undefined);
7
+ const options = (hasParams ? maybeOptions : paramsOrOptions);
8
+ const bound = query.bind(params);
9
+ return useSyncQuery(query.sql, bound, {
10
+ ...options,
11
+ tables: query.tables,
12
+ });
13
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `usePresence(scopeKey)` — the ephemeral peers present on a §8.6 scope key
3
+ * (join/update/leave). Reads the current peer list on mount and re-reads
4
+ * whenever presence on THIS key changes, via the client's `onPresence`
5
+ * subscription (the subscribable twin of the config callback). Presence is
6
+ * lost on disconnect (the server emits leave), so the list reflects only
7
+ * what the live socket has delivered.
8
+ */
9
+ import type { PresencePeer } from '@syncular/client';
10
+ export declare function usePresence(scopeKey: string): readonly PresencePeer[];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `usePresence(scopeKey)` — the ephemeral peers present on a §8.6 scope key
3
+ * (join/update/leave). Reads the current peer list on mount and re-reads
4
+ * whenever presence on THIS key changes, via the client's `onPresence`
5
+ * subscription (the subscribable twin of the config callback). Presence is
6
+ * lost on disconnect (the server emits leave), so the list reflects only
7
+ * what the live socket has delivered.
8
+ */
9
+ import { useEffect, useState } from 'react';
10
+ import { useSyncClient } from './use-client.js';
11
+ export function usePresence(scopeKey) {
12
+ const client = useSyncClient();
13
+ const [peers, setPeers] = useState([]);
14
+ useEffect(() => {
15
+ let cancelled = false;
16
+ const read = () => {
17
+ Promise.resolve(client.presence(scopeKey))
18
+ .then((list) => {
19
+ if (!cancelled)
20
+ setPeers(list);
21
+ })
22
+ .catch(() => {
23
+ /* transient — the next presence event re-reads */
24
+ });
25
+ };
26
+ read();
27
+ const unsubscribe = client.onPresence((changedKey) => {
28
+ if (changedKey === scopeKey)
29
+ read();
30
+ });
31
+ return () => {
32
+ cancelled = true;
33
+ unsubscribe();
34
+ };
35
+ }, [client, scopeKey]);
36
+ return peers;
37
+ }
@@ -0,0 +1,39 @@
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
+ import type { SqlRow, SqlValue } from '@syncular/client';
16
+ export interface UseSyncQueryOptions {
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
+ */
21
+ 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
+ */
28
+ readonly scopeKeys?: readonly string[];
29
+ /** Skip running the query (e.g. while inputs are not ready). */
30
+ readonly enabled?: boolean;
31
+ }
32
+ export interface UseSyncQueryResult<Row> {
33
+ readonly rows: readonly Row[];
34
+ readonly isLoading: boolean;
35
+ readonly error: Error | undefined;
36
+ /** Force a re-run (identity-stable). */
37
+ readonly refresh: () => void;
38
+ }
39
+ export declare function useSyncQuery<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseSyncQueryOptions): UseSyncQueryResult<Row>;
@@ -0,0 +1,179 @@
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
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+ 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
+ }
50
+ export function useSyncQuery(sql, params, options) {
51
+ const client = useSyncClient();
52
+ 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
+ }
139
+ };
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.
161
+ // biome-ignore lint/correctness/useExhaustiveDependencies: key/scopeKeysKey re-key the subscription intentionally
162
+ useEffect(() => {
163
+ if (!enabled)
164
+ return;
165
+ const scheduler = schedulerRef.current;
166
+ const unsubscribe = client.onInvalidate((event) => {
167
+ if (eventMatches(event, depTablesRef.current, scopeKeysRef.current)) {
168
+ scheduler?.schedule();
169
+ }
170
+ });
171
+ return unsubscribe;
172
+ }, [client, enabled, key, scopeKeysKey]);
173
+ // Dispose the scheduler when the hook unmounts (drop any pending frame).
174
+ useEffect(() => {
175
+ const scheduler = schedulerRef.current;
176
+ return () => scheduler?.dispose();
177
+ }, []);
178
+ return { rows, isLoading, error, refresh };
179
+ }