@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.
- package/README.md +182 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.js +31 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +18 -0
- package/dist/infer-tables.d.ts +20 -0
- package/dist/infer-tables.js +33 -0
- package/dist/provider.d.ts +16 -0
- package/dist/provider.js +15 -0
- package/dist/query-churn.d.ts +92 -0
- package/dist/query-churn.js +216 -0
- package/dist/typed.d.ts +6 -0
- package/dist/typed.js +6 -0
- package/dist/use-client.d.ts +3 -0
- package/dist/use-client.js +10 -0
- package/dist/use-conflicts.d.ts +12 -0
- package/dist/use-conflicts.js +37 -0
- package/dist/use-mutation.d.ts +18 -0
- package/dist/use-mutation.js +34 -0
- package/dist/use-named-query.d.ts +34 -0
- package/dist/use-named-query.js +13 -0
- package/dist/use-presence.d.ts +10 -0
- package/dist/use-presence.js +37 -0
- package/dist/use-sync-query.d.ts +39 -0
- package/dist/use-sync-query.js +179 -0
- package/dist/use-sync-status.d.ts +29 -0
- package/dist/use-sync-status.js +67 -0
- package/dist/use-typed-query.d.ts +32 -0
- package/dist/use-typed-query.js +44 -0
- package/dist/use-window.d.ts +27 -0
- package/dist/use-window.js +49 -0
- package/package.json +85 -0
- package/src/client.ts +130 -0
- package/src/index.ts +38 -0
- package/src/infer-tables.ts +35 -0
- package/src/provider.ts +36 -0
- package/src/query-churn.ts +248 -0
- package/src/typed.ts +6 -0
- package/src/use-client.ts +14 -0
- package/src/use-conflicts.ts +47 -0
- package/src/use-mutation.ts +47 -0
- package/src/use-named-query.ts +66 -0
- package/src/use-presence.ts +40 -0
- package/src/use-sync-query.ts +223 -0
- package/src/use-sync-status.ts +87 -0
- package/src/use-typed-query.ts +67 -0
- 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,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
|
+
}
|