@syncular/react 0.15.13 → 0.15.15

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,62 @@ 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
+ If the application requires authentication or signed quarantine processing
105
+ before protected data is visible, complete the concrete client's security
106
+ preflight before returning it from the resource factory. Do not mount the
107
+ ordinary provider tree while `securityLifecycle` is `preflight`: reactive store
108
+ startup intentionally touches protected status/outcome/query surfaces.
109
+
110
+ ```ts
111
+ const clientResource = createSyncClientResource(async () => {
112
+ const client = await createClient({ securityPreflight: true });
113
+ await applyValidatedLocalPurge(client);
114
+ await client.activateSecurity({ encryption: acceptedKeyring });
115
+ return client;
116
+ });
117
+ ```
118
+
119
+ The normalized client exposes `securityLifecycle()`,
120
+ `beginSecurityPreflight()`, and keyless `activateSecurity()` for host-agnostic
121
+ coordination. Key-bearing activation stays on each concrete client type because
122
+ direct TypeScript uses an `EncryptionConfig`, while Worker/Tauri/React Native
123
+ use the portable keyring.
124
+
125
+ Use `renderBoundary` as the canonical application guard across browser and
126
+ native hosts. It covers resource startup/errors, migration, client upgrade,
127
+ server-behind, incompatible-schema, and unreachable-leader states. The
128
+ provider restores its children automatically when the public status becomes
129
+ ready again:
130
+
131
+ ```tsx
132
+ <SyncProvider
133
+ client={clientResource}
134
+ renderBoundary={(state, actions) => (
135
+ <SyncBlockedScreen state={state} onRetry={actions.retry} />
136
+ )}
137
+ >
138
+ <App />
139
+ </SyncProvider>
140
+ ```
141
+
142
+ `fallback` and `renderError` retain their existing behavior when
143
+ `renderBoundary` is absent. `useSyncStatus()` exposes the same classified
144
+ `availability` plus `currentSchemaVersion`, `schemaFloor`, and the raw status
145
+ fields for status chrome outside the provider guard.
146
+
96
147
  The resource is stable across React remounts, not JavaScript module replacement.
97
148
  During development, preserve it in the bundler's hot-module data or dispose the
98
149
  old resource before creating another one. Otherwise the old worker and the new
99
150
  worker can briefly compete for the same persistent OPFS directory. Never wipe
100
151
  or rename the database in response to a retryable startup error.
101
152
 
153
+ For Vite, use `retainViteSyncClientResource(hot.data, schema.version,
154
+ createClient)`. It retains a `{ schemaVersion, resource }` record, reuses it for
155
+ same-schema HMR, and awaits old-resource disposal before creating a schema-bump
156
+ replacement. Request `hot.invalidate()` only when its `schemaChanged` result is
157
+ true and `disposalError` is absent; the official example and full explanation
158
+ are in the [Vite guide](https://syncular.dev/guide-vite/).
159
+
102
160
  ## `useMutation`
103
161
 
104
162
  `useMutation()` retains the raw batch API. `useMutation(generatedTable)` adds
@@ -172,8 +230,9 @@ not need a custom retention effect.
172
230
  ## Other hooks
173
231
 
174
232
  - `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.
233
+ every row change. It includes `availability` and `currentSchemaVersion`;
234
+ `outbox` is local push work, while `syncNeeded` specifically means an inbound
235
+ pull/catch-up signal.
177
236
  - `useConflicts()` observes conflicts and rejections only when that domain
178
237
  changes.
179
238
  - `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, SecurityLifecycle, 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,16 @@ 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;
28
+ securityLifecycle: SecurityLifecycle | (() => SecurityLifecycle | Promise<SecurityLifecycle>);
29
+ beginSecurityPreflight(): void | Promise<void>;
30
+ /** Key-bearing activation remains available on each concrete host type. */
31
+ activateSecurity(): void | Promise<void>;
25
32
  query(sql: string, params?: readonly SqlValue[]): SqlRow[] | Promise<SqlRow[]>;
26
33
  mutate(mutations: readonly MutationInput[]): string | Promise<string>;
27
34
  patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
@@ -49,9 +56,16 @@ export interface SyncClientLike {
49
56
  }
50
57
  /** The uniform async facade the hooks actually call. */
51
58
  export interface NormalizedClient {
59
+ readonly currentSchemaVersion?: number;
52
60
  onChange(listener: ClientChangeListener): () => void;
53
61
  onInvalidate(listener: InvalidationListener): () => void;
54
62
  onPresence(listener: (scopeKey: string) => void): () => void;
63
+ onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
64
+ leadershipSnapshot(): LeadershipState | undefined;
65
+ securityLifecycle(): Promise<SecurityLifecycle>;
66
+ beginSecurityPreflight(): Promise<void>;
67
+ /** Key-bearing activation remains available on each concrete host type. */
68
+ activateSecurity(): Promise<void>;
55
69
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
56
70
  mutate(mutations: readonly MutationInput[]): Promise<string>;
57
71
  patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
package/dist/client.js CHANGED
@@ -12,9 +12,17 @@ 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?.(),
23
+ securityLifecycle: () => resolveMember(client, 'securityLifecycle'),
24
+ beginSecurityPreflight: () => Promise.resolve(client.beginSecurityPreflight()),
25
+ activateSecurity: () => Promise.resolve(client.activateSecurity()),
18
26
  query: (sql, params) => Promise.resolve(client.query(sql, params)),
19
27
  mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
20
28
  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.15",
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.15"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "react": ">=18.0.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@happy-dom/global-registrator": "^15.11.0",
58
- "@syncular/core": "0.15.13",
59
- "@syncular/server": "0.15.13",
57
+ "@happy-dom/global-registrator": "^20.0.0",
58
+ "@syncular/core": "0.15.15",
59
+ "@syncular/server": "0.15.15",
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,
@@ -27,6 +28,7 @@ import type {
27
28
  RejectionRecord,
28
29
  ResolveCommitOutcomeInput,
29
30
  SchemaFloor,
31
+ SecurityLifecycle,
30
32
  SqlRow,
31
33
  SqlValue,
32
34
  SyncStatusSnapshot,
@@ -41,9 +43,18 @@ import type {
41
43
  * never reach past this surface.
42
44
  */
43
45
  export interface SyncClientLike {
46
+ readonly currentSchemaVersion?: number;
44
47
  onChange(listener: ClientChangeListener): () => void;
45
48
  onInvalidate(listener: InvalidationListener): () => void;
46
49
  onPresence(listener: (scopeKey: string) => void): () => void;
50
+ onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
51
+ leadershipSnapshot?(): LeadershipState | undefined;
52
+ securityLifecycle:
53
+ | SecurityLifecycle
54
+ | (() => SecurityLifecycle | Promise<SecurityLifecycle>);
55
+ beginSecurityPreflight(): void | Promise<void>;
56
+ /** Key-bearing activation remains available on each concrete host type. */
57
+ activateSecurity(): void | Promise<void>;
47
58
  query(
48
59
  sql: string,
49
60
  params?: readonly SqlValue[],
@@ -120,9 +131,16 @@ function resolveMember<T>(
120
131
 
121
132
  /** The uniform async facade the hooks actually call. */
122
133
  export interface NormalizedClient {
134
+ readonly currentSchemaVersion?: number;
123
135
  onChange(listener: ClientChangeListener): () => void;
124
136
  onInvalidate(listener: InvalidationListener): () => void;
125
137
  onPresence(listener: (scopeKey: string) => void): () => void;
138
+ onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
139
+ leadershipSnapshot(): LeadershipState | undefined;
140
+ securityLifecycle(): Promise<SecurityLifecycle>;
141
+ beginSecurityPreflight(): Promise<void>;
142
+ /** Key-bearing activation remains available on each concrete host type. */
143
+ activateSecurity(): Promise<void>;
126
144
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
127
145
  mutate(mutations: readonly MutationInput[]): Promise<string>;
128
146
  patch(
@@ -157,9 +175,19 @@ export interface NormalizedClient {
157
175
 
158
176
  export function normalizeClient(client: SyncClientLike): NormalizedClient {
159
177
  return {
178
+ ...(client.currentSchemaVersion !== undefined
179
+ ? { currentSchemaVersion: client.currentSchemaVersion }
180
+ : {}),
160
181
  onChange: (listener) => client.onChange(listener),
161
182
  onInvalidate: (listener) => client.onInvalidate(listener),
162
183
  onPresence: (listener) => client.onPresence(listener),
184
+ onLeadershipChange: (listener) =>
185
+ client.onLeadershipChange?.(listener) ?? (() => {}),
186
+ leadershipSnapshot: () => client.leadershipSnapshot?.(),
187
+ securityLifecycle: () => resolveMember(client, 'securityLifecycle'),
188
+ beginSecurityPreflight: () =>
189
+ Promise.resolve(client.beginSecurityPreflight()),
190
+ activateSecurity: () => Promise.resolve(client.activateSecurity()),
163
191
  query: (sql, params) => Promise.resolve(client.query(sql, params)),
164
192
  mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
165
193
  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
+ }