@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,248 @@
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
+ /**
28
+ * A stable content hash for one row: JSON with keys sorted, so two rows with
29
+ * the same columns in a different order hash equal (SQLite projection order is
30
+ * stable per query, but sorting removes any dependence on it and is cheap for
31
+ * the narrow rows a row-component renders). Uint8Array values (rare in a
32
+ * projection) serialize by byte view so a fresh copy of equal bytes hashes
33
+ * equal rather than to `{}`.
34
+ */
35
+ export function hashRow(row: unknown): string {
36
+ return JSON.stringify(row, replacer);
37
+ }
38
+
39
+ function replacer(_key: string, value: unknown): unknown {
40
+ if (value instanceof Uint8Array) return { __u8: [...value] };
41
+ if (
42
+ value !== null &&
43
+ typeof value === 'object' &&
44
+ !Array.isArray(value) &&
45
+ Object.getPrototypeOf(value) === Object.prototype
46
+ ) {
47
+ // Sort keys so column/field order never spuriously changes the hash.
48
+ const sorted: Record<string, unknown> = {};
49
+ for (const k of Object.keys(value as Record<string, unknown>).sort()) {
50
+ sorted[k] = (value as Record<string, unknown>)[k];
51
+ }
52
+ return sorted;
53
+ }
54
+ return value;
55
+ }
56
+
57
+ /** The precomputed hash carrier for the previous result, so we hash once. */
58
+ export interface HashedRows<Row> {
59
+ readonly rows: readonly Row[];
60
+ readonly hashes: readonly string[];
61
+ }
62
+
63
+ export function hashRows<Row>(rows: readonly Row[]): HashedRows<Row> {
64
+ const hashes = new Array<string>(rows.length);
65
+ for (let i = 0; i < rows.length; i++) hashes[i] = hashRow(rows[i]);
66
+ return { rows, hashes };
67
+ }
68
+
69
+ export interface ReconcileResult<Row> {
70
+ /**
71
+ * `undefined` → the whole result is unchanged; the caller MUST NOT call
72
+ * setRows (zero re-render, lever 1a). Otherwise the reconciled array to set,
73
+ * with previous row objects reused wherever a row's content was unchanged
74
+ * (lever 1b).
75
+ */
76
+ readonly next: HashedRows<Row> | undefined;
77
+ }
78
+
79
+ /**
80
+ * Reconcile a freshly-queried result against the previous hashed result.
81
+ * Returns `next: undefined` when nothing changed at all; otherwise a new
82
+ * hashed result whose row objects are the PREVIOUS objects wherever content is
83
+ * unchanged (matched by index), so identity-keyed memo components skip.
84
+ */
85
+ export function reconcileRows<Row>(
86
+ prev: HashedRows<Row> | undefined,
87
+ fresh: readonly Row[],
88
+ ): ReconcileResult<Row> {
89
+ const freshHashes = new Array<string>(fresh.length);
90
+ for (let i = 0; i < fresh.length; i++) freshHashes[i] = hashRow(fresh[i]);
91
+
92
+ if (prev !== undefined && prev.rows.length === fresh.length) {
93
+ let identical = true;
94
+ for (let i = 0; i < fresh.length; i++) {
95
+ if (prev.hashes[i] !== freshHashes[i]) {
96
+ identical = false;
97
+ break;
98
+ }
99
+ }
100
+ if (identical) return { next: undefined };
101
+ }
102
+
103
+ // Changed: reuse the previous row object at each index whose hash matches,
104
+ // so unchanged rows keep their identity for memoized row components.
105
+ const rows = new Array<Row>(fresh.length);
106
+ for (let i = 0; i < fresh.length; i++) {
107
+ const reuse =
108
+ prev !== undefined &&
109
+ i < prev.rows.length &&
110
+ prev.hashes[i] === freshHashes[i];
111
+ // `i < fresh.length` bounds this loop, so `fresh[i]` is defined; the reuse
112
+ // branch is additionally guarded by `i < prev.rows.length`.
113
+ rows[i] = reuse ? (prev.rows[i] as Row) : (fresh[i] as Row);
114
+ }
115
+ return { next: { rows, hashes: freshHashes } };
116
+ }
117
+
118
+ /**
119
+ * A per-query re-run scheduler that coalesces bursts of invalidation events
120
+ * into ONE run per paint. Multiple `schedule()` calls before the next flush
121
+ * run the callback once. A `schedule()` that arrives WHILE the callback is
122
+ * running (re-entrant, or an event during an async re-query) marks dirty and
123
+ * runs the callback exactly once more after — never lost, never concurrent.
124
+ *
125
+ * Timing source: `requestAnimationFrame` when the host has it (a real browser
126
+ * paints one frame; the coalescing window is a frame), else a microtask via a
127
+ * resolved promise (bun tests have no rAF — this keeps them deterministic and
128
+ * timer-free, honoring the no-timers doctrine: it's a readiness turn, not a
129
+ * wall-clock sleep). {@link flush} runs any pending callback synchronously for
130
+ * tests, so no arbitrary sleeps are needed to observe coalescing.
131
+ */
132
+ export class FrameScheduler {
133
+ #scheduled = false;
134
+ #running = false;
135
+ #dirty = false;
136
+ #callback: (() => void | Promise<void>) | undefined;
137
+
138
+ constructor(callback: () => void | Promise<void>) {
139
+ this.#callback = callback;
140
+ liveSchedulers.add(this);
141
+ }
142
+
143
+ /** Request a run. Coalesces until the next frame/microtask boundary. */
144
+ schedule(): void {
145
+ if (this.#running) {
146
+ // An event arrived during a run — remember it and re-run once after.
147
+ this.#dirty = true;
148
+ return;
149
+ }
150
+ if (this.#scheduled) return;
151
+ this.#scheduled = true;
152
+ scheduleFrame(() => this.#fire());
153
+ }
154
+
155
+ #fire(): void {
156
+ this.#run();
157
+ }
158
+
159
+ /**
160
+ * Run the callback once, honoring the running/dirty contract: a `schedule()`
161
+ * during the run marks `#dirty`, and on completion we re-schedule exactly
162
+ * one more run — never lost, never concurrent. Returns the callback's result
163
+ * (a Promise for the async host path) so `flush` can hand it to a test.
164
+ */
165
+ #run(): void | Promise<void> {
166
+ this.#scheduled = false;
167
+ if (this.#callback === undefined) return;
168
+ this.#running = true;
169
+ this.#dirty = false;
170
+ const done = () => {
171
+ this.#running = false;
172
+ if (this.#dirty && this.#callback !== undefined) {
173
+ this.#dirty = false;
174
+ // An invalidation landed mid-run: re-run once more, coalesced.
175
+ this.schedule();
176
+ }
177
+ };
178
+ let result: void | Promise<void>;
179
+ try {
180
+ result = this.#callback();
181
+ } catch {
182
+ done();
183
+ return;
184
+ }
185
+ if (result && typeof (result as Promise<void>).then === 'function') {
186
+ return (result as Promise<void>).then(done, done);
187
+ }
188
+ done();
189
+ return;
190
+ }
191
+
192
+ /**
193
+ * Synchronously run any pending scheduled callback NOW (test determinism).
194
+ * Returns whatever the callback returned (a Promise for the async host path)
195
+ * so a test can await the re-query settling without a sleep. A no-op when
196
+ * nothing is pending.
197
+ */
198
+ flush(): void | Promise<void> {
199
+ if (!this.#scheduled || this.#callback === undefined) return;
200
+ return this.#run();
201
+ }
202
+
203
+ /** Drop the callback so a torn-down hook's pending frame is a no-op. */
204
+ dispose(): void {
205
+ this.#callback = undefined;
206
+ liveSchedulers.delete(this);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Live schedulers, weakly tracked for the test-only flush below. A Set (not
212
+ * WeakSet) so we can iterate; entries are removed on `dispose()`, so a mounted
213
+ * hook holds at most one entry and unmount clears it.
214
+ */
215
+ const liveSchedulers = new Set<FrameScheduler>();
216
+
217
+ /**
218
+ * TEST-ONLY: synchronously flush every live scheduler's pending frame, so a
219
+ * test can observe the coalesced re-query without a wall-clock sleep (the
220
+ * no-timers doctrine — a readiness flush, injected, not a timer). Returns a
221
+ * Promise that settles when every flushed async re-query has settled.
222
+ */
223
+ export function flushQuerySchedulers(): Promise<void> {
224
+ const pending: Array<Promise<void>> = [];
225
+ for (const s of liveSchedulers) {
226
+ const r = s.flush();
227
+ if (r && typeof (r as Promise<void>).then === 'function') {
228
+ pending.push(r as Promise<void>);
229
+ }
230
+ }
231
+ return Promise.all(pending).then(() => undefined);
232
+ }
233
+
234
+ const raf: ((cb: () => void) => unknown) | undefined =
235
+ typeof globalThis.requestAnimationFrame === 'function'
236
+ ? globalThis.requestAnimationFrame.bind(globalThis)
237
+ : undefined;
238
+
239
+ function scheduleFrame(cb: () => void): void {
240
+ if (raf !== undefined) {
241
+ raf(cb);
242
+ return;
243
+ }
244
+ // No rAF (bun test / worker): a microtask is the deterministic, timer-free
245
+ // coalescing boundary. Everything queued in the current synchronous run
246
+ // (a burst of emits) has already called schedule() before this drains.
247
+ queueMicrotask(cb);
248
+ }
package/src/typed.ts 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';
@@ -0,0 +1,14 @@
1
+ import { useContext } from 'react';
2
+ import type { NormalizedClient } from './client';
3
+ import { SyncContext } from './provider';
4
+
5
+ /** Read the normalized client from context; throws outside a `SyncProvider`. */
6
+ export function useSyncClient(): NormalizedClient {
7
+ const client = useContext(SyncContext);
8
+ if (client === undefined) {
9
+ throw new Error(
10
+ '@syncular/react: no client in context — wrap your tree in <SyncProvider client={…}>',
11
+ );
12
+ }
13
+ return client;
14
+ }
@@ -0,0 +1,47 @@
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
+
7
+ import type { ConflictRecord, RejectionRecord } from '@syncular/client';
8
+ import { useCallback, useEffect, useRef, useState } from 'react';
9
+ import { useSyncClient } from './use-client';
10
+
11
+ export interface UseConflictsResult {
12
+ readonly conflicts: readonly ConflictRecord[];
13
+ readonly rejections: readonly RejectionRecord[];
14
+ readonly refresh: () => void;
15
+ }
16
+
17
+ export function useConflicts(): UseConflictsResult {
18
+ const client = useSyncClient();
19
+ const [conflicts, setConflicts] = useState<readonly ConflictRecord[]>([]);
20
+ const [rejections, setRejections] = useState<readonly RejectionRecord[]>([]);
21
+ const readRef = useRef<() => void>(() => {});
22
+ const refresh = useCallback(() => readRef.current(), []);
23
+
24
+ useEffect(() => {
25
+ let cancelled = false;
26
+ const read = () => {
27
+ Promise.all([client.conflicts(), client.rejections()])
28
+ .then(([c, r]) => {
29
+ if (cancelled) return;
30
+ setConflicts(c);
31
+ setRejections(r);
32
+ })
33
+ .catch(() => {
34
+ /* transient read failure — the next batch re-reads */
35
+ });
36
+ };
37
+ readRef.current = read;
38
+ read();
39
+ const unsubscribe = client.onInvalidate(read);
40
+ return () => {
41
+ cancelled = true;
42
+ unsubscribe();
43
+ };
44
+ }, [client]);
45
+
46
+ return { conflicts, rejections, refresh };
47
+ }
@@ -0,0 +1,47 @@
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
+
12
+ import type { MutationInput } from '@syncular/client';
13
+ import { useCallback, useState } from 'react';
14
+ import { useSyncClient } from './use-client';
15
+
16
+ export interface UseMutationResult {
17
+ /** Submit mutations; resolves to the clientCommitId. */
18
+ mutate: (mutations: readonly MutationInput[]) => Promise<string>;
19
+ readonly isPending: boolean;
20
+ readonly error: Error | undefined;
21
+ }
22
+
23
+ export function useMutation(): UseMutationResult {
24
+ const client = useSyncClient();
25
+ const [isPending, setIsPending] = useState(false);
26
+ const [error, setError] = useState<Error | undefined>(undefined);
27
+
28
+ const mutate = useCallback(
29
+ async (mutations: readonly MutationInput[]): Promise<string> => {
30
+ setIsPending(true);
31
+ setError(undefined);
32
+ try {
33
+ const id = await client.mutate(mutations);
34
+ return id;
35
+ } catch (err) {
36
+ const wrapped = err instanceof Error ? err : new Error(String(err));
37
+ setError(wrapped);
38
+ throw wrapped;
39
+ } finally {
40
+ setIsPending(false);
41
+ }
42
+ },
43
+ [client],
44
+ );
45
+
46
+ return { mutate, isPending, error };
47
+ }
@@ -0,0 +1,66 @@
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';
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 {
23
+ type UseSyncQueryOptions,
24
+ type UseSyncQueryResult,
25
+ useSyncQuery,
26
+ } from './use-sync-query';
27
+
28
+ /** The structural shape typegen's `NamedQuery<Row, Params>` satisfies. */
29
+ export interface NamedQueryDescriptor<Row, Params> {
30
+ readonly sql: string;
31
+ readonly tables: readonly string[];
32
+ readonly bind: (params: Params) => readonly SqlValue[];
33
+ /** Phantom row carrier (never read at runtime). */
34
+ readonly __row?: Row;
35
+ }
36
+
37
+ /** Run a param-less named query live. */
38
+ export function useNamedQuery<Row>(
39
+ query: NamedQueryDescriptor<Row, undefined>,
40
+ options?: Omit<UseSyncQueryOptions, 'tables'>,
41
+ ): UseSyncQueryResult<Row>;
42
+ /** Run a named query live with its typed params. */
43
+ export function useNamedQuery<Row, Params>(
44
+ query: NamedQueryDescriptor<Row, Params>,
45
+ params: Params,
46
+ options?: Omit<UseSyncQueryOptions, 'tables'>,
47
+ ): UseSyncQueryResult<Row>;
48
+ export function useNamedQuery<Row, Params>(
49
+ query: NamedQueryDescriptor<Row, Params>,
50
+ paramsOrOptions?: Params | Omit<UseSyncQueryOptions, 'tables'>,
51
+ maybeOptions?: Omit<UseSyncQueryOptions, 'tables'>,
52
+ ): UseSyncQueryResult<Row> {
53
+ // Overload disambiguation: a param-less query's second arg (if any) is the
54
+ // options object; a parameterized query's second arg is the params.
55
+ const hasParams = query.bind.length > 0;
56
+ const params = (hasParams ? paramsOrOptions : undefined) as Params;
57
+ const options = (hasParams ? maybeOptions : paramsOrOptions) as
58
+ | Omit<UseSyncQueryOptions, 'tables'>
59
+ | undefined;
60
+
61
+ const bound = query.bind(params) as readonly SqlValue[];
62
+ return useSyncQuery<Row>(query.sql, bound, {
63
+ ...options,
64
+ tables: query.tables,
65
+ });
66
+ }
@@ -0,0 +1,40 @@
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
+
10
+ import type { PresencePeer } from '@syncular/client';
11
+ import { useEffect, useState } from 'react';
12
+ import { useSyncClient } from './use-client';
13
+
14
+ export function usePresence(scopeKey: string): readonly PresencePeer[] {
15
+ const client = useSyncClient();
16
+ const [peers, setPeers] = useState<readonly PresencePeer[]>([]);
17
+
18
+ useEffect(() => {
19
+ let cancelled = false;
20
+ const read = () => {
21
+ Promise.resolve(client.presence(scopeKey))
22
+ .then((list) => {
23
+ if (!cancelled) setPeers(list);
24
+ })
25
+ .catch(() => {
26
+ /* transient — the next presence event re-reads */
27
+ });
28
+ };
29
+ read();
30
+ const unsubscribe = client.onPresence((changedKey) => {
31
+ if (changedKey === scopeKey) read();
32
+ });
33
+ return () => {
34
+ cancelled = true;
35
+ unsubscribe();
36
+ };
37
+ }, [client, scopeKey]);
38
+
39
+ return peers;
40
+ }