@syncular/react 0.2.1 → 0.3.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 +9 -37
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -6
- package/dist/infer-tables.d.ts +1 -1
- package/dist/infer-tables.js +1 -1
- package/dist/query-churn.d.ts +24 -7
- package/dist/query-churn.js +74 -14
- package/dist/use-mutation.d.ts +1 -1
- package/dist/use-mutation.js +1 -1
- package/dist/{use-named-query.d.ts → use-query.d.ts} +10 -6
- package/dist/{use-named-query.js → use-query.js} +4 -3
- package/dist/{use-sync-query.d.ts → use-raw-sql.d.ts} +4 -4
- package/dist/{use-sync-query.js → use-raw-sql.js} +2 -2
- package/dist/use-window.js +1 -1
- package/package.json +2 -22
- package/src/index.ts +7 -11
- package/src/infer-tables.ts +1 -1
- package/src/query-churn.ts +84 -15
- package/src/use-mutation.ts +1 -1
- package/src/{use-named-query.ts → use-query.ts} +24 -19
- package/src/{use-sync-query.ts → use-raw-sql.ts} +6 -6
- package/src/use-window.ts +1 -1
- package/dist/typed.d.ts +0 -6
- package/dist/typed.js +0 -6
- package/dist/use-typed-query.d.ts +0 -32
- package/dist/use-typed-query.js +0 -44
- package/src/typed.ts +0 -6
- package/src/use-typed-query.ts +0 -67
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @syncular/react
|
|
2
2
|
|
|
3
|
-
React bindings for the syncular
|
|
3
|
+
React bindings for the syncular client, with **fine-grained live
|
|
4
4
|
queries** designed in from day one (TODO 3.1 / `DESIGN-eviction.md` I1–I4).
|
|
5
|
-
A `
|
|
5
|
+
A `useRawSql` re-runs **only** when a table it depends on is touched by an
|
|
6
6
|
apply batch — never "re-run everything on any change".
|
|
7
7
|
|
|
8
8
|
Works against **both** client cores through one interface:
|
|
@@ -19,7 +19,7 @@ React 18+ is a **peer dependency**. There are no other runtime dependencies.
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
21
21
|
```tsx
|
|
22
|
-
import { SyncProvider,
|
|
22
|
+
import { SyncProvider, useRawSql, useMutation } from '@syncular/react';
|
|
23
23
|
|
|
24
24
|
// `client` is a SyncClient or a SyncClientHandle you already started.
|
|
25
25
|
function App({ client }) {
|
|
@@ -31,7 +31,7 @@ function App({ client }) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function Tasks() {
|
|
34
|
-
const { rows, isLoading, error, refresh } =
|
|
34
|
+
const { rows, isLoading, error, refresh } = useRawSql(
|
|
35
35
|
'SELECT id, title, done FROM tasks ORDER BY id',
|
|
36
36
|
);
|
|
37
37
|
const { mutate } = useMutation();
|
|
@@ -88,14 +88,14 @@ schema-bump reset — the ONE choke point). Never one event per row.
|
|
|
88
88
|
- Purge / reset / optimistic writes are keyed by table (and by effective
|
|
89
89
|
scope keys where a scope map is in hand).
|
|
90
90
|
|
|
91
|
-
**Consequence for `
|
|
91
|
+
**Consequence for `useRawSql`:** by default a query re-runs whenever a
|
|
92
92
|
depended-on **table** is touched. You may narrow further with `scopeKeys`
|
|
93
93
|
(below), but a **table-level** event (a segment bootstrap, a reset — one that
|
|
94
94
|
carries no scope keys) **always** re-runs a matching query, because it carries
|
|
95
95
|
no key to discriminate on. This is deliberate: under-running is a stale query,
|
|
96
96
|
the one thing a live-query layer must never do.
|
|
97
97
|
|
|
98
|
-
## `
|
|
98
|
+
## `useRawSql(sql, params?, options?)`
|
|
99
99
|
|
|
100
100
|
Runs a local SQL query and keeps it live.
|
|
101
101
|
|
|
@@ -112,7 +112,7 @@ When the text cannot be read (dynamic SQL, views, unusual syntax), pass the
|
|
|
112
112
|
explicit **`tables`** option — it always wins:
|
|
113
113
|
|
|
114
114
|
```tsx
|
|
115
|
-
|
|
115
|
+
useRawSql(buildDynamicSql(), params, { tables: ['tasks', 'projects'] });
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
### Options
|
|
@@ -123,34 +123,6 @@ useSyncQuery(buildDynamicSql(), params, { tables: ['tasks', 'projects'] });
|
|
|
123
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
124
|
| `enabled` | Skip running while `false` (e.g. inputs not ready). |
|
|
125
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
126
|
## Other hooks
|
|
155
127
|
|
|
156
128
|
- **`useSyncStatus()`** → `{ outbox, upgrading, leaseState, schemaFloor,
|
|
@@ -163,7 +135,7 @@ normalized `query` surface.
|
|
|
163
135
|
connected realtime socket.
|
|
164
136
|
- **`useMutation()`** → `{ mutate, isPending, error }`. `mutate(mutations)`
|
|
165
137
|
resolves to the `clientCommitId`; the optimistic overlay is applied
|
|
166
|
-
immediately, and dependent `
|
|
138
|
+
immediately, and dependent `useRawSql`s re-run on the resulting batch.
|
|
167
139
|
|
|
168
140
|
## SSR
|
|
169
141
|
|
|
@@ -177,6 +149,6 @@ Per `DESIGN-eviction.md` I3, query bindings must be able to route a query's
|
|
|
177
149
|
scope footprint through the window registry once windowed sync (TODO §5
|
|
178
150
|
item 2) lands, so a query can report **completeness** (answerable from the
|
|
179
151
|
local replica vs a window miss). Today the registry trivially contains
|
|
180
|
-
"everything subscribed", so `
|
|
152
|
+
"everything subscribed", so `useRawSql` always answers from the local
|
|
181
153
|
replica. The `scopeKeys` option is the seam through which per-scope
|
|
182
154
|
completeness will be surfaced without an API break.
|
package/dist/index.d.ts
CHANGED
|
@@ -14,8 +14,8 @@ export { SyncContext, SyncProvider, type SyncProviderProps } from './provider.js
|
|
|
14
14
|
export { useSyncClient } from './use-client.js';
|
|
15
15
|
export { type UseConflictsResult, useConflicts } from './use-conflicts.js';
|
|
16
16
|
export { type UseMutationResult, useMutation } from './use-mutation.js';
|
|
17
|
-
export { type NamedQueryDescriptor, useNamedQuery, } from './use-named-query.js';
|
|
18
17
|
export { usePresence } from './use-presence.js';
|
|
19
|
-
export { type
|
|
18
|
+
export { type NamedQueryDescriptor, useQuery, } from './use-query.js';
|
|
19
|
+
export { type UseRawSqlOptions, type UseRawSqlResult, useRawSql, } from './use-raw-sql.js';
|
|
20
20
|
export { type SyncStatus, useSyncStatus } from './use-sync-status.js';
|
|
21
21
|
export { type UseWindowResult, useWindow } from './use-window.js';
|
package/dist/index.js
CHANGED
|
@@ -7,12 +7,8 @@ export { SyncContext, SyncProvider } from './provider.js';
|
|
|
7
7
|
export { useSyncClient } from './use-client.js';
|
|
8
8
|
export { useConflicts } from './use-conflicts.js';
|
|
9
9
|
export { useMutation } from './use-mutation.js';
|
|
10
|
-
export { useNamedQuery, } from './use-named-query.js';
|
|
11
10
|
export { usePresence } from './use-presence.js';
|
|
12
|
-
export {
|
|
11
|
+
export { useQuery, } from './use-query.js';
|
|
12
|
+
export { useRawSql, } from './use-raw-sql.js';
|
|
13
13
|
export { useSyncStatus } from './use-sync-status.js';
|
|
14
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`.
|
package/dist/infer-tables.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Conservative table inference for `
|
|
2
|
+
* Conservative table inference for `useRawSql` (TODO 3.1: "infer
|
|
3
3
|
* conservatively from the SQL text's table names … documented as a
|
|
4
4
|
* heuristic with the explicit option as the escape hatch").
|
|
5
5
|
*
|
package/dist/infer-tables.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Conservative table inference for `
|
|
2
|
+
* Conservative table inference for `useRawSql` (TODO 3.1: "infer
|
|
3
3
|
* conservatively from the SQL text's table names … documented as a
|
|
4
4
|
* heuristic with the explicit option as the escape hatch").
|
|
5
5
|
*
|
package/dist/query-churn.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* so `React.memo`'d row components keyed by row identity skip re-render.
|
|
12
12
|
* 2. {@link FrameScheduler} — frame-coalesced re-query scheduling. Many
|
|
13
13
|
* invalidation events between paints collapse to ONE re-run per query.
|
|
14
|
-
* 3. scope-key filtering lives in {@link ../use-
|
|
14
|
+
* 3. scope-key filtering lives in {@link ../use-raw-sql} `eventMatches`
|
|
15
15
|
* (it needs the event + the hook's options), documented there.
|
|
16
16
|
*
|
|
17
17
|
* Row identity mechanism (the honest key): the hook knows no primary key —
|
|
@@ -61,18 +61,35 @@ export declare function reconcileRows<Row>(prev: HashedRows<Row> | undefined, fr
|
|
|
61
61
|
* running (re-entrant, or an event during an async re-query) marks dirty and
|
|
62
62
|
* runs the callback exactly once more after — never lost, never concurrent.
|
|
63
63
|
*
|
|
64
|
-
* Timing source: `requestAnimationFrame` when the host has it
|
|
65
|
-
* paints one frame; the coalescing window is a
|
|
66
|
-
* resolved promise (bun tests have no rAF —
|
|
67
|
-
* timer-free, honoring the no-timers
|
|
68
|
-
* wall-clock sleep). {@link flush} runs
|
|
69
|
-
* tests, so no arbitrary sleeps are
|
|
64
|
+
* Timing source: `requestAnimationFrame` when the host has it AND the document
|
|
65
|
+
* is visible (a real browser paints one frame; the coalescing window is a
|
|
66
|
+
* frame), else a microtask via a resolved promise (bun tests have no rAF —
|
|
67
|
+
* this keeps them deterministic and timer-free, honoring the no-timers
|
|
68
|
+
* doctrine: it's a readiness turn, not a wall-clock sleep). {@link flush} runs
|
|
69
|
+
* any pending callback synchronously for tests, so no arbitrary sleeps are
|
|
70
|
+
* needed to observe coalescing.
|
|
71
|
+
*
|
|
72
|
+
* Hidden documents: browsers SUSPEND rAF while a page is hidden (background
|
|
73
|
+
* tab, occluded webview, headless embed), so a frame parked there fires only
|
|
74
|
+
* when the page becomes visible again — and a page that is never visible would
|
|
75
|
+
* freeze its live queries forever while invalidations keep arriving. Two
|
|
76
|
+
* guards keep the schedule honest: a `schedule()` issued while hidden goes to
|
|
77
|
+
* the microtask boundary (there is no paint to coalesce against anyway), and a
|
|
78
|
+
* visible → hidden transition re-dispatches any frame already parked in rAF to
|
|
79
|
+
* a microtask (the stale rAF callback later no-ops via the `#scheduled` guard
|
|
80
|
+
* in {@link #fire}).
|
|
70
81
|
*/
|
|
71
82
|
export declare class FrameScheduler {
|
|
72
83
|
#private;
|
|
73
84
|
constructor(callback: () => void | Promise<void>);
|
|
74
85
|
/** Request a run. Coalesces until the next frame/microtask boundary. */
|
|
75
86
|
schedule(): void;
|
|
87
|
+
/**
|
|
88
|
+
* @internal — the visible → hidden transition hands a frame parked in the
|
|
89
|
+
* (now suspended) rAF to the microtask boundary, so live queries keep
|
|
90
|
+
* converging off-screen. A no-op when nothing is pending.
|
|
91
|
+
*/
|
|
92
|
+
redispatchPending(): void;
|
|
76
93
|
/**
|
|
77
94
|
* Synchronously run any pending scheduled callback NOW (test determinism).
|
|
78
95
|
* Returns whatever the callback returned (a Promise for the async host path)
|
package/dist/query-churn.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* so `React.memo`'d row components keyed by row identity skip re-render.
|
|
12
12
|
* 2. {@link FrameScheduler} — frame-coalesced re-query scheduling. Many
|
|
13
13
|
* invalidation events between paints collapse to ONE re-run per query.
|
|
14
|
-
* 3. scope-key filtering lives in {@link ../use-
|
|
14
|
+
* 3. scope-key filtering lives in {@link ../use-raw-sql} `eventMatches`
|
|
15
15
|
* (it needs the event + the hook's options), documented there.
|
|
16
16
|
*
|
|
17
17
|
* Row identity mechanism (the honest key): the hook knows no primary key —
|
|
@@ -97,12 +97,23 @@ export function reconcileRows(prev, fresh) {
|
|
|
97
97
|
* running (re-entrant, or an event during an async re-query) marks dirty and
|
|
98
98
|
* runs the callback exactly once more after — never lost, never concurrent.
|
|
99
99
|
*
|
|
100
|
-
* Timing source: `requestAnimationFrame` when the host has it
|
|
101
|
-
* paints one frame; the coalescing window is a
|
|
102
|
-
* resolved promise (bun tests have no rAF —
|
|
103
|
-
* timer-free, honoring the no-timers
|
|
104
|
-
* wall-clock sleep). {@link flush} runs
|
|
105
|
-
* tests, so no arbitrary sleeps are
|
|
100
|
+
* Timing source: `requestAnimationFrame` when the host has it AND the document
|
|
101
|
+
* is visible (a real browser paints one frame; the coalescing window is a
|
|
102
|
+
* frame), else a microtask via a resolved promise (bun tests have no rAF —
|
|
103
|
+
* this keeps them deterministic and timer-free, honoring the no-timers
|
|
104
|
+
* doctrine: it's a readiness turn, not a wall-clock sleep). {@link flush} runs
|
|
105
|
+
* any pending callback synchronously for tests, so no arbitrary sleeps are
|
|
106
|
+
* needed to observe coalescing.
|
|
107
|
+
*
|
|
108
|
+
* Hidden documents: browsers SUSPEND rAF while a page is hidden (background
|
|
109
|
+
* tab, occluded webview, headless embed), so a frame parked there fires only
|
|
110
|
+
* when the page becomes visible again — and a page that is never visible would
|
|
111
|
+
* freeze its live queries forever while invalidations keep arriving. Two
|
|
112
|
+
* guards keep the schedule honest: a `schedule()` issued while hidden goes to
|
|
113
|
+
* the microtask boundary (there is no paint to coalesce against anyway), and a
|
|
114
|
+
* visible → hidden transition re-dispatches any frame already parked in rAF to
|
|
115
|
+
* a microtask (the stale rAF callback later no-ops via the `#scheduled` guard
|
|
116
|
+
* in {@link #fire}).
|
|
106
117
|
*/
|
|
107
118
|
export class FrameScheduler {
|
|
108
119
|
#scheduled = false;
|
|
@@ -112,6 +123,7 @@ export class FrameScheduler {
|
|
|
112
123
|
constructor(callback) {
|
|
113
124
|
this.#callback = callback;
|
|
114
125
|
liveSchedulers.add(this);
|
|
126
|
+
hookVisibility();
|
|
115
127
|
}
|
|
116
128
|
/** Request a run. Coalesces until the next frame/microtask boundary. */
|
|
117
129
|
schedule() {
|
|
@@ -126,8 +138,23 @@ export class FrameScheduler {
|
|
|
126
138
|
scheduleFrame(() => this.#fire());
|
|
127
139
|
}
|
|
128
140
|
#fire() {
|
|
141
|
+
// A stale dispatch must be a no-op: an rAF parked before the page went
|
|
142
|
+
// hidden fires again on the visible transition, AFTER the microtask
|
|
143
|
+
// fallback already ran the callback and cleared `#scheduled`.
|
|
144
|
+
if (!this.#scheduled)
|
|
145
|
+
return;
|
|
129
146
|
this.#run();
|
|
130
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* @internal — the visible → hidden transition hands a frame parked in the
|
|
150
|
+
* (now suspended) rAF to the microtask boundary, so live queries keep
|
|
151
|
+
* converging off-screen. A no-op when nothing is pending.
|
|
152
|
+
*/
|
|
153
|
+
redispatchPending() {
|
|
154
|
+
if (!this.#scheduled)
|
|
155
|
+
return;
|
|
156
|
+
queueMicrotask(() => this.#fire());
|
|
157
|
+
}
|
|
131
158
|
/**
|
|
132
159
|
* Run the callback once, honoring the running/dirty contract: a `schedule()`
|
|
133
160
|
* during the run marks `#dirty`, and on completion we re-schedule exactly
|
|
@@ -201,16 +228,49 @@ export function flushQuerySchedulers() {
|
|
|
201
228
|
}
|
|
202
229
|
return Promise.all(pending).then(() => undefined);
|
|
203
230
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
231
|
+
function currentDocument() {
|
|
232
|
+
return globalThis.document;
|
|
233
|
+
}
|
|
234
|
+
function documentHidden() {
|
|
235
|
+
return currentDocument()?.visibilityState === 'hidden';
|
|
236
|
+
}
|
|
207
237
|
function scheduleFrame(cb) {
|
|
208
|
-
|
|
238
|
+
// Read rAF per call (not cached at module load) so a test can install a
|
|
239
|
+
// double around one scenario.
|
|
240
|
+
const raf = typeof globalThis.requestAnimationFrame === 'function'
|
|
241
|
+
? globalThis.requestAnimationFrame.bind(globalThis)
|
|
242
|
+
: undefined;
|
|
243
|
+
if (raf !== undefined && !documentHidden()) {
|
|
209
244
|
raf(cb);
|
|
210
245
|
return;
|
|
211
246
|
}
|
|
212
|
-
// No rAF (bun test / worker)
|
|
213
|
-
//
|
|
214
|
-
// (a burst of emits) has
|
|
247
|
+
// No rAF (bun test / worker) or a hidden document (rAF suspended): a
|
|
248
|
+
// microtask is the deterministic, timer-free coalescing boundary.
|
|
249
|
+
// Everything queued in the current synchronous run (a burst of emits) has
|
|
250
|
+
// already called schedule() before this drains.
|
|
215
251
|
queueMicrotask(cb);
|
|
216
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* The document whose `visibilitychange` is currently hooked. Re-hooked when
|
|
255
|
+
* the document identity changes (never in a browser — one document per page —
|
|
256
|
+
* but each test double gets its own listener; stale listeners die with their
|
|
257
|
+
* document). Registration is lazy (first scheduler construction) so importing
|
|
258
|
+
* the module has no side effect.
|
|
259
|
+
*/
|
|
260
|
+
let hookedDocument;
|
|
261
|
+
function hookVisibility() {
|
|
262
|
+
const doc = currentDocument();
|
|
263
|
+
if (doc === undefined ||
|
|
264
|
+
doc === hookedDocument ||
|
|
265
|
+
typeof doc.addEventListener !== 'function') {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
hookedDocument = doc;
|
|
269
|
+
doc.addEventListener('visibilitychange', () => {
|
|
270
|
+
if (doc.visibilityState !== 'hidden')
|
|
271
|
+
return;
|
|
272
|
+
// rAF is suspended from here on; hand every parked frame to a microtask.
|
|
273
|
+
for (const s of liveSchedulers)
|
|
274
|
+
s.redispatchPending();
|
|
275
|
+
});
|
|
276
|
+
}
|
package/dist/use-mutation.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
|
|
3
3
|
* that appends to the outbox and applies the optimistic overlay immediately
|
|
4
|
-
* (§7.1); the referencing `
|
|
4
|
+
* (§7.1); the referencing `useRawSql` re-runs on the resulting
|
|
5
5
|
* invalidation batch, so optimistic writes appear without a manual refetch.
|
|
6
6
|
* `mutate` resolves to the `clientCommitId` (track it against
|
|
7
7
|
* `useConflicts`/status). `isPending`/`error` cover the (usually instant)
|
package/dist/use-mutation.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
|
|
3
3
|
* that appends to the outbox and applies the optimistic overlay immediately
|
|
4
|
-
* (§7.1); the referencing `
|
|
4
|
+
* (§7.1); the referencing `useRawSql` re-runs on the resulting
|
|
5
5
|
* invalidation batch, so optimistic writes appear without a manual refetch.
|
|
6
6
|
* `mutate` resolves to the `clientCommitId` (track it against
|
|
7
7
|
* `useConflicts`/status). `isPending`/`error` cover the (usually instant)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `useQuery` — the live-query hook for the generated NAMED-query tier
|
|
3
3
|
* (typegen's sqlc/SQLDelight rung). You author a `.sql` file; typegen emits a
|
|
4
4
|
* typed `NamedQuery` descriptor (`{ sql, tables, bind }`) + its `Row` type.
|
|
5
|
-
* This hook runs that descriptor live and reuses {@link
|
|
5
|
+
* This hook runs that descriptor live and reuses {@link useRawSql}'s
|
|
6
6
|
* invalidation machinery verbatim — the descriptor's `tables` set is the EXACT
|
|
7
7
|
* dependency set (typegen resolved it from the query's FROM/JOIN against the
|
|
8
8
|
* schema IR), so invalidation is precise with zero SQL-text heuristic and the
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ```ts
|
|
12
12
|
* import { listProjectTasksQuery } from './syncular.queries.js';
|
|
13
|
-
* const { rows } =
|
|
13
|
+
* const { rows } = useQuery(listProjectTasksQuery, { projectId });
|
|
14
14
|
* // ^ ListProjectTasksRow[]
|
|
15
15
|
* ```
|
|
16
16
|
*
|
|
@@ -19,16 +19,20 @@
|
|
|
19
19
|
* descriptor's structural shape — no generated-file import coupling.
|
|
20
20
|
*/
|
|
21
21
|
import type { SqlValue } from '@syncular/client';
|
|
22
|
-
import { type
|
|
22
|
+
import { type UseRawSqlOptions, type UseRawSqlResult } from './use-raw-sql.js';
|
|
23
23
|
/** The structural shape typegen's `NamedQuery<Row, Params>` satisfies. */
|
|
24
24
|
export interface NamedQueryDescriptor<Row, Params> {
|
|
25
25
|
readonly sql: string;
|
|
26
26
|
readonly tables: readonly string[];
|
|
27
27
|
readonly bind: (params: Params) => readonly SqlValue[];
|
|
28
|
+
/** §6 orderBy knob: composes the statement for the CHOSEN order from a
|
|
29
|
+
* generate-time-checked allowlist (identifiers never come from runtime
|
|
30
|
+
* input). Absent on knob-less queries — `sql` is the whole statement. */
|
|
31
|
+
readonly sqlFor?: (params: Params) => string;
|
|
28
32
|
/** Phantom row carrier (never read at runtime). */
|
|
29
33
|
readonly __row?: Row;
|
|
30
34
|
}
|
|
31
35
|
/** Run a param-less named query live. */
|
|
32
|
-
export declare function
|
|
36
|
+
export declare function useQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?: Omit<UseRawSqlOptions, 'tables'>): UseRawSqlResult<Row>;
|
|
33
37
|
/** Run a named query live with its typed params. */
|
|
34
|
-
export declare function
|
|
38
|
+
export declare function useQuery<Row, Params>(query: NamedQueryDescriptor<Row, Params>, params: Params, options?: Omit<UseRawSqlOptions, 'tables'>): UseRawSqlResult<Row>;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export function
|
|
1
|
+
import { useRawSql, } from './use-raw-sql.js';
|
|
2
|
+
export function useQuery(query, paramsOrOptions, maybeOptions) {
|
|
3
3
|
// Overload disambiguation: a param-less query's second arg (if any) is the
|
|
4
4
|
// options object; a parameterized query's second arg is the params.
|
|
5
5
|
const hasParams = query.bind.length > 0;
|
|
6
6
|
const params = (hasParams ? paramsOrOptions : undefined);
|
|
7
7
|
const options = (hasParams ? maybeOptions : paramsOrOptions);
|
|
8
8
|
const bound = query.bind(params);
|
|
9
|
-
|
|
9
|
+
const sql = query.sqlFor === undefined ? query.sql : query.sqlFor(params);
|
|
10
|
+
return useRawSql(sql, bound, {
|
|
10
11
|
...options,
|
|
11
12
|
tables: query.tables,
|
|
12
13
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `useRawSql` — a live local SQL query with fine-grained invalidation
|
|
3
3
|
* (TODO 3.1 / DESIGN-eviction I1–I4). The query runs once on mount, then
|
|
4
4
|
* re-runs ONLY when an invalidation event (one per apply batch, from the
|
|
5
5
|
* web-client choke point) touches a table this query depends on — never
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* apply carries no per-row scope keys, so table-level is the safe default).
|
|
14
14
|
*/
|
|
15
15
|
import type { SqlRow, SqlValue } from '@syncular/client';
|
|
16
|
-
export interface
|
|
16
|
+
export interface UseRawSqlOptions {
|
|
17
17
|
/**
|
|
18
18
|
* Tables this query depends on. Defaults to a conservative scan of `sql`.
|
|
19
19
|
* Pass explicitly to override the heuristic (the escape hatch).
|
|
@@ -29,11 +29,11 @@ export interface UseSyncQueryOptions {
|
|
|
29
29
|
/** Skip running the query (e.g. while inputs are not ready). */
|
|
30
30
|
readonly enabled?: boolean;
|
|
31
31
|
}
|
|
32
|
-
export interface
|
|
32
|
+
export interface UseRawSqlResult<Row> {
|
|
33
33
|
readonly rows: readonly Row[];
|
|
34
34
|
readonly isLoading: boolean;
|
|
35
35
|
readonly error: Error | undefined;
|
|
36
36
|
/** Force a re-run (identity-stable). */
|
|
37
37
|
readonly refresh: () => void;
|
|
38
38
|
}
|
|
39
|
-
export declare function
|
|
39
|
+
export declare function useRawSql<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseRawSqlOptions): UseRawSqlResult<Row>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `useRawSql` — a live local SQL query with fine-grained invalidation
|
|
3
3
|
* (TODO 3.1 / DESIGN-eviction I1–I4). The query runs once on mount, then
|
|
4
4
|
* re-runs ONLY when an invalidation event (one per apply batch, from the
|
|
5
5
|
* web-client choke point) touches a table this query depends on — never
|
|
@@ -47,7 +47,7 @@ function eventMatches(event, tables, scopeKeys) {
|
|
|
47
47
|
}
|
|
48
48
|
return false;
|
|
49
49
|
}
|
|
50
|
-
export function
|
|
50
|
+
export function useRawSql(sql, params, options) {
|
|
51
51
|
const client = useSyncClient();
|
|
52
52
|
const enabled = options?.enabled ?? true;
|
|
53
53
|
const [rows, setRows] = useState([]);
|
package/dist/use-window.js
CHANGED
|
@@ -5,7 +5,7 @@ export function useWindow(base) {
|
|
|
5
5
|
const [units, setUnits] = useState([]);
|
|
6
6
|
// A stable key so the effects re-run only when the base identity changes,
|
|
7
7
|
// not on every render's fresh object. The latest `base` is read via a ref
|
|
8
|
-
// inside the closures (the
|
|
8
|
+
// inside the closures (the useRawSql pattern), so the dep list stays on
|
|
9
9
|
// primitive keys.
|
|
10
10
|
const baseKey = `${base.table} ${base.variable} ${JSON.stringify(base.fixedScopes ?? {})} ${base.params ?? ''}`;
|
|
11
11
|
const baseRef = useRef(base);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "React hooks for Syncular offline-first sync",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -28,19 +28,11 @@
|
|
|
28
28
|
"exports": {
|
|
29
29
|
".": {
|
|
30
30
|
"bun": "./src/index.ts",
|
|
31
|
-
"browser": "./
|
|
31
|
+
"browser": "./dist/index.js",
|
|
32
32
|
"import": {
|
|
33
33
|
"types": "./dist/index.d.ts",
|
|
34
34
|
"default": "./dist/index.js"
|
|
35
35
|
}
|
|
36
|
-
},
|
|
37
|
-
"./typed": {
|
|
38
|
-
"bun": "./src/typed.ts",
|
|
39
|
-
"browser": "./src/typed.ts",
|
|
40
|
-
"import": {
|
|
41
|
-
"types": "./dist/typed.d.ts",
|
|
42
|
-
"default": "./dist/typed.js"
|
|
43
|
-
}
|
|
44
36
|
}
|
|
45
37
|
},
|
|
46
38
|
"files": [
|
|
@@ -59,26 +51,14 @@
|
|
|
59
51
|
"@syncular/client": "0.2.1"
|
|
60
52
|
},
|
|
61
53
|
"peerDependencies": {
|
|
62
|
-
"@syncular/kysely": "0.2.1",
|
|
63
|
-
"kysely": ">=0.27.0",
|
|
64
54
|
"react": ">=18.0.0"
|
|
65
55
|
},
|
|
66
|
-
"peerDependenciesMeta": {
|
|
67
|
-
"@syncular/kysely": {
|
|
68
|
-
"optional": true
|
|
69
|
-
},
|
|
70
|
-
"kysely": {
|
|
71
|
-
"optional": true
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
56
|
"devDependencies": {
|
|
75
57
|
"@happy-dom/global-registrator": "^15.11.0",
|
|
76
58
|
"@syncular/core": "0.2.1",
|
|
77
|
-
"@syncular/kysely": "0.2.1",
|
|
78
59
|
"@syncular/server": "0.2.1",
|
|
79
60
|
"@testing-library/react": "^16.1.0",
|
|
80
61
|
"@types/react": "^18.3.0",
|
|
81
|
-
"kysely": "^0.29.2",
|
|
82
62
|
"react": "^18.3.1",
|
|
83
63
|
"react-dom": "^18.3.1"
|
|
84
64
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,19 +20,15 @@ export { SyncContext, SyncProvider, type SyncProviderProps } from './provider';
|
|
|
20
20
|
export { useSyncClient } from './use-client';
|
|
21
21
|
export { type UseConflictsResult, useConflicts } from './use-conflicts';
|
|
22
22
|
export { type UseMutationResult, useMutation } from './use-mutation';
|
|
23
|
+
export { usePresence } from './use-presence';
|
|
23
24
|
export {
|
|
24
25
|
type NamedQueryDescriptor,
|
|
25
|
-
|
|
26
|
-
} from './use-
|
|
27
|
-
export { usePresence } from './use-presence';
|
|
26
|
+
useQuery,
|
|
27
|
+
} from './use-query';
|
|
28
28
|
export {
|
|
29
|
-
type
|
|
30
|
-
type
|
|
31
|
-
|
|
32
|
-
} from './use-
|
|
29
|
+
type UseRawSqlOptions,
|
|
30
|
+
type UseRawSqlResult,
|
|
31
|
+
useRawSql,
|
|
32
|
+
} from './use-raw-sql';
|
|
33
33
|
export { type SyncStatus, useSyncStatus } from './use-sync-status';
|
|
34
34
|
export { type UseWindowResult, useWindow } from './use-window';
|
|
35
|
-
// NOTE: `useTypedQuery` is intentionally NOT re-exported here — it needs the
|
|
36
|
-
// `@syncular/kysely` + `kysely` peers. It lives behind the `./typed`
|
|
37
|
-
// subpath so apps using only `useSyncQuery` never pull Kysely into their
|
|
38
|
-
// bundle. Import it as `@syncular/react/typed`.
|
package/src/infer-tables.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Conservative table inference for `
|
|
2
|
+
* Conservative table inference for `useRawSql` (TODO 3.1: "infer
|
|
3
3
|
* conservatively from the SQL text's table names … documented as a
|
|
4
4
|
* heuristic with the explicit option as the escape hatch").
|
|
5
5
|
*
|
package/src/query-churn.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* so `React.memo`'d row components keyed by row identity skip re-render.
|
|
12
12
|
* 2. {@link FrameScheduler} — frame-coalesced re-query scheduling. Many
|
|
13
13
|
* invalidation events between paints collapse to ONE re-run per query.
|
|
14
|
-
* 3. scope-key filtering lives in {@link ../use-
|
|
14
|
+
* 3. scope-key filtering lives in {@link ../use-raw-sql} `eventMatches`
|
|
15
15
|
* (it needs the event + the hook's options), documented there.
|
|
16
16
|
*
|
|
17
17
|
* Row identity mechanism (the honest key): the hook knows no primary key —
|
|
@@ -122,12 +122,23 @@ export function reconcileRows<Row>(
|
|
|
122
122
|
* running (re-entrant, or an event during an async re-query) marks dirty and
|
|
123
123
|
* runs the callback exactly once more after — never lost, never concurrent.
|
|
124
124
|
*
|
|
125
|
-
* Timing source: `requestAnimationFrame` when the host has it
|
|
126
|
-
* paints one frame; the coalescing window is a
|
|
127
|
-
* resolved promise (bun tests have no rAF —
|
|
128
|
-
* timer-free, honoring the no-timers
|
|
129
|
-
* wall-clock sleep). {@link flush} runs
|
|
130
|
-
* tests, so no arbitrary sleeps are
|
|
125
|
+
* Timing source: `requestAnimationFrame` when the host has it AND the document
|
|
126
|
+
* is visible (a real browser paints one frame; the coalescing window is a
|
|
127
|
+
* frame), else a microtask via a resolved promise (bun tests have no rAF —
|
|
128
|
+
* this keeps them deterministic and timer-free, honoring the no-timers
|
|
129
|
+
* doctrine: it's a readiness turn, not a wall-clock sleep). {@link flush} runs
|
|
130
|
+
* any pending callback synchronously for tests, so no arbitrary sleeps are
|
|
131
|
+
* needed to observe coalescing.
|
|
132
|
+
*
|
|
133
|
+
* Hidden documents: browsers SUSPEND rAF while a page is hidden (background
|
|
134
|
+
* tab, occluded webview, headless embed), so a frame parked there fires only
|
|
135
|
+
* when the page becomes visible again — and a page that is never visible would
|
|
136
|
+
* freeze its live queries forever while invalidations keep arriving. Two
|
|
137
|
+
* guards keep the schedule honest: a `schedule()` issued while hidden goes to
|
|
138
|
+
* the microtask boundary (there is no paint to coalesce against anyway), and a
|
|
139
|
+
* visible → hidden transition re-dispatches any frame already parked in rAF to
|
|
140
|
+
* a microtask (the stale rAF callback later no-ops via the `#scheduled` guard
|
|
141
|
+
* in {@link #fire}).
|
|
131
142
|
*/
|
|
132
143
|
export class FrameScheduler {
|
|
133
144
|
#scheduled = false;
|
|
@@ -138,6 +149,7 @@ export class FrameScheduler {
|
|
|
138
149
|
constructor(callback: () => void | Promise<void>) {
|
|
139
150
|
this.#callback = callback;
|
|
140
151
|
liveSchedulers.add(this);
|
|
152
|
+
hookVisibility();
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
/** Request a run. Coalesces until the next frame/microtask boundary. */
|
|
@@ -153,9 +165,23 @@ export class FrameScheduler {
|
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
#fire(): void {
|
|
168
|
+
// A stale dispatch must be a no-op: an rAF parked before the page went
|
|
169
|
+
// hidden fires again on the visible transition, AFTER the microtask
|
|
170
|
+
// fallback already ran the callback and cleared `#scheduled`.
|
|
171
|
+
if (!this.#scheduled) return;
|
|
156
172
|
this.#run();
|
|
157
173
|
}
|
|
158
174
|
|
|
175
|
+
/**
|
|
176
|
+
* @internal — the visible → hidden transition hands a frame parked in the
|
|
177
|
+
* (now suspended) rAF to the microtask boundary, so live queries keep
|
|
178
|
+
* converging off-screen. A no-op when nothing is pending.
|
|
179
|
+
*/
|
|
180
|
+
redispatchPending(): void {
|
|
181
|
+
if (!this.#scheduled) return;
|
|
182
|
+
queueMicrotask(() => this.#fire());
|
|
183
|
+
}
|
|
184
|
+
|
|
159
185
|
/**
|
|
160
186
|
* Run the callback once, honoring the running/dirty contract: a `schedule()`
|
|
161
187
|
* during the run marks `#dirty`, and on completion we re-schedule exactly
|
|
@@ -231,18 +257,61 @@ export function flushQuerySchedulers(): Promise<void> {
|
|
|
231
257
|
return Promise.all(pending).then(() => undefined);
|
|
232
258
|
}
|
|
233
259
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
260
|
+
/** The document surface this module reads — kept structural so the package
|
|
261
|
+
* needs no DOM lib types and tests can inject a double. */
|
|
262
|
+
interface DocumentLike {
|
|
263
|
+
readonly visibilityState?: string;
|
|
264
|
+
addEventListener?: (type: string, listener: () => void) => void;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function currentDocument(): DocumentLike | undefined {
|
|
268
|
+
return (globalThis as { document?: DocumentLike }).document;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function documentHidden(): boolean {
|
|
272
|
+
return currentDocument()?.visibilityState === 'hidden';
|
|
273
|
+
}
|
|
238
274
|
|
|
239
275
|
function scheduleFrame(cb: () => void): void {
|
|
240
|
-
|
|
276
|
+
// Read rAF per call (not cached at module load) so a test can install a
|
|
277
|
+
// double around one scenario.
|
|
278
|
+
const raf =
|
|
279
|
+
typeof globalThis.requestAnimationFrame === 'function'
|
|
280
|
+
? globalThis.requestAnimationFrame.bind(globalThis)
|
|
281
|
+
: undefined;
|
|
282
|
+
if (raf !== undefined && !documentHidden()) {
|
|
241
283
|
raf(cb);
|
|
242
284
|
return;
|
|
243
285
|
}
|
|
244
|
-
// No rAF (bun test / worker)
|
|
245
|
-
//
|
|
246
|
-
// (a burst of emits) has
|
|
286
|
+
// No rAF (bun test / worker) or a hidden document (rAF suspended): a
|
|
287
|
+
// microtask is the deterministic, timer-free coalescing boundary.
|
|
288
|
+
// Everything queued in the current synchronous run (a burst of emits) has
|
|
289
|
+
// already called schedule() before this drains.
|
|
247
290
|
queueMicrotask(cb);
|
|
248
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The document whose `visibilitychange` is currently hooked. Re-hooked when
|
|
295
|
+
* the document identity changes (never in a browser — one document per page —
|
|
296
|
+
* but each test double gets its own listener; stale listeners die with their
|
|
297
|
+
* document). Registration is lazy (first scheduler construction) so importing
|
|
298
|
+
* the module has no side effect.
|
|
299
|
+
*/
|
|
300
|
+
let hookedDocument: DocumentLike | undefined;
|
|
301
|
+
|
|
302
|
+
function hookVisibility(): void {
|
|
303
|
+
const doc = currentDocument();
|
|
304
|
+
if (
|
|
305
|
+
doc === undefined ||
|
|
306
|
+
doc === hookedDocument ||
|
|
307
|
+
typeof doc.addEventListener !== 'function'
|
|
308
|
+
) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
hookedDocument = doc;
|
|
312
|
+
doc.addEventListener('visibilitychange', () => {
|
|
313
|
+
if (doc.visibilityState !== 'hidden') return;
|
|
314
|
+
// rAF is suspended from here on; hand every parked frame to a microtask.
|
|
315
|
+
for (const s of liveSchedulers) s.redispatchPending();
|
|
316
|
+
});
|
|
317
|
+
}
|
package/src/use-mutation.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `useMutation` — submit local mutations (§6.1). Returns a stable `mutate`
|
|
3
3
|
* that appends to the outbox and applies the optimistic overlay immediately
|
|
4
|
-
* (§7.1); the referencing `
|
|
4
|
+
* (§7.1); the referencing `useRawSql` re-runs on the resulting
|
|
5
5
|
* invalidation batch, so optimistic writes appear without a manual refetch.
|
|
6
6
|
* `mutate` resolves to the `clientCommitId` (track it against
|
|
7
7
|
* `useConflicts`/status). `isPending`/`error` cover the (usually instant)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `useQuery` — the live-query hook for the generated NAMED-query tier
|
|
3
3
|
* (typegen's sqlc/SQLDelight rung). You author a `.sql` file; typegen emits a
|
|
4
4
|
* typed `NamedQuery` descriptor (`{ sql, tables, bind }`) + its `Row` type.
|
|
5
|
-
* This hook runs that descriptor live and reuses {@link
|
|
5
|
+
* This hook runs that descriptor live and reuses {@link useRawSql}'s
|
|
6
6
|
* invalidation machinery verbatim — the descriptor's `tables` set is the EXACT
|
|
7
7
|
* dependency set (typegen resolved it from the query's FROM/JOIN against the
|
|
8
8
|
* schema IR), so invalidation is precise with zero SQL-text heuristic and the
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ```ts
|
|
12
12
|
* import { listProjectTasksQuery } from './syncular.queries';
|
|
13
|
-
* const { rows } =
|
|
13
|
+
* const { rows } = useQuery(listProjectTasksQuery, { projectId });
|
|
14
14
|
* // ^ ListProjectTasksRow[]
|
|
15
15
|
* ```
|
|
16
16
|
*
|
|
@@ -20,46 +20,51 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import type { SqlValue } from '@syncular/client';
|
|
22
22
|
import {
|
|
23
|
-
type
|
|
24
|
-
type
|
|
25
|
-
|
|
26
|
-
} from './use-
|
|
23
|
+
type UseRawSqlOptions,
|
|
24
|
+
type UseRawSqlResult,
|
|
25
|
+
useRawSql,
|
|
26
|
+
} from './use-raw-sql';
|
|
27
27
|
|
|
28
28
|
/** The structural shape typegen's `NamedQuery<Row, Params>` satisfies. */
|
|
29
29
|
export interface NamedQueryDescriptor<Row, Params> {
|
|
30
30
|
readonly sql: string;
|
|
31
31
|
readonly tables: readonly string[];
|
|
32
32
|
readonly bind: (params: Params) => readonly SqlValue[];
|
|
33
|
+
/** §6 orderBy knob: composes the statement for the CHOSEN order from a
|
|
34
|
+
* generate-time-checked allowlist (identifiers never come from runtime
|
|
35
|
+
* input). Absent on knob-less queries — `sql` is the whole statement. */
|
|
36
|
+
readonly sqlFor?: (params: Params) => string;
|
|
33
37
|
/** Phantom row carrier (never read at runtime). */
|
|
34
38
|
readonly __row?: Row;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/** Run a param-less named query live. */
|
|
38
|
-
export function
|
|
42
|
+
export function useQuery<Row>(
|
|
39
43
|
query: NamedQueryDescriptor<Row, undefined>,
|
|
40
|
-
options?: Omit<
|
|
41
|
-
):
|
|
44
|
+
options?: Omit<UseRawSqlOptions, 'tables'>,
|
|
45
|
+
): UseRawSqlResult<Row>;
|
|
42
46
|
/** Run a named query live with its typed params. */
|
|
43
|
-
export function
|
|
47
|
+
export function useQuery<Row, Params>(
|
|
44
48
|
query: NamedQueryDescriptor<Row, Params>,
|
|
45
49
|
params: Params,
|
|
46
|
-
options?: Omit<
|
|
47
|
-
):
|
|
48
|
-
export function
|
|
50
|
+
options?: Omit<UseRawSqlOptions, 'tables'>,
|
|
51
|
+
): UseRawSqlResult<Row>;
|
|
52
|
+
export function useQuery<Row, Params>(
|
|
49
53
|
query: NamedQueryDescriptor<Row, Params>,
|
|
50
|
-
paramsOrOptions?: Params | Omit<
|
|
51
|
-
maybeOptions?: Omit<
|
|
52
|
-
):
|
|
54
|
+
paramsOrOptions?: Params | Omit<UseRawSqlOptions, 'tables'>,
|
|
55
|
+
maybeOptions?: Omit<UseRawSqlOptions, 'tables'>,
|
|
56
|
+
): UseRawSqlResult<Row> {
|
|
53
57
|
// Overload disambiguation: a param-less query's second arg (if any) is the
|
|
54
58
|
// options object; a parameterized query's second arg is the params.
|
|
55
59
|
const hasParams = query.bind.length > 0;
|
|
56
60
|
const params = (hasParams ? paramsOrOptions : undefined) as Params;
|
|
57
61
|
const options = (hasParams ? maybeOptions : paramsOrOptions) as
|
|
58
|
-
| Omit<
|
|
62
|
+
| Omit<UseRawSqlOptions, 'tables'>
|
|
59
63
|
| undefined;
|
|
60
64
|
|
|
61
65
|
const bound = query.bind(params) as readonly SqlValue[];
|
|
62
|
-
|
|
66
|
+
const sql = query.sqlFor === undefined ? query.sql : query.sqlFor(params);
|
|
67
|
+
return useRawSql<Row>(sql, bound, {
|
|
63
68
|
...options,
|
|
64
69
|
tables: query.tables,
|
|
65
70
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `useRawSql` — a live local SQL query with fine-grained invalidation
|
|
3
3
|
* (TODO 3.1 / DESIGN-eviction I1–I4). The query runs once on mount, then
|
|
4
4
|
* re-runs ONLY when an invalidation event (one per apply batch, from the
|
|
5
5
|
* web-client choke point) touches a table this query depends on — never
|
|
@@ -19,7 +19,7 @@ import { inferTables } from './infer-tables';
|
|
|
19
19
|
import { FrameScheduler, type HashedRows, reconcileRows } from './query-churn';
|
|
20
20
|
import { useSyncClient } from './use-client';
|
|
21
21
|
|
|
22
|
-
export interface
|
|
22
|
+
export interface UseRawSqlOptions {
|
|
23
23
|
/**
|
|
24
24
|
* Tables this query depends on. Defaults to a conservative scan of `sql`.
|
|
25
25
|
* Pass explicitly to override the heuristic (the escape hatch).
|
|
@@ -36,7 +36,7 @@ export interface UseSyncQueryOptions {
|
|
|
36
36
|
readonly enabled?: boolean;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export interface
|
|
39
|
+
export interface UseRawSqlResult<Row> {
|
|
40
40
|
readonly rows: readonly Row[];
|
|
41
41
|
readonly isLoading: boolean;
|
|
42
42
|
readonly error: Error | undefined;
|
|
@@ -78,11 +78,11 @@ function eventMatches(
|
|
|
78
78
|
return false;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function
|
|
81
|
+
export function useRawSql<Row = SqlRow>(
|
|
82
82
|
sql: string,
|
|
83
83
|
params?: readonly SqlValue[],
|
|
84
|
-
options?:
|
|
85
|
-
):
|
|
84
|
+
options?: UseRawSqlOptions,
|
|
85
|
+
): UseRawSqlResult<Row> {
|
|
86
86
|
const client = useSyncClient();
|
|
87
87
|
const enabled = options?.enabled ?? true;
|
|
88
88
|
|
package/src/use-window.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function useWindow(base: WindowBase): UseWindowResult {
|
|
|
34
34
|
|
|
35
35
|
// A stable key so the effects re-run only when the base identity changes,
|
|
36
36
|
// not on every render's fresh object. The latest `base` is read via a ref
|
|
37
|
-
// inside the closures (the
|
|
37
|
+
// inside the closures (the useRawSql pattern), so the dep list stays on
|
|
38
38
|
// primitive keys.
|
|
39
39
|
const baseKey = `${base.table} ${base.variable} ${JSON.stringify(
|
|
40
40
|
base.fixedScopes ?? {},
|
package/dist/typed.d.ts
DELETED
package/dist/typed.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
import type { SqlRow } from '@syncular/client';
|
|
23
|
-
import type { Compilable, Kysely } from 'kysely';
|
|
24
|
-
import { type UseSyncQueryOptions, type UseSyncQueryResult } from './use-sync-query.js';
|
|
25
|
-
/**
|
|
26
|
-
* Build a live typed query. `build` receives a `Kysely<Database>` bound to the
|
|
27
|
-
* context client and returns any compilable query builder. `deps` re-keys the
|
|
28
|
-
* builder the same way a `useEffect` dep array does (values the query closes
|
|
29
|
-
* over, e.g. filter inputs). `options.enabled`/`scopeKeys` pass through; the
|
|
30
|
-
* `{tables}` set is derived from the compiled query, never guessed.
|
|
31
|
-
*/
|
|
32
|
-
export declare function useTypedQuery<Database, Row = SqlRow>(build: (db: Kysely<Database>) => Compilable<Row>, deps?: readonly unknown[], options?: Omit<UseSyncQueryOptions, 'tables'>): UseSyncQueryResult<Row>;
|
package/dist/use-typed-query.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
import { createSyncularKysely, extractTables } from '@syncular/kysely';
|
|
23
|
-
import { useMemo } from 'react';
|
|
24
|
-
import { useSyncClient } from './use-client.js';
|
|
25
|
-
import { useSyncQuery, } from './use-sync-query.js';
|
|
26
|
-
/**
|
|
27
|
-
* Build a live typed query. `build` receives a `Kysely<Database>` bound to the
|
|
28
|
-
* context client and returns any compilable query builder. `deps` re-keys the
|
|
29
|
-
* builder the same way a `useEffect` dep array does (values the query closes
|
|
30
|
-
* over, e.g. filter inputs). `options.enabled`/`scopeKeys` pass through; the
|
|
31
|
-
* `{tables}` set is derived from the compiled query, never guessed.
|
|
32
|
-
*/
|
|
33
|
-
export function useTypedQuery(build, deps = [], options) {
|
|
34
|
-
const client = useSyncClient();
|
|
35
|
-
// One Kysely instance per client identity — the dialect drives the same
|
|
36
|
-
// normalized `query` surface the other hooks use, so every host works.
|
|
37
|
-
const db = useMemo(() => createSyncularKysely(client), [client]);
|
|
38
|
-
// Compile on the deps the caller declared. The compiled query yields SQL +
|
|
39
|
-
// parameters (for execution) and its AST (for exact table extraction).
|
|
40
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `deps` is the caller-declared re-key, `build`/`db` are stable-by-design
|
|
41
|
-
const compiled = useMemo(() => build(db).compile(), [db, ...deps]);
|
|
42
|
-
const tables = useMemo(() => extractTables(compiled), [compiled]);
|
|
43
|
-
return useSyncQuery(compiled.sql, compiled.parameters, { ...options, tables });
|
|
44
|
-
}
|
package/src/typed.ts
DELETED
package/src/use-typed-query.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
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
|
-
}
|