@syncular/react 0.15.13 → 0.15.14

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
@@ -24,6 +24,9 @@ function Tasks({ projectId }: { projectId: string }) {
24
24
  const mutation = useMutation(tasksTable);
25
25
 
26
26
  if (tasks.phase === 'loading') return <p>Loading…</p>;
27
+ if (tasks.phase === 'blocked') {
28
+ return <p>Sync unavailable: {tasks.availability.reason}</p>;
29
+ }
27
30
  if (tasks.phase === 'error') return <p>{tasks.error?.message}</p>;
28
31
  if (tasks.phase === 'ready' && tasks.rows.length === 0) return <p>Empty</p>;
29
32
 
@@ -49,12 +52,17 @@ Typegen puts these facts on the descriptor:
49
52
  - a stable row key when the projection proves one.
50
53
 
51
54
  `useQuery` returns `{ rows, phase, revision, isLoading, isRefreshing, error,
52
- refresh }`. `phase` is:
55
+ availability, refresh }`. `availability` is the typed `ready`, `migrating`, or
56
+ `blocked` state shared by every client host. `phase` is:
53
57
 
54
58
  - `loading`: there is not yet a complete answer and there are no partial rows;
55
59
  - `partial`: rows exist, but some required window coverage is incomplete;
56
60
  - `ready`: the atomic snapshot says the answer is complete, including an
57
61
  honestly empty result;
62
+ - `blocked`: sync cannot safely serve a fresh result because the generated
63
+ schema is incompatible or the browser leader is unreachable. `isLoading`
64
+ is `false`; previously read rows remain available for deliberate read-only
65
+ UI, and `availability.reason` identifies the boundary;
58
66
  - `error`: the initial read failed. A later refresh error keeps existing rows
59
67
  and phase visible through `error`/`isRefreshing`.
60
68
 
@@ -93,12 +101,41 @@ const clientResource = createSyncClientResource(() => createClient());
93
101
  Call `await clientResource.dispose()` from the application's real lifecycle
94
102
  owner, not from a StrictMode-sensitive child effect.
95
103
 
104
+ Use `renderBoundary` as the canonical application guard across browser and
105
+ native hosts. It covers resource startup/errors, migration, client upgrade,
106
+ server-behind, incompatible-schema, and unreachable-leader states. The
107
+ provider restores its children automatically when the public status becomes
108
+ ready again:
109
+
110
+ ```tsx
111
+ <SyncProvider
112
+ client={clientResource}
113
+ renderBoundary={(state, actions) => (
114
+ <SyncBlockedScreen state={state} onRetry={actions.retry} />
115
+ )}
116
+ >
117
+ <App />
118
+ </SyncProvider>
119
+ ```
120
+
121
+ `fallback` and `renderError` retain their existing behavior when
122
+ `renderBoundary` is absent. `useSyncStatus()` exposes the same classified
123
+ `availability` plus `currentSchemaVersion`, `schemaFloor`, and the raw status
124
+ fields for status chrome outside the provider guard.
125
+
96
126
  The resource is stable across React remounts, not JavaScript module replacement.
97
127
  During development, preserve it in the bundler's hot-module data or dispose the
98
128
  old resource before creating another one. Otherwise the old worker and the new
99
129
  worker can briefly compete for the same persistent OPFS directory. Never wipe
100
130
  or rename the database in response to a retryable startup error.
101
131
 
132
+ For Vite, use `retainViteSyncClientResource(hot.data, schema.version,
133
+ createClient)`. It retains a `{ schemaVersion, resource }` record, reuses it for
134
+ same-schema HMR, and awaits old-resource disposal before creating a schema-bump
135
+ replacement. Request `hot.invalidate()` only when its `schemaChanged` result is
136
+ true and `disposalError` is absent; the official example and full explanation
137
+ are in the [Vite guide](https://syncular.dev/guide-vite/).
138
+
102
139
  ## `useMutation`
103
140
 
104
141
  `useMutation()` retains the raw batch API. `useMutation(generatedTable)` adds
@@ -172,8 +209,9 @@ not need a custom retention effect.
172
209
  ## Other hooks
173
210
 
174
211
  - `useSyncStatus()` observes the status domain without a follow-up read after
175
- every row change. `outbox` is local push work; `syncNeeded` specifically
176
- means an inbound pull/catch-up signal.
212
+ every row change. It includes `availability` and `currentSchemaVersion`;
213
+ `outbox` is local push work, while `syncNeeded` specifically means an inbound
214
+ pull/catch-up signal.
177
215
  - `useConflicts()` observes conflicts and rejections only when that domain
178
216
  changes.
179
217
  - `useCommitOutcomes()` observes the durable newest-first final-outcome journal
package/dist/client.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * value → read it) and wraps every result in `Promise.resolve`, so a hook
12
12
  * never has to care which core it holds.
13
13
  */
14
- import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, InvalidationListener, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
14
+ import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictRecord, InvalidationListener, LeadershipState, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
15
15
  /**
16
16
  * The structural union of `SyncClient` and `SyncClientHandle`. Members that
17
17
  * diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
@@ -19,9 +19,12 @@ import type { ClientChangeListener, CommitOutcome, CommitOutcomeQuery, ConflictR
19
19
  * never reach past this surface.
20
20
  */
21
21
  export interface SyncClientLike {
22
+ readonly currentSchemaVersion?: number;
22
23
  onChange(listener: ClientChangeListener): () => void;
23
24
  onInvalidate(listener: InvalidationListener): () => void;
24
25
  onPresence(listener: (scopeKey: string) => void): () => void;
26
+ onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
27
+ leadershipSnapshot?(): LeadershipState | undefined;
25
28
  query(sql: string, params?: readonly SqlValue[]): SqlRow[] | Promise<SqlRow[]>;
26
29
  mutate(mutations: readonly MutationInput[]): string | Promise<string>;
27
30
  patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
@@ -49,9 +52,12 @@ export interface SyncClientLike {
49
52
  }
50
53
  /** The uniform async facade the hooks actually call. */
51
54
  export interface NormalizedClient {
55
+ readonly currentSchemaVersion?: number;
52
56
  onChange(listener: ClientChangeListener): () => void;
53
57
  onInvalidate(listener: InvalidationListener): () => void;
54
58
  onPresence(listener: (scopeKey: string) => void): () => void;
59
+ onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
60
+ leadershipSnapshot(): LeadershipState | undefined;
55
61
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
56
62
  mutate(mutations: readonly MutationInput[]): Promise<string>;
57
63
  patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
package/dist/client.js CHANGED
@@ -12,9 +12,14 @@ function resolveMember(client, key) {
12
12
  }
13
13
  export function normalizeClient(client) {
14
14
  return {
15
+ ...(client.currentSchemaVersion !== undefined
16
+ ? { currentSchemaVersion: client.currentSchemaVersion }
17
+ : {}),
15
18
  onChange: (listener) => client.onChange(listener),
16
19
  onInvalidate: (listener) => client.onInvalidate(listener),
17
20
  onPresence: (listener) => client.onPresence(listener),
21
+ onLeadershipChange: (listener) => client.onLeadershipChange?.(listener) ?? (() => { }),
22
+ leadershipSnapshot: () => client.leadershipSnapshot?.(),
18
23
  query: (sql, params) => Promise.resolve(client.query(sql, params)),
19
24
  mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
20
25
  patch: (table, rowId, partial, options) => Promise.resolve(client.patch(table, rowId, partial, options)),
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  export type { NormalizedClient, SyncClientLike, } from './client.js';
11
11
  export { normalizeClient } from './client.js';
12
12
  export { inferTables } from './infer-tables.js';
13
- export { SyncContext, SyncProvider, type SyncProviderProps, SyncStoreContext, } from './provider.js';
13
+ export { type SyncBoundaryActions, type SyncBoundaryState, SyncContext, SyncProvider, type SyncProviderProps, SyncStoreContext, } from './provider.js';
14
14
  export { createSyncClientResource, isSyncClientResource, type SyncClientResource, type SyncClientResourceSnapshot, } from './resource.js';
15
15
  export { useReactiveStore, useSyncClient } from './use-client.js';
16
16
  export { type UseCommitOutcomesResult, useCommitOutcomes, } from './use-commit-outcomes.js';
@@ -21,3 +21,4 @@ export { type NamedQueryDescriptor, useQuery, } from './use-query.js';
21
21
  export { type UseRawSqlOptions, type UseRawSqlResult, useRawSql, } from './use-raw-sql.js';
22
22
  export { type SyncStatus, useSyncStatus } from './use-sync-status.js';
23
23
  export { type UseRetainedWindowResult, type UseWindowResult, useRetainedWindow, useWindow, } from './use-window.js';
24
+ export { type RetainedSyncularResource, retainViteSyncClientResource, type ViteSyncClientResourceResult, } from './vite-hmr.js';
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export { useQuery, } from './use-query.js';
14
14
  export { useRawSql, } from './use-raw-sql.js';
15
15
  export { useSyncStatus } from './use-sync-status.js';
16
16
  export { useRetainedWindow, useWindow, } from './use-window.js';
17
+ export { retainViteSyncClientResource, } from './vite-hmr.js';
@@ -5,7 +5,7 @@
5
5
  * package typechecks under the repo's `.ts`-only root tsconfig with no jsx
6
6
  * setting — the bindings are plain function components either way.
7
7
  */
8
- import { ReactiveClientStore } from '@syncular/client';
8
+ import { ReactiveClientStore, type SyncAvailability } from '@syncular/client';
9
9
  import { type ReactNode } from 'react';
10
10
  import { type NormalizedClient, type SyncClientLike } from './client.js';
11
11
  import { type SyncClientResource } from './resource.js';
@@ -17,5 +17,18 @@ export interface SyncProviderProps {
17
17
  readonly children?: ReactNode;
18
18
  readonly fallback?: ReactNode;
19
19
  readonly renderError?: (error: Error, retry: () => Promise<void>) => ReactNode;
20
+ readonly renderBoundary?: (state: SyncBoundaryState, actions: SyncBoundaryActions) => ReactNode;
21
+ }
22
+ export type SyncBoundaryState = {
23
+ readonly state: 'starting';
24
+ } | {
25
+ readonly state: 'startup-error';
26
+ readonly error: Error;
27
+ readonly retryable: boolean;
28
+ } | Exclude<SyncAvailability, {
29
+ readonly state: 'ready';
30
+ }>;
31
+ export interface SyncBoundaryActions {
32
+ readonly retry?: () => Promise<void>;
20
33
  }
21
34
  export declare function SyncProvider(props: SyncProviderProps): ReactNode;
package/dist/provider.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * package typechecks under the repo's `.ts`-only root tsconfig with no jsx
6
6
  * setting — the bindings are plain function components either way.
7
7
  */
8
- import { ReactiveClientStore } from '@syncular/client';
8
+ import { ClientSyncError, ReactiveClientStore, } from '@syncular/client';
9
9
  import { createContext, createElement, useEffect, useMemo, useSyncExternalStore, } from 'react';
10
10
  import { normalizeClient, } from './client.js';
11
11
  import { isSyncClientResource } from './resource.js';
@@ -29,6 +29,7 @@ function recordFor(client) {
29
29
  function ReadySyncProvider(props) {
30
30
  const record = useMemo(() => recordFor(props.client), [props.client]);
31
31
  const { normalized, store } = record;
32
+ const status = useSyncExternalStore(store.status.subscribe, store.status.getSnapshot, store.status.getSnapshot);
32
33
  useEffect(() => {
33
34
  record.refs += 1;
34
35
  store.start();
@@ -40,6 +41,15 @@ function ReadySyncProvider(props) {
40
41
  });
41
42
  };
42
43
  }, [record, store]);
44
+ if (props.renderBoundary !== undefined) {
45
+ if (status.isLoading) {
46
+ return props.renderBoundary({ state: 'starting' }, props.retry === undefined ? {} : { retry: props.retry });
47
+ }
48
+ const availability = store.availabilitySnapshot();
49
+ if (availability.state !== 'ready') {
50
+ return props.renderBoundary(availability, props.retry === undefined ? {} : { retry: props.retry });
51
+ }
52
+ }
43
53
  return createElement(SyncContext.Provider, { value: normalized }, createElement(SyncStoreContext.Provider, { value: store }, props.children));
44
54
  }
45
55
  const noSubscribe = () => () => { };
@@ -53,12 +63,31 @@ export function SyncProvider(props) {
53
63
  const snapshot = useSyncExternalStore(resource?.subscribe ?? noSubscribe, resource?.getSnapshot ??
54
64
  (() => readySnapshot), resource?.getSnapshot ??
55
65
  (() => readySnapshot));
56
- if (snapshot.phase === 'pending')
66
+ if (snapshot.phase === 'pending') {
67
+ if (props.renderBoundary !== undefined) {
68
+ return props.renderBoundary({ state: 'starting' }, resource === undefined ? {} : { retry: resource.retry });
69
+ }
57
70
  return props.fallback ?? null;
71
+ }
58
72
  if (snapshot.phase === 'error') {
73
+ if (props.renderBoundary !== undefined) {
74
+ return props.renderBoundary({
75
+ state: 'startup-error',
76
+ error: snapshot.error,
77
+ retryable: snapshot.error instanceof ClientSyncError
78
+ ? snapshot.error.retryable
79
+ : false,
80
+ }, resource === undefined ? {} : { retry: resource.retry });
81
+ }
59
82
  if (props.renderError !== undefined && resource !== undefined)
60
83
  return props.renderError(snapshot.error, resource.retry);
61
84
  throw snapshot.error;
62
85
  }
63
- return createElement(ReadySyncProvider, { client: snapshot.client }, props.children);
86
+ return createElement(ReadySyncProvider, {
87
+ client: snapshot.client,
88
+ ...(props.renderBoundary !== undefined
89
+ ? { renderBoundary: props.renderBoundary }
90
+ : {}),
91
+ ...(resource !== undefined ? { retry: resource.retry } : {}),
92
+ }, props.children);
64
93
  }
@@ -26,6 +26,7 @@ export interface NamedQueryDescriptor<Row, Params> {
26
26
  readonly hasParams: boolean;
27
27
  readonly sql: string;
28
28
  readonly tables: readonly string[];
29
+ readonly mapRow?: (row: Readonly<Record<string, unknown>>) => Row;
29
30
  readonly bind: (params: Params) => readonly SqlValue[];
30
31
  /** §6 orderBy knob: composes the statement for the CHOSEN order from a
31
32
  * generate-time-checked allowlist (identifiers never come from runtime
@@ -37,7 +38,7 @@ export interface NamedQueryDescriptor<Row, Params> {
37
38
  /** Phantom row carrier (never read at runtime). */
38
39
  readonly __row?: Row;
39
40
  }
40
- type NamedQueryOptions<Row> = Omit<UseRawSqlOptions<Row>, 'tables' | 'scopeKeys' | 'dependencies' | 'coverage' | 'rowKey' | 'id'>;
41
+ type NamedQueryOptions<Row> = Omit<UseRawSqlOptions<Row>, 'tables' | 'scopeKeys' | 'dependencies' | 'coverage' | 'mapRow' | 'rowKey' | 'id'>;
41
42
  /** Run a param-less named query live. */
42
43
  export declare function useQuery<Row>(query: NamedQueryDescriptor<Row, undefined>, options?: NamedQueryOptions<Row>): UseRawSqlResult<Row>;
43
44
  /** Run a named query live with its typed params. */
package/dist/use-query.js CHANGED
@@ -14,6 +14,7 @@ export function useQuery(query, paramsOrOptions, maybeOptions) {
14
14
  id: query.id,
15
15
  dependencies,
16
16
  coverage,
17
+ ...(query.mapRow !== undefined ? { mapRow: query.mapRow } : {}),
17
18
  ...(query.rowKey !== undefined ? { rowKey: query.rowKey } : {}),
18
19
  });
19
20
  }
@@ -1,4 +1,4 @@
1
- import { type LiveQueryPhase, type QueryDependency, type SqlRow, type SqlValue, type WindowCoverage } from '@syncular/client';
1
+ import { type LiveQueryPhase, type QueryDependency, type SqlRow, type SqlValue, type SyncAvailability, type WindowCoverage } from '@syncular/client';
2
2
  export interface UseRawSqlOptions<Row = SqlRow> {
3
3
  /** Legacy table list; prefer table-associated `dependencies`. */
4
4
  readonly tables?: readonly string[];
@@ -6,6 +6,8 @@ export interface UseRawSqlOptions<Row = SqlRow> {
6
6
  readonly scopeKeys?: readonly string[];
7
7
  readonly dependencies?: readonly QueryDependency[];
8
8
  readonly coverage?: readonly WindowCoverage[];
9
+ /** Optional semantic row decoder. Generated named queries provide this. */
10
+ readonly mapRow?: (row: Readonly<SqlRow>) => Row;
9
11
  readonly rowKey?: (row: Row) => readonly SqlValue[];
10
12
  /** Generated coverage claims its windows by default. */
11
13
  readonly claimCoverage?: boolean;
@@ -20,6 +22,7 @@ export interface UseRawSqlResult<Row> {
20
22
  readonly isLoading: boolean;
21
23
  readonly isRefreshing: boolean;
22
24
  readonly error: Error | undefined;
25
+ readonly availability: SyncAvailability;
23
26
  readonly refresh: () => void;
24
27
  }
25
28
  export declare function useRawSql<Row = SqlRow>(sql: string, params?: readonly SqlValue[], options?: UseRawSqlOptions<Row>): UseRawSqlResult<Row>;
@@ -8,6 +8,7 @@ const DISABLED = {
8
8
  revision: undefined,
9
9
  error: undefined,
10
10
  isRefreshing: false,
11
+ availability: { state: 'ready' },
11
12
  };
12
13
  const noSubscribe = () => () => { };
13
14
  export function useRawSql(sql, params, options) {
@@ -31,7 +32,7 @@ export function useRawSql(sql, params, options) {
31
32
  });
32
33
  // `identity` canonically contains every value-shaped input. Depending on
33
34
  // the caller's array/object references would defeat value-stable query
34
- // identity; executable rowKey intentionally follows function identity.
35
+ // identity; executable decoders and row keys follow function identity.
35
36
  // biome-ignore lint/correctness/useExhaustiveDependencies: canonical value identity is the dependency
36
37
  const entry = useMemo(() => store.query({
37
38
  id: options?.id ?? `raw:${identity}`,
@@ -39,9 +40,10 @@ export function useRawSql(sql, params, options) {
39
40
  ...(params !== undefined ? { params } : {}),
40
41
  dependencies,
41
42
  ...(coverage.length > 0 ? { coverage } : {}),
43
+ ...(options?.mapRow !== undefined ? { mapRow: options.mapRow } : {}),
42
44
  ...(options?.rowKey !== undefined ? { rowKey: options.rowKey } : {}),
43
45
  claimCoverage: options?.claimCoverage ?? true,
44
- }), [store, identity, options?.rowKey]);
46
+ }), [store, identity, options?.mapRow, options?.rowKey]);
45
47
  const snapshot = useSyncExternalStore(enabled ? entry.subscribe : noSubscribe, enabled ? entry.getSnapshot : () => DISABLED, enabled ? entry.getSnapshot : () => DISABLED);
46
48
  const refresh = useCallback(() => {
47
49
  if (enabled)
@@ -54,6 +56,7 @@ export function useRawSql(sql, params, options) {
54
56
  isLoading: snapshot.phase === 'loading',
55
57
  isRefreshing: snapshot.isRefreshing,
56
58
  error: snapshot.error,
59
+ availability: snapshot.availability,
57
60
  refresh,
58
61
  };
59
62
  }
@@ -1,5 +1,6 @@
1
- import type { LeaseState, SchemaFloor } from '@syncular/client';
1
+ import type { LeaseState, SchemaFloor, SyncAvailability } from '@syncular/client';
2
2
  export interface SyncStatus {
3
+ readonly currentSchemaVersion: number | undefined;
3
4
  readonly outbox: number;
4
5
  readonly upgrading: boolean;
5
6
  readonly leaseState: LeaseState | undefined;
@@ -7,6 +8,7 @@ export interface SyncStatus {
7
8
  readonly syncNeeded: boolean;
8
9
  readonly isLoading: boolean;
9
10
  readonly error: Error | undefined;
11
+ readonly availability: SyncAvailability;
10
12
  readonly refresh: () => void;
11
13
  }
12
14
  export declare function useSyncStatus(): SyncStatus;
@@ -1,10 +1,12 @@
1
1
  import { useCallback, useSyncExternalStore } from 'react';
2
2
  import { useReactiveStore } from './use-client.js';
3
3
  export function useSyncStatus() {
4
- const entry = useReactiveStore().status;
4
+ const store = useReactiveStore();
5
+ const entry = store.status;
5
6
  const snapshot = useSyncExternalStore(entry.subscribe, entry.getSnapshot, entry.getSnapshot);
6
7
  const refresh = useCallback(() => entry.refresh(), [entry]);
7
8
  return {
9
+ currentSchemaVersion: snapshot.status?.currentSchemaVersion,
8
10
  outbox: snapshot.status?.outbox ?? 0,
9
11
  upgrading: snapshot.status?.upgrading ?? false,
10
12
  leaseState: snapshot.status?.leaseState,
@@ -12,6 +14,7 @@ export function useSyncStatus() {
12
14
  syncNeeded: snapshot.status?.syncNeeded ?? false,
13
15
  isLoading: snapshot.isLoading,
14
16
  error: snapshot.error,
17
+ availability: store.availabilitySnapshot(),
15
18
  refresh,
16
19
  };
17
20
  }
@@ -0,0 +1,22 @@
1
+ import type { SyncClientLike } from './client.js';
2
+ import { type SyncClientResource } from './resource.js';
3
+ /** The exact value retained in `import.meta.hot.data.syncularClientResource`. */
4
+ export interface RetainedSyncularResource {
5
+ readonly schemaVersion: number;
6
+ readonly resource: SyncClientResource;
7
+ }
8
+ export interface ViteSyncClientResourceResult {
9
+ readonly resource: SyncClientResource;
10
+ /** True only when an older captured schema identity was replaced. */
11
+ readonly schemaChanged: boolean;
12
+ /** A failed close is exposed by `resource` as a startup error. */
13
+ readonly disposalError?: Error;
14
+ }
15
+ type HotData = Record<string, unknown>;
16
+ /**
17
+ * Reuse a Vite-owned client only while its captured generated schema matches.
18
+ * On a bump, the prior resource is fully disposed before the replacement
19
+ * resource (and therefore its worker) is constructed.
20
+ */
21
+ export declare function retainViteSyncClientResource(hotData: HotData | undefined, schemaVersion: number, factory: () => SyncClientLike | Promise<SyncClientLike>): Promise<ViteSyncClientResourceResult>;
22
+ export {};
@@ -0,0 +1,63 @@
1
+ import { createSyncClientResource } from './resource.js';
2
+ function errorOf(value) {
3
+ return value instanceof Error ? value : new Error(String(value));
4
+ }
5
+ function isResource(value) {
6
+ return (typeof value === 'object' &&
7
+ value !== null &&
8
+ value.kind === 'syncular-client-resource');
9
+ }
10
+ function retainedFrom(hotData) {
11
+ const value = hotData?.syncularClientResource;
12
+ if (isResource(value)) {
13
+ // Compatibility with the pre-RFC guide, which retained the bare resource.
14
+ return { resource: value };
15
+ }
16
+ if (typeof value !== 'object' || value === null)
17
+ return undefined;
18
+ const candidate = value;
19
+ if (typeof candidate.schemaVersion !== 'number' ||
20
+ !Number.isInteger(candidate.schemaVersion) ||
21
+ !isResource(candidate.resource)) {
22
+ return undefined;
23
+ }
24
+ return {
25
+ schemaVersion: candidate.schemaVersion,
26
+ resource: candidate.resource,
27
+ };
28
+ }
29
+ /**
30
+ * Reuse a Vite-owned client only while its captured generated schema matches.
31
+ * On a bump, the prior resource is fully disposed before the replacement
32
+ * resource (and therefore its worker) is constructed.
33
+ */
34
+ export async function retainViteSyncClientResource(hotData, schemaVersion, factory) {
35
+ if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
36
+ throw new TypeError('schemaVersion must be a positive integer');
37
+ }
38
+ const retained = retainedFrom(hotData);
39
+ if (retained?.schemaVersion === schemaVersion) {
40
+ return { resource: retained.resource, schemaChanged: false };
41
+ }
42
+ let disposalError;
43
+ if (retained !== undefined) {
44
+ try {
45
+ await retained.resource.dispose();
46
+ }
47
+ catch (error) {
48
+ disposalError = errorOf(error);
49
+ }
50
+ }
51
+ // A failed disposal must not open a competing owner. Publish a resource
52
+ // whose ordinary provider startup boundary reports the close failure.
53
+ const resource = createSyncClientResource(disposalError === undefined ? factory : () => Promise.reject(disposalError));
54
+ if (hotData !== undefined) {
55
+ const record = { schemaVersion, resource };
56
+ hotData.syncularClientResource = record;
57
+ }
58
+ return {
59
+ resource,
60
+ schemaChanged: retained !== undefined,
61
+ ...(disposalError !== undefined ? { disposalError } : {}),
62
+ };
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/react",
3
- "version": "0.15.13",
3
+ "version": "0.15.14",
4
4
  "description": "React hooks for Syncular offline-first sync",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,15 +48,15 @@
48
48
  "test": "bun test --preload ./test/setup.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@syncular/client": "0.15.13"
51
+ "@syncular/client": "0.15.14"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "react": ">=18.0.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@happy-dom/global-registrator": "^15.11.0",
58
- "@syncular/core": "0.15.13",
59
- "@syncular/server": "0.15.13",
58
+ "@syncular/core": "0.15.14",
59
+ "@syncular/server": "0.15.14",
60
60
  "@testing-library/react": "^16.1.0",
61
61
  "@types/react": "^18.3.0",
62
62
  "react": "^18.3.1",
package/src/client.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  CommitOutcomeQuery,
18
18
  ConflictRecord,
19
19
  InvalidationListener,
20
+ LeadershipState,
20
21
  LeaseState,
21
22
  LocalDataPurgeInput,
22
23
  LocalDataPurgeResult,
@@ -41,9 +42,12 @@ import type {
41
42
  * never reach past this surface.
42
43
  */
43
44
  export interface SyncClientLike {
45
+ readonly currentSchemaVersion?: number;
44
46
  onChange(listener: ClientChangeListener): () => void;
45
47
  onInvalidate(listener: InvalidationListener): () => void;
46
48
  onPresence(listener: (scopeKey: string) => void): () => void;
49
+ onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
50
+ leadershipSnapshot?(): LeadershipState | undefined;
47
51
  query(
48
52
  sql: string,
49
53
  params?: readonly SqlValue[],
@@ -120,9 +124,12 @@ function resolveMember<T>(
120
124
 
121
125
  /** The uniform async facade the hooks actually call. */
122
126
  export interface NormalizedClient {
127
+ readonly currentSchemaVersion?: number;
123
128
  onChange(listener: ClientChangeListener): () => void;
124
129
  onInvalidate(listener: InvalidationListener): () => void;
125
130
  onPresence(listener: (scopeKey: string) => void): () => void;
131
+ onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
132
+ leadershipSnapshot(): LeadershipState | undefined;
126
133
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
127
134
  mutate(mutations: readonly MutationInput[]): Promise<string>;
128
135
  patch(
@@ -157,9 +164,15 @@ export interface NormalizedClient {
157
164
 
158
165
  export function normalizeClient(client: SyncClientLike): NormalizedClient {
159
166
  return {
167
+ ...(client.currentSchemaVersion !== undefined
168
+ ? { currentSchemaVersion: client.currentSchemaVersion }
169
+ : {}),
160
170
  onChange: (listener) => client.onChange(listener),
161
171
  onInvalidate: (listener) => client.onInvalidate(listener),
162
172
  onPresence: (listener) => client.onPresence(listener),
173
+ onLeadershipChange: (listener) =>
174
+ client.onLeadershipChange?.(listener) ?? (() => {}),
175
+ leadershipSnapshot: () => client.leadershipSnapshot?.(),
163
176
  query: (sql, params) => Promise.resolve(client.query(sql, params)),
164
177
  mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
165
178
  patch: (table, rowId, partial, options) =>
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export type {
17
17
  export { normalizeClient } from './client';
18
18
  export { inferTables } from './infer-tables';
19
19
  export {
20
+ type SyncBoundaryActions,
21
+ type SyncBoundaryState,
20
22
  SyncContext,
21
23
  SyncProvider,
22
24
  type SyncProviderProps,
@@ -58,3 +60,8 @@ export {
58
60
  useRetainedWindow,
59
61
  useWindow,
60
62
  } from './use-window';
63
+ export {
64
+ type RetainedSyncularResource,
65
+ retainViteSyncClientResource,
66
+ type ViteSyncClientResourceResult,
67
+ } from './vite-hmr';
package/src/provider.ts CHANGED
@@ -5,7 +5,11 @@
5
5
  * package typechecks under the repo's `.ts`-only root tsconfig with no jsx
6
6
  * setting — the bindings are plain function components either way.
7
7
  */
8
- import { ReactiveClientStore } from '@syncular/client';
8
+ import {
9
+ ClientSyncError,
10
+ ReactiveClientStore,
11
+ type SyncAvailability,
12
+ } from '@syncular/client';
9
13
  import {
10
14
  createContext,
11
15
  createElement,
@@ -37,6 +41,23 @@ export interface SyncProviderProps {
37
41
  error: Error,
38
42
  retry: () => Promise<void>,
39
43
  ) => ReactNode;
44
+ readonly renderBoundary?: (
45
+ state: SyncBoundaryState,
46
+ actions: SyncBoundaryActions,
47
+ ) => ReactNode;
48
+ }
49
+
50
+ export type SyncBoundaryState =
51
+ | { readonly state: 'starting' }
52
+ | {
53
+ readonly state: 'startup-error';
54
+ readonly error: Error;
55
+ readonly retryable: boolean;
56
+ }
57
+ | Exclude<SyncAvailability, { readonly state: 'ready' }>;
58
+
59
+ export interface SyncBoundaryActions {
60
+ readonly retry?: () => Promise<void>;
40
61
  }
41
62
 
42
63
  interface ClientRecord {
@@ -65,11 +86,18 @@ function recordFor(client: SyncClientLike): ClientRecord {
65
86
  interface ReadySyncProviderProps {
66
87
  readonly client: SyncClientLike;
67
88
  readonly children?: ReactNode;
89
+ readonly renderBoundary?: SyncProviderProps['renderBoundary'];
90
+ readonly retry?: () => Promise<void>;
68
91
  }
69
92
 
70
93
  function ReadySyncProvider(props: ReadySyncProviderProps): ReactNode {
71
94
  const record = useMemo(() => recordFor(props.client), [props.client]);
72
95
  const { normalized, store } = record;
96
+ const status = useSyncExternalStore(
97
+ store.status.subscribe,
98
+ store.status.getSnapshot,
99
+ store.status.getSnapshot,
100
+ );
73
101
  useEffect(() => {
74
102
  record.refs += 1;
75
103
  store.start();
@@ -80,6 +108,21 @@ function ReadySyncProvider(props: ReadySyncProviderProps): ReactNode {
80
108
  });
81
109
  };
82
110
  }, [record, store]);
111
+ if (props.renderBoundary !== undefined) {
112
+ if (status.isLoading) {
113
+ return props.renderBoundary(
114
+ { state: 'starting' },
115
+ props.retry === undefined ? {} : { retry: props.retry },
116
+ );
117
+ }
118
+ const availability = store.availabilitySnapshot();
119
+ if (availability.state !== 'ready') {
120
+ return props.renderBoundary(
121
+ availability,
122
+ props.retry === undefined ? {} : { retry: props.retry },
123
+ );
124
+ }
125
+ }
83
126
  return createElement(
84
127
  SyncContext.Provider,
85
128
  { value: normalized },
@@ -107,15 +150,42 @@ export function SyncProvider(props: SyncProviderProps): ReactNode {
107
150
  resource?.getSnapshot ??
108
151
  (() => readySnapshot as NonNullable<typeof readySnapshot>),
109
152
  );
110
- if (snapshot.phase === 'pending') return props.fallback ?? null;
153
+ if (snapshot.phase === 'pending') {
154
+ if (props.renderBoundary !== undefined) {
155
+ return props.renderBoundary(
156
+ { state: 'starting' },
157
+ resource === undefined ? {} : { retry: resource.retry },
158
+ );
159
+ }
160
+ return props.fallback ?? null;
161
+ }
111
162
  if (snapshot.phase === 'error') {
163
+ if (props.renderBoundary !== undefined) {
164
+ return props.renderBoundary(
165
+ {
166
+ state: 'startup-error',
167
+ error: snapshot.error,
168
+ retryable:
169
+ snapshot.error instanceof ClientSyncError
170
+ ? snapshot.error.retryable
171
+ : false,
172
+ },
173
+ resource === undefined ? {} : { retry: resource.retry },
174
+ );
175
+ }
112
176
  if (props.renderError !== undefined && resource !== undefined)
113
177
  return props.renderError(snapshot.error, resource.retry);
114
178
  throw snapshot.error;
115
179
  }
116
180
  return createElement(
117
181
  ReadySyncProvider,
118
- { client: snapshot.client },
182
+ {
183
+ client: snapshot.client,
184
+ ...(props.renderBoundary !== undefined
185
+ ? { renderBoundary: props.renderBoundary }
186
+ : {}),
187
+ ...(resource !== undefined ? { retry: resource.retry } : {}),
188
+ },
119
189
  props.children,
120
190
  );
121
191
  }
package/src/use-query.ts CHANGED
@@ -35,6 +35,7 @@ export interface NamedQueryDescriptor<Row, Params> {
35
35
  readonly hasParams: boolean;
36
36
  readonly sql: string;
37
37
  readonly tables: readonly string[];
38
+ readonly mapRow?: (row: Readonly<Record<string, unknown>>) => Row;
38
39
  readonly bind: (params: Params) => readonly SqlValue[];
39
40
  /** §6 orderBy knob: composes the statement for the CHOSEN order from a
40
41
  * generate-time-checked allowlist (identifiers never come from runtime
@@ -49,7 +50,13 @@ export interface NamedQueryDescriptor<Row, Params> {
49
50
 
50
51
  type NamedQueryOptions<Row> = Omit<
51
52
  UseRawSqlOptions<Row>,
52
- 'tables' | 'scopeKeys' | 'dependencies' | 'coverage' | 'rowKey' | 'id'
53
+ | 'tables'
54
+ | 'scopeKeys'
55
+ | 'dependencies'
56
+ | 'coverage'
57
+ | 'mapRow'
58
+ | 'rowKey'
59
+ | 'id'
53
60
  >;
54
61
 
55
62
  /** Run a param-less named query live. */
@@ -85,6 +92,7 @@ export function useQuery<Row, Params>(
85
92
  id: query.id,
86
93
  dependencies,
87
94
  coverage,
95
+ ...(query.mapRow !== undefined ? { mapRow: query.mapRow } : {}),
88
96
  ...(query.rowKey !== undefined ? { rowKey: query.rowKey } : {}),
89
97
  });
90
98
  }
@@ -4,6 +4,7 @@ import {
4
4
  type QueryDependency,
5
5
  type SqlRow,
6
6
  type SqlValue,
7
+ type SyncAvailability,
7
8
  type WindowCoverage,
8
9
  } from '@syncular/client';
9
10
  import { useCallback, useMemo, useSyncExternalStore } from 'react';
@@ -17,6 +18,8 @@ export interface UseRawSqlOptions<Row = SqlRow> {
17
18
  readonly scopeKeys?: readonly string[];
18
19
  readonly dependencies?: readonly QueryDependency[];
19
20
  readonly coverage?: readonly WindowCoverage[];
21
+ /** Optional semantic row decoder. Generated named queries provide this. */
22
+ readonly mapRow?: (row: Readonly<SqlRow>) => Row;
20
23
  readonly rowKey?: (row: Row) => readonly SqlValue[];
21
24
  /** Generated coverage claims its windows by default. */
22
25
  readonly claimCoverage?: boolean;
@@ -32,6 +35,7 @@ export interface UseRawSqlResult<Row> {
32
35
  readonly isLoading: boolean;
33
36
  readonly isRefreshing: boolean;
34
37
  readonly error: Error | undefined;
38
+ readonly availability: SyncAvailability;
35
39
  readonly refresh: () => void;
36
40
  }
37
41
 
@@ -41,6 +45,7 @@ const DISABLED = {
41
45
  revision: undefined,
42
46
  error: undefined,
43
47
  isRefreshing: false,
48
+ availability: { state: 'ready' },
44
49
  } as const;
45
50
 
46
51
  const noSubscribe = (): (() => void) => () => {};
@@ -71,7 +76,7 @@ export function useRawSql<Row = SqlRow>(
71
76
  });
72
77
  // `identity` canonically contains every value-shaped input. Depending on
73
78
  // the caller's array/object references would defeat value-stable query
74
- // identity; executable rowKey intentionally follows function identity.
79
+ // identity; executable decoders and row keys follow function identity.
75
80
  // biome-ignore lint/correctness/useExhaustiveDependencies: canonical value identity is the dependency
76
81
  const entry = useMemo(
77
82
  () =>
@@ -81,10 +86,11 @@ export function useRawSql<Row = SqlRow>(
81
86
  ...(params !== undefined ? { params } : {}),
82
87
  dependencies,
83
88
  ...(coverage.length > 0 ? { coverage } : {}),
89
+ ...(options?.mapRow !== undefined ? { mapRow: options.mapRow } : {}),
84
90
  ...(options?.rowKey !== undefined ? { rowKey: options.rowKey } : {}),
85
91
  claimCoverage: options?.claimCoverage ?? true,
86
92
  }),
87
- [store, identity, options?.rowKey],
93
+ [store, identity, options?.mapRow, options?.rowKey],
88
94
  );
89
95
  const snapshot = useSyncExternalStore(
90
96
  enabled ? entry.subscribe : noSubscribe,
@@ -101,6 +107,7 @@ export function useRawSql<Row = SqlRow>(
101
107
  isLoading: snapshot.phase === 'loading',
102
108
  isRefreshing: snapshot.isRefreshing,
103
109
  error: snapshot.error,
110
+ availability: snapshot.availability,
104
111
  refresh,
105
112
  };
106
113
  }
@@ -1,8 +1,13 @@
1
- import type { LeaseState, SchemaFloor } from '@syncular/client';
1
+ import type {
2
+ LeaseState,
3
+ SchemaFloor,
4
+ SyncAvailability,
5
+ } from '@syncular/client';
2
6
  import { useCallback, useSyncExternalStore } from 'react';
3
7
  import { useReactiveStore } from './use-client';
4
8
 
5
9
  export interface SyncStatus {
10
+ readonly currentSchemaVersion: number | undefined;
6
11
  readonly outbox: number;
7
12
  readonly upgrading: boolean;
8
13
  readonly leaseState: LeaseState | undefined;
@@ -10,11 +15,13 @@ export interface SyncStatus {
10
15
  readonly syncNeeded: boolean;
11
16
  readonly isLoading: boolean;
12
17
  readonly error: Error | undefined;
18
+ readonly availability: SyncAvailability;
13
19
  readonly refresh: () => void;
14
20
  }
15
21
 
16
22
  export function useSyncStatus(): SyncStatus {
17
- const entry = useReactiveStore().status;
23
+ const store = useReactiveStore();
24
+ const entry = store.status;
18
25
  const snapshot = useSyncExternalStore(
19
26
  entry.subscribe,
20
27
  entry.getSnapshot,
@@ -22,6 +29,7 @@ export function useSyncStatus(): SyncStatus {
22
29
  );
23
30
  const refresh = useCallback(() => entry.refresh(), [entry]);
24
31
  return {
32
+ currentSchemaVersion: snapshot.status?.currentSchemaVersion,
25
33
  outbox: snapshot.status?.outbox ?? 0,
26
34
  upgrading: snapshot.status?.upgrading ?? false,
27
35
  leaseState: snapshot.status?.leaseState,
@@ -29,6 +37,7 @@ export function useSyncStatus(): SyncStatus {
29
37
  syncNeeded: snapshot.status?.syncNeeded ?? false,
30
38
  isLoading: snapshot.isLoading,
31
39
  error: snapshot.error,
40
+ availability: store.availabilitySnapshot(),
32
41
  refresh,
33
42
  };
34
43
  }
@@ -0,0 +1,102 @@
1
+ import type { SyncClientLike } from './client';
2
+ import { createSyncClientResource, type SyncClientResource } from './resource';
3
+
4
+ /** The exact value retained in `import.meta.hot.data.syncularClientResource`. */
5
+ export interface RetainedSyncularResource {
6
+ readonly schemaVersion: number;
7
+ readonly resource: SyncClientResource;
8
+ }
9
+
10
+ export interface ViteSyncClientResourceResult {
11
+ readonly resource: SyncClientResource;
12
+ /** True only when an older captured schema identity was replaced. */
13
+ readonly schemaChanged: boolean;
14
+ /** A failed close is exposed by `resource` as a startup error. */
15
+ readonly disposalError?: Error;
16
+ }
17
+
18
+ type HotData = Record<string, unknown>;
19
+
20
+ function errorOf(value: unknown): Error {
21
+ return value instanceof Error ? value : new Error(String(value));
22
+ }
23
+
24
+ function isResource(value: unknown): value is SyncClientResource {
25
+ return (
26
+ typeof value === 'object' &&
27
+ value !== null &&
28
+ (value as { readonly kind?: unknown }).kind === 'syncular-client-resource'
29
+ );
30
+ }
31
+
32
+ function retainedFrom(
33
+ hotData: HotData | undefined,
34
+ ):
35
+ | { readonly schemaVersion?: number; readonly resource: SyncClientResource }
36
+ | undefined {
37
+ const value = hotData?.syncularClientResource;
38
+ if (isResource(value)) {
39
+ // Compatibility with the pre-RFC guide, which retained the bare resource.
40
+ return { resource: value };
41
+ }
42
+ if (typeof value !== 'object' || value === null) return undefined;
43
+ const candidate = value as {
44
+ readonly schemaVersion?: unknown;
45
+ readonly resource?: unknown;
46
+ };
47
+ if (
48
+ typeof candidate.schemaVersion !== 'number' ||
49
+ !Number.isInteger(candidate.schemaVersion) ||
50
+ !isResource(candidate.resource)
51
+ ) {
52
+ return undefined;
53
+ }
54
+ return {
55
+ schemaVersion: candidate.schemaVersion,
56
+ resource: candidate.resource,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Reuse a Vite-owned client only while its captured generated schema matches.
62
+ * On a bump, the prior resource is fully disposed before the replacement
63
+ * resource (and therefore its worker) is constructed.
64
+ */
65
+ export async function retainViteSyncClientResource(
66
+ hotData: HotData | undefined,
67
+ schemaVersion: number,
68
+ factory: () => SyncClientLike | Promise<SyncClientLike>,
69
+ ): Promise<ViteSyncClientResourceResult> {
70
+ if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
71
+ throw new TypeError('schemaVersion must be a positive integer');
72
+ }
73
+
74
+ const retained = retainedFrom(hotData);
75
+ if (retained?.schemaVersion === schemaVersion) {
76
+ return { resource: retained.resource, schemaChanged: false };
77
+ }
78
+
79
+ let disposalError: Error | undefined;
80
+ if (retained !== undefined) {
81
+ try {
82
+ await retained.resource.dispose();
83
+ } catch (error) {
84
+ disposalError = errorOf(error);
85
+ }
86
+ }
87
+
88
+ // A failed disposal must not open a competing owner. Publish a resource
89
+ // whose ordinary provider startup boundary reports the close failure.
90
+ const resource = createSyncClientResource(
91
+ disposalError === undefined ? factory : () => Promise.reject(disposalError),
92
+ );
93
+ if (hotData !== undefined) {
94
+ const record: RetainedSyncularResource = { schemaVersion, resource };
95
+ hotData.syncularClientResource = record;
96
+ }
97
+ return {
98
+ resource,
99
+ schemaChanged: retained !== undefined,
100
+ ...(disposalError !== undefined ? { disposalError } : {}),
101
+ };
102
+ }