@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
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.
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/provider.js
ADDED
|
@@ -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>;
|