@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
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # @syncular/react
2
+
3
+ React bindings for the syncular v2 client, with **fine-grained live
4
+ queries** designed in from day one (TODO 3.1 / `DESIGN-eviction.md` I1–I4).
5
+ A `useSyncQuery` re-runs **only** when a table it depends on is touched by an
6
+ apply batch — never "re-run everything on any change".
7
+
8
+ Works against **both** client cores through one interface:
9
+
10
+ - `SyncClient` — the direct core (constructed on the current thread), and
11
+ - `SyncClientHandle` — the worker-mode proxy (the whole core in an OPFS
12
+ worker).
13
+
14
+ Their public surfaces diverge (getters vs methods, sync vs promise); the
15
+ bindings normalize both, so a component never cares which it holds.
16
+
17
+ React 18+ is a **peer dependency**. There are no other runtime dependencies.
18
+
19
+ ## Quick start
20
+
21
+ ```tsx
22
+ import { SyncProvider, useSyncQuery, useMutation } from '@syncular/react';
23
+
24
+ // `client` is a SyncClient or a SyncClientHandle you already started.
25
+ function App({ client }) {
26
+ return (
27
+ <SyncProvider client={client}>
28
+ <Tasks />
29
+ </SyncProvider>
30
+ );
31
+ }
32
+
33
+ function Tasks() {
34
+ const { rows, isLoading, error, refresh } = useSyncQuery(
35
+ 'SELECT id, title, done FROM tasks ORDER BY id',
36
+ );
37
+ const { mutate } = useMutation();
38
+
39
+ if (isLoading) return <p>Loading…</p>;
40
+ if (error) return <p>Query failed: {error.message}</p>;
41
+
42
+ return (
43
+ <ul>
44
+ {rows.map((r) => (
45
+ <li key={r.id}>{r.title}</li>
46
+ ))}
47
+ <button
48
+ onClick={() =>
49
+ mutate([
50
+ {
51
+ table: 'tasks',
52
+ op: 'upsert',
53
+ values: { id: crypto.randomUUID(), project_id: 'p1', title: 'new', done: false },
54
+ },
55
+ ])
56
+ }
57
+ >
58
+ Add
59
+ </button>
60
+ </ul>
61
+ );
62
+ }
63
+ ```
64
+
65
+ The mutate applies optimistically (§7.1) and fires the invalidation batch, so
66
+ the list updates immediately — no manual refetch.
67
+
68
+ ## The invalidation granularity truth
69
+
70
+ This is the honest granularity the wire actually provides — read it before
71
+ relying on scope-key narrowing.
72
+
73
+ The web-client emits **exactly one** `{ tables, scopeKeys }` invalidation event
74
+ per apply batch (a pull/delta round, a local `mutate`, a purge, or a
75
+ schema-bump reset — the ONE choke point). Never one event per row.
76
+
77
+ - **`tables`** — the set of tables whose local rows changed this batch. This
78
+ is the **reliable floor**: it is always present and always correct.
79
+ - **`scopeKeys`** — `prefix:value` scope keys (§3.1), present **where the
80
+ source carried them**:
81
+ - `COMMIT` frames carry per-row stored scopes (§4.5), so commit-driven
82
+ invalidation carries **precise** scope keys.
83
+ - **Segments carry no per-row scope keys** — only a table + a scope digest.
84
+ A segment (bootstrap / re-bootstrap) invalidation therefore carries the
85
+ table plus the **subscription's effective scope keys**, the coarsest
86
+ honest key for bulk data. It never fabricates per-row keys the wire did
87
+ not deliver.
88
+ - Purge / reset / optimistic writes are keyed by table (and by effective
89
+ scope keys where a scope map is in hand).
90
+
91
+ **Consequence for `useSyncQuery`:** by default a query re-runs whenever a
92
+ depended-on **table** is touched. You may narrow further with `scopeKeys`
93
+ (below), but a **table-level** event (a segment bootstrap, a reset — one that
94
+ carries no scope keys) **always** re-runs a matching query, because it carries
95
+ no key to discriminate on. This is deliberate: under-running is a stale query,
96
+ the one thing a live-query layer must never do.
97
+
98
+ ## `useSyncQuery(sql, params?, options?)`
99
+
100
+ Runs a local SQL query and keeps it live.
101
+
102
+ Returns `{ rows, isLoading, error, refresh }`.
103
+
104
+ ### Dependency tables — inference and the escape hatch
105
+
106
+ By default the hook infers its dependency tables with a **conservative scan**
107
+ of the SQL text (the identifiers after `FROM`/`JOIN`). This is a heuristic,
108
+ **not** a SQL parser — it is intentionally over-inclusive at the edges (an
109
+ extra harmless re-run) rather than under-inclusive (a stale query).
110
+
111
+ When the text cannot be read (dynamic SQL, views, unusual syntax), pass the
112
+ explicit **`tables`** option — it always wins:
113
+
114
+ ```tsx
115
+ useSyncQuery(buildDynamicSql(), params, { tables: ['tasks', 'projects'] });
116
+ ```
117
+
118
+ ### Options
119
+
120
+ | Option | Meaning |
121
+ | ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
122
+ | `tables` | Explicit dependency tables. Overrides the SQL-text inference (the escape hatch). |
123
+ | `scopeKeys` | Narrow re-runs to specific `prefix:value` keys. A dependency-table event still re-runs if it carries **no** scope keys (see above). |
124
+ | `enabled` | Skip running while `false` (e.g. inputs not ready). |
125
+
126
+ ## `useTypedQuery(build, deps?, options?)` — the typed twin
127
+
128
+ Behind the `@syncular/react/typed` subpath (needs the `@syncular/kysely`
129
+ + `kysely` peers). You write a [Kysely](https://kysely.dev) builder typed by
130
+ your generated `Database` interface; the hook compiles it, runs it live, and
131
+ extracts the `{tables}` dependency set from the compiled query's **AST** — so
132
+ invalidation is *exact*, never a text heuristic. It reuses `useSyncQuery`'s
133
+ machinery verbatim.
134
+
135
+ ```tsx
136
+ import { useTypedQuery } from '@syncular/react/typed';
137
+ import type { Database, TodosRow } from './syncular.generated';
138
+
139
+ function TodoList({ listId }: { listId: string }) {
140
+ const { rows } = useTypedQuery<Database, Pick<TodosRow, 'id' | 'title'>>(
141
+ (db) =>
142
+ db.selectFrom('todos').select(['id', 'title']).where('list_id', '=', listId),
143
+ [listId], // re-key the builder like a useEffect dep array
144
+ );
145
+ return <ul>{rows.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
146
+ }
147
+ ```
148
+
149
+ Read-only, like the dialect: a write builder throws — use `useMutation` for
150
+ writes (they must go through the outbox, SPEC §7.1). Works on every host the
151
+ other hooks do (direct, worker, follower, Tauri, RN) — it drives the same
152
+ normalized `query` surface.
153
+
154
+ ## Other hooks
155
+
156
+ - **`useSyncStatus()`** → `{ outbox, upgrading, leaseState, schemaFloor,
157
+ syncNeeded, isLoading, refresh }`. Re-reads after every apply batch.
158
+ (`online` is not a value the core exposes — §1.3 transport-owned — so it is
159
+ not reported rather than guessed.)
160
+ - **`useConflicts()`** → `{ conflicts, rejections, refresh }` (§6.2/§6.3).
161
+ - **`usePresence(scopeKey)`** → the ephemeral peers present on a §8.6 scope
162
+ key; updates on join/update/leave. Empty (and never crashes) without a
163
+ connected realtime socket.
164
+ - **`useMutation()`** → `{ mutate, isPending, error }`. `mutate(mutations)`
165
+ resolves to the `clientCommitId`; the optimistic overlay is applied
166
+ immediately, and dependent `useSyncQuery`s re-run on the resulting batch.
167
+
168
+ ## SSR
169
+
170
+ The hooks are SSR-safe: on the server they render their initial state and the
171
+ query fires only in the client-side mount effect. `renderToString` never
172
+ crashes.
173
+
174
+ ## Design note — the window registry (forward-looking)
175
+
176
+ Per `DESIGN-eviction.md` I3, query bindings must be able to route a query's
177
+ scope footprint through the window registry once windowed sync (TODO §5
178
+ item 2) lands, so a query can report **completeness** (answerable from the
179
+ local replica vs a window miss). Today the registry trivially contains
180
+ "everything subscribed", so `useSyncQuery` always answers from the local
181
+ replica. The `scopeKeys` option is the seam through which per-scope
182
+ completeness will be surfaced without an API break.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The ONE client interface the React bindings target. Both the direct
3
+ * `SyncClient` (constructed on the current thread) and the worker-mode
4
+ * `SyncClientHandle` (a promise proxy over the OPFS worker) satisfy it —
5
+ * their public surfaces diverge (getters vs methods, sync vs promise), so
6
+ * this module normalizes both into a single async-friendly facade the hooks
7
+ * consume. That is the "one interface across direct and worker-handle modes"
8
+ * the invalidation seam (TODO 3.1) was standardized to enable.
9
+ *
10
+ * The normalizer resolves each accessor at call time (function → call it,
11
+ * value → read it) and wraps every result in `Promise.resolve`, so a hook
12
+ * never has to care which core it holds.
13
+ */
14
+ import type { ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SqlRow, SqlValue, WindowBase, WindowState } from '@syncular/client';
15
+ /**
16
+ * The structural union of `SyncClient` and `SyncClientHandle`. Members that
17
+ * diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
18
+ * collapses the divergence. Only what the hooks use is listed — the bindings
19
+ * never reach past this surface.
20
+ */
21
+ export interface SyncClientLike {
22
+ onInvalidate(listener: InvalidationListener): () => void;
23
+ onPresence(listener: (scopeKey: string) => void): () => void;
24
+ query(sql: string, params?: readonly SqlValue[]): SqlRow[] | Promise<SqlRow[]>;
25
+ mutate(mutations: readonly MutationInput[]): string | Promise<string>;
26
+ conflicts: readonly ConflictRecord[] | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
27
+ rejections: readonly RejectionRecord[] | (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
28
+ schemaFloor: SchemaFloor | undefined | (() => SchemaFloor | undefined | Promise<SchemaFloor | undefined>);
29
+ leaseState: LeaseState | undefined | (() => LeaseState | undefined | Promise<LeaseState | undefined>);
30
+ upgrading: boolean | (() => boolean | Promise<boolean>);
31
+ syncNeeded: boolean | (() => boolean | Promise<boolean>);
32
+ pendingCommits: () => unknown[] | Promise<unknown[]>;
33
+ presence(scopeKey: string): readonly PresencePeer[] | Promise<readonly PresencePeer[]>;
34
+ setPresence(scopeKey: string, doc: Record<string, unknown> | null): void | Promise<void>;
35
+ /** §4.8 windowed subscriptions: set the live units for a window base. */
36
+ setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
37
+ /** §4.8 completeness oracle (I3): the windowed-in units for a base. */
38
+ windowState(base: WindowBase): WindowState | Promise<WindowState>;
39
+ }
40
+ /** The uniform async facade the hooks actually call. */
41
+ export interface NormalizedClient {
42
+ onInvalidate(listener: InvalidationListener): () => void;
43
+ onPresence(listener: (scopeKey: string) => void): () => void;
44
+ query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
45
+ mutate(mutations: readonly MutationInput[]): Promise<string>;
46
+ conflicts(): Promise<readonly ConflictRecord[]>;
47
+ rejections(): Promise<readonly RejectionRecord[]>;
48
+ schemaFloor(): Promise<SchemaFloor | undefined>;
49
+ leaseState(): Promise<LeaseState | undefined>;
50
+ upgrading(): Promise<boolean>;
51
+ syncNeeded(): Promise<boolean>;
52
+ pendingCommits(): Promise<unknown[]>;
53
+ presence(scopeKey: string): Promise<readonly PresencePeer[]>;
54
+ setPresence(scopeKey: string, doc: Record<string, unknown> | null): Promise<void>;
55
+ setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
56
+ windowState(base: WindowBase): Promise<WindowState>;
57
+ }
58
+ export declare function normalizeClient(client: SyncClientLike): NormalizedClient;
package/dist/client.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Read a member by key that is EITHER a value getter (SyncClient) or a
3
+ * method returning a value/promise (SyncClientHandle). Read from the client
4
+ * so a method keeps its `this` binding; if the read is a function, call it.
5
+ */
6
+ function resolveMember(client, key) {
7
+ const member = client[key];
8
+ const value = typeof member === 'function'
9
+ ? member.call(client)
10
+ : member;
11
+ return Promise.resolve(value);
12
+ }
13
+ export function normalizeClient(client) {
14
+ return {
15
+ onInvalidate: (listener) => client.onInvalidate(listener),
16
+ onPresence: (listener) => client.onPresence(listener),
17
+ query: (sql, params) => Promise.resolve(client.query(sql, params)),
18
+ mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
19
+ conflicts: () => resolveMember(client, 'conflicts'),
20
+ rejections: () => resolveMember(client, 'rejections'),
21
+ schemaFloor: () => resolveMember(client, 'schemaFloor'),
22
+ leaseState: () => resolveMember(client, 'leaseState'),
23
+ upgrading: () => resolveMember(client, 'upgrading'),
24
+ syncNeeded: () => resolveMember(client, 'syncNeeded'),
25
+ pendingCommits: () => resolveMember(client, 'pendingCommits'),
26
+ presence: (scopeKey) => Promise.resolve(client.presence(scopeKey)),
27
+ setPresence: (scopeKey, doc) => Promise.resolve(client.setPresence(scopeKey, doc)),
28
+ setWindow: (base, units) => Promise.resolve(client.setWindow(base, units)),
29
+ windowState: (base) => Promise.resolve(client.windowState(base)),
30
+ };
31
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @syncular/react — React bindings with fine-grained live queries
3
+ * (TODO 3.1 / DESIGN-eviction I1–I4). Works against BOTH `SyncClient`
4
+ * (direct) and `SyncClientHandle` (worker) through one normalized client
5
+ * interface. React 18+ (react is a peer dependency); no other runtime deps.
6
+ *
7
+ * See README.md for the invalidation granularity truth and the `tables`
8
+ * option.
9
+ */
10
+ export type { NormalizedClient, SyncClientLike, } from './client.js';
11
+ export { normalizeClient } from './client.js';
12
+ export { inferTables } from './infer-tables.js';
13
+ export { SyncContext, SyncProvider, type SyncProviderProps } from './provider.js';
14
+ export { useSyncClient } from './use-client.js';
15
+ export { type UseConflictsResult, useConflicts } from './use-conflicts.js';
16
+ export { type UseMutationResult, useMutation } from './use-mutation.js';
17
+ export { type NamedQueryDescriptor, useNamedQuery, } from './use-named-query.js';
18
+ export { usePresence } from './use-presence.js';
19
+ export { type UseSyncQueryOptions, type UseSyncQueryResult, useSyncQuery, } from './use-sync-query.js';
20
+ export { type SyncStatus, useSyncStatus } from './use-sync-status.js';
21
+ export { type UseWindowResult, useWindow } from './use-window.js';
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ // `normalizeClient` is the runtime facade the hooks consume; exported so
2
+ // alternate hosts (e.g. `@syncular/tauri`) can assert shape-parity against
3
+ // the exact normalizer the bindings use.
4
+ export { normalizeClient } from './client.js';
5
+ export { inferTables } from './infer-tables.js';
6
+ export { SyncContext, SyncProvider } from './provider.js';
7
+ export { useSyncClient } from './use-client.js';
8
+ export { useConflicts } from './use-conflicts.js';
9
+ export { useMutation } from './use-mutation.js';
10
+ export { useNamedQuery, } from './use-named-query.js';
11
+ export { usePresence } from './use-presence.js';
12
+ export { useSyncQuery, } from './use-sync-query.js';
13
+ export { useSyncStatus } from './use-sync-status.js';
14
+ export { useWindow } from './use-window.js';
15
+ // NOTE: `useTypedQuery` is intentionally NOT re-exported here — it needs the
16
+ // `@syncular/kysely` + `kysely` peers. It lives behind the `./typed`
17
+ // subpath so apps using only `useSyncQuery` never pull Kysely into their
18
+ // bundle. Import it as `@syncular/react/typed`.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Conservative table inference for `useSyncQuery` (TODO 3.1: "infer
3
+ * conservatively from the SQL text's table names … documented as a
4
+ * heuristic with the explicit option as the escape hatch").
5
+ *
6
+ * This is a SIMPLE identifier scan, NOT a SQL parser. It collects the
7
+ * identifiers that follow `FROM` and `JOIN` — the tables a SELECT reads.
8
+ * It is deliberately over-inclusive at the edges (a table aliased in a CTE,
9
+ * a function-table, an odd quoting style) because the failure mode of
10
+ * over-inclusion is a harmless extra re-run, whereas under-inclusion is a
11
+ * stale query — the one thing live queries must never do. When a query's
12
+ * real dependencies cannot be read off its text (dynamic SQL, views,
13
+ * unusual syntax), pass the explicit `tables` option; that always wins.
14
+ */
15
+ /**
16
+ * Extract the table names a SELECT reads. Returns a de-duplicated,
17
+ * lower-nothing set (identifiers are compared to invalidation table names
18
+ * as-is — SQLite table names are case-sensitive for our generated schema).
19
+ */
20
+ export declare function inferTables(sql: string): Set<string>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Conservative table inference for `useSyncQuery` (TODO 3.1: "infer
3
+ * conservatively from the SQL text's table names … documented as a
4
+ * heuristic with the explicit option as the escape hatch").
5
+ *
6
+ * This is a SIMPLE identifier scan, NOT a SQL parser. It collects the
7
+ * identifiers that follow `FROM` and `JOIN` — the tables a SELECT reads.
8
+ * It is deliberately over-inclusive at the edges (a table aliased in a CTE,
9
+ * a function-table, an odd quoting style) because the failure mode of
10
+ * over-inclusion is a harmless extra re-run, whereas under-inclusion is a
11
+ * stale query — the one thing live queries must never do. When a query's
12
+ * real dependencies cannot be read off its text (dynamic SQL, views,
13
+ * unusual syntax), pass the explicit `tables` option; that always wins.
14
+ */
15
+ const FROM_JOIN_RE = /\b(?:from|join)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([a-zA-Z_][a-zA-Z0-9_$]*))/gi;
16
+ /**
17
+ * Extract the table names a SELECT reads. Returns a de-duplicated,
18
+ * lower-nothing set (identifiers are compared to invalidation table names
19
+ * as-is — SQLite table names are case-sensitive for our generated schema).
20
+ */
21
+ export function inferTables(sql) {
22
+ const tables = new Set();
23
+ // Strip a leading schema qualifier (`main.tasks` → `tasks`) so the name
24
+ // matches the invalidation event's bare table name.
25
+ for (const match of sql.matchAll(FROM_JOIN_RE)) {
26
+ const raw = match[1] ?? match[2] ?? match[3] ?? match[4];
27
+ if (raw === undefined)
28
+ continue;
29
+ const dot = raw.lastIndexOf('.');
30
+ tables.add(dot === -1 ? raw : raw.slice(dot + 1));
31
+ }
32
+ return tables;
33
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `SyncProvider` — supplies a `SyncClient` or `SyncClientHandle` to the
3
+ * hook tree through React context. One provider per client; the hooks read
4
+ * the normalized facade. Written with `createElement` (no JSX) so the whole
5
+ * package typechecks under the repo's `.ts`-only root tsconfig with no jsx
6
+ * setting — the bindings are plain function components either way.
7
+ */
8
+ import { type ReactNode } from 'react';
9
+ import { type NormalizedClient, type SyncClientLike } from './client.js';
10
+ export declare const SyncContext: import("react").Context<NormalizedClient | undefined>;
11
+ export interface SyncProviderProps {
12
+ /** A `SyncClient` (direct) or `SyncClientHandle` (worker) — both satisfy it. */
13
+ readonly client: SyncClientLike;
14
+ readonly children?: ReactNode;
15
+ }
16
+ export declare function SyncProvider(props: SyncProviderProps): ReactNode;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `SyncProvider` — supplies a `SyncClient` or `SyncClientHandle` to the
3
+ * hook tree through React context. One provider per client; the hooks read
4
+ * the normalized facade. Written with `createElement` (no JSX) so the whole
5
+ * package typechecks under the repo's `.ts`-only root tsconfig with no jsx
6
+ * setting — the bindings are plain function components either way.
7
+ */
8
+ import { createContext, createElement, useMemo } from 'react';
9
+ import { normalizeClient, } from './client.js';
10
+ export const SyncContext = createContext(undefined);
11
+ export function SyncProvider(props) {
12
+ // Re-normalize only when the client identity changes.
13
+ const normalized = useMemo(() => normalizeClient(props.client), [props.client]);
14
+ return createElement(SyncContext.Provider, { value: normalized }, props.children);
15
+ }
@@ -0,0 +1,92 @@
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 declare function hashRow(row: unknown): string;
35
+ /** The precomputed hash carrier for the previous result, so we hash once. */
36
+ export interface HashedRows<Row> {
37
+ readonly rows: readonly Row[];
38
+ readonly hashes: readonly string[];
39
+ }
40
+ export declare function hashRows<Row>(rows: readonly Row[]): HashedRows<Row>;
41
+ export interface ReconcileResult<Row> {
42
+ /**
43
+ * `undefined` → the whole result is unchanged; the caller MUST NOT call
44
+ * setRows (zero re-render, lever 1a). Otherwise the reconciled array to set,
45
+ * with previous row objects reused wherever a row's content was unchanged
46
+ * (lever 1b).
47
+ */
48
+ readonly next: HashedRows<Row> | undefined;
49
+ }
50
+ /**
51
+ * Reconcile a freshly-queried result against the previous hashed result.
52
+ * Returns `next: undefined` when nothing changed at all; otherwise a new
53
+ * hashed result whose row objects are the PREVIOUS objects wherever content is
54
+ * unchanged (matched by index), so identity-keyed memo components skip.
55
+ */
56
+ export declare function reconcileRows<Row>(prev: HashedRows<Row> | undefined, fresh: readonly Row[]): ReconcileResult<Row>;
57
+ /**
58
+ * A per-query re-run scheduler that coalesces bursts of invalidation events
59
+ * into ONE run per paint. Multiple `schedule()` calls before the next flush
60
+ * run the callback once. A `schedule()` that arrives WHILE the callback is
61
+ * running (re-entrant, or an event during an async re-query) marks dirty and
62
+ * runs the callback exactly once more after — never lost, never concurrent.
63
+ *
64
+ * Timing source: `requestAnimationFrame` when the host has it (a real browser
65
+ * paints one frame; the coalescing window is a frame), else a microtask via a
66
+ * resolved promise (bun tests have no rAF — this keeps them deterministic and
67
+ * timer-free, honoring the no-timers doctrine: it's a readiness turn, not a
68
+ * wall-clock sleep). {@link flush} runs any pending callback synchronously for
69
+ * tests, so no arbitrary sleeps are needed to observe coalescing.
70
+ */
71
+ export declare class FrameScheduler {
72
+ #private;
73
+ constructor(callback: () => void | Promise<void>);
74
+ /** Request a run. Coalesces until the next frame/microtask boundary. */
75
+ schedule(): void;
76
+ /**
77
+ * Synchronously run any pending scheduled callback NOW (test determinism).
78
+ * Returns whatever the callback returned (a Promise for the async host path)
79
+ * so a test can await the re-query settling without a sleep. A no-op when
80
+ * nothing is pending.
81
+ */
82
+ flush(): void | Promise<void>;
83
+ /** Drop the callback so a torn-down hook's pending frame is a no-op. */
84
+ dispose(): void;
85
+ }
86
+ /**
87
+ * TEST-ONLY: synchronously flush every live scheduler's pending frame, so a
88
+ * test can observe the coalesced re-query without a wall-clock sleep (the
89
+ * no-timers doctrine — a readiness flush, injected, not a timer). Returns a
90
+ * Promise that settles when every flushed async re-query has settled.
91
+ */
92
+ export declare function flushQuerySchedulers(): Promise<void>;