@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,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
|
+
}
|