@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @syncular/react
2
2
 
3
- React bindings for the syncular v2 client, with **fine-grained live
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 `useSyncQuery` re-runs **only** when a table it depends on is touched by an
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, useSyncQuery, useMutation } from '@syncular/react';
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 } = useSyncQuery(
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 `useSyncQuery`:** by default a query re-runs whenever a
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
- ## `useSyncQuery(sql, params?, options?)`
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
- useSyncQuery(buildDynamicSql(), params, { tables: ['tasks', 'projects'] });
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 `useSyncQuery`s re-run on the resulting batch.
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 `useSyncQuery` always answers from the local
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 UseSyncQueryOptions, type UseSyncQueryResult, useSyncQuery, } from './use-sync-query.js';
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 { useSyncQuery, } from './use-sync-query.js';
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`.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Conservative table inference for `useSyncQuery` (TODO 3.1: "infer
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
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Conservative table inference for `useSyncQuery` (TODO 3.1: "infer
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
  *
@@ -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-sync-query} `eventMatches`
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 (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.
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)
@@ -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-sync-query} `eventMatches`
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 (a real browser
101
- * paints one frame; the coalescing window is a frame), else a microtask via a
102
- * resolved promise (bun tests have no rAF — this keeps them deterministic and
103
- * timer-free, honoring the no-timers doctrine: it's a readiness turn, not a
104
- * wall-clock sleep). {@link flush} runs any pending callback synchronously for
105
- * tests, so no arbitrary sleeps are needed to observe coalescing.
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
- const raf = typeof globalThis.requestAnimationFrame === 'function'
205
- ? globalThis.requestAnimationFrame.bind(globalThis)
206
- : undefined;
231
+ function currentDocument() {
232
+ return globalThis.document;
233
+ }
234
+ function documentHidden() {
235
+ return currentDocument()?.visibilityState === 'hidden';
236
+ }
207
237
  function scheduleFrame(cb) {
208
- if (raf !== undefined) {
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): a microtask is the deterministic, timer-free
213
- // coalescing boundary. Everything queued in the current synchronous run
214
- // (a burst of emits) has already called schedule() before this drains.
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
+ }
@@ -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 `useSyncQuery` re-runs on the resulting
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,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 `useSyncQuery` re-runs on the resulting
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
- * `useNamedQuery` — the live-query hook for the generated NAMED-query tier
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 useSyncQuery}'s
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 } = useNamedQuery(listProjectTasksQuery, { projectId });
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 UseSyncQueryOptions, type UseSyncQueryResult } from './use-sync-query.js';
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 useNamedQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?: Omit<UseSyncQueryOptions, 'tables'>): UseSyncQueryResult<Row>;
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 useNamedQuery<Row, Params>(query: NamedQueryDescriptor<Row, Params>, params: Params, options?: Omit<UseSyncQueryOptions, 'tables'>): UseSyncQueryResult<Row>;
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 { useSyncQuery, } from './use-sync-query.js';
2
- export function useNamedQuery(query, paramsOrOptions, maybeOptions) {
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
- return useSyncQuery(query.sql, bound, {
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
- * `useSyncQuery` — a live local SQL query with fine-grained invalidation
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 UseSyncQueryOptions {
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 UseSyncQueryResult<Row> {
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 useSyncQuery<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseSyncQueryOptions): UseSyncQueryResult<Row>;
39
+ export declare function useRawSql<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseRawSqlOptions): UseRawSqlResult<Row>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `useSyncQuery` — a live local SQL query with fine-grained invalidation
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 useSyncQuery(sql, params, options) {
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([]);
@@ -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 useSyncQuery pattern), so the dep list stays on
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.2.1",
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": "./src/index.ts",
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
- useNamedQuery,
26
- } from './use-named-query';
27
- export { usePresence } from './use-presence';
26
+ useQuery,
27
+ } from './use-query';
28
28
  export {
29
- type UseSyncQueryOptions,
30
- type UseSyncQueryResult,
31
- useSyncQuery,
32
- } from './use-sync-query';
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`.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Conservative table inference for `useSyncQuery` (TODO 3.1: "infer
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
  *
@@ -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-sync-query} `eventMatches`
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 (a real browser
126
- * paints one frame; the coalescing window is a frame), else a microtask via a
127
- * resolved promise (bun tests have no rAF — this keeps them deterministic and
128
- * timer-free, honoring the no-timers doctrine: it's a readiness turn, not a
129
- * wall-clock sleep). {@link flush} runs any pending callback synchronously for
130
- * tests, so no arbitrary sleeps are needed to observe coalescing.
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
- const raf: ((cb: () => void) => unknown) | undefined =
235
- typeof globalThis.requestAnimationFrame === 'function'
236
- ? globalThis.requestAnimationFrame.bind(globalThis)
237
- : undefined;
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
- if (raf !== undefined) {
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): a microtask is the deterministic, timer-free
245
- // coalescing boundary. Everything queued in the current synchronous run
246
- // (a burst of emits) has already called schedule() before this drains.
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
+ }
@@ -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 `useSyncQuery` re-runs on the resulting
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
- * `useNamedQuery` — the live-query hook for the generated NAMED-query tier
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 useSyncQuery}'s
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 } = useNamedQuery(listProjectTasksQuery, { projectId });
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 UseSyncQueryOptions,
24
- type UseSyncQueryResult,
25
- useSyncQuery,
26
- } from './use-sync-query';
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 useNamedQuery<Row>(
42
+ export function useQuery<Row>(
39
43
  query: NamedQueryDescriptor<Row, undefined>,
40
- options?: Omit<UseSyncQueryOptions, 'tables'>,
41
- ): UseSyncQueryResult<Row>;
44
+ options?: Omit<UseRawSqlOptions, 'tables'>,
45
+ ): UseRawSqlResult<Row>;
42
46
  /** Run a named query live with its typed params. */
43
- export function useNamedQuery<Row, Params>(
47
+ export function useQuery<Row, Params>(
44
48
  query: NamedQueryDescriptor<Row, Params>,
45
49
  params: Params,
46
- options?: Omit<UseSyncQueryOptions, 'tables'>,
47
- ): UseSyncQueryResult<Row>;
48
- export function useNamedQuery<Row, Params>(
50
+ options?: Omit<UseRawSqlOptions, 'tables'>,
51
+ ): UseRawSqlResult<Row>;
52
+ export function useQuery<Row, Params>(
49
53
  query: NamedQueryDescriptor<Row, Params>,
50
- paramsOrOptions?: Params | Omit<UseSyncQueryOptions, 'tables'>,
51
- maybeOptions?: Omit<UseSyncQueryOptions, 'tables'>,
52
- ): UseSyncQueryResult<Row> {
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<UseSyncQueryOptions, 'tables'>
62
+ | Omit<UseRawSqlOptions, 'tables'>
59
63
  | undefined;
60
64
 
61
65
  const bound = query.bind(params) as readonly SqlValue[];
62
- return useSyncQuery<Row>(query.sql, bound, {
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
- * `useSyncQuery` — a live local SQL query with fine-grained invalidation
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 UseSyncQueryOptions {
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 UseSyncQueryResult<Row> {
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 useSyncQuery<Row = SqlRow>(
81
+ export function useRawSql<Row = SqlRow>(
82
82
  sql: string,
83
83
  params?: readonly SqlValue[],
84
- options?: UseSyncQueryOptions,
85
- ): UseSyncQueryResult<Row> {
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 useSyncQuery pattern), so the dep list stays on
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
@@ -1,6 +0,0 @@
1
- /**
2
- * `@syncular/react/typed` — the Kysely-typed live-query hook, kept behind
3
- * this subpath so the main barrel never imports Kysely. Requires the
4
- * `@syncular/kysely` and `kysely` peer dependencies.
5
- */
6
- export { useTypedQuery } from './use-typed-query.js';
package/dist/typed.js DELETED
@@ -1,6 +0,0 @@
1
- /**
2
- * `@syncular/react/typed` — the Kysely-typed live-query hook, kept behind
3
- * this subpath so the main barrel never imports Kysely. Requires the
4
- * `@syncular/kysely` and `kysely` peer dependencies.
5
- */
6
- export { useTypedQuery } from './use-typed-query.js';
@@ -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>;
@@ -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
@@ -1,6 +0,0 @@
1
- /**
2
- * `@syncular/react/typed` — the Kysely-typed live-query hook, kept behind
3
- * this subpath so the main barrel never imports Kysely. Requires the
4
- * `@syncular/kysely` and `kysely` peer dependencies.
5
- */
6
- export { useTypedQuery } from './use-typed-query';
@@ -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
- }