@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 +41 -3
- package/dist/client.d.ts +7 -1
- package/dist/client.js +5 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/provider.d.ts +14 -1
- package/dist/provider.js +32 -3
- package/dist/use-query.d.ts +2 -1
- package/dist/use-query.js +1 -0
- package/dist/use-raw-sql.d.ts +4 -1
- package/dist/use-raw-sql.js +5 -2
- package/dist/use-sync-status.d.ts +3 -1
- package/dist/use-sync-status.js +4 -1
- package/dist/vite-hmr.d.ts +22 -0
- package/dist/vite-hmr.js +63 -0
- package/package.json +4 -4
- package/src/client.ts +13 -0
- package/src/index.ts +7 -0
- package/src/provider.ts +73 -3
- package/src/use-query.ts +9 -1
- package/src/use-raw-sql.ts +9 -2
- package/src/use-sync-status.ts +11 -2
- package/src/vite-hmr.ts +102 -0
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 }`. `
|
|
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. `
|
|
176
|
-
means an inbound
|
|
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';
|
package/dist/provider.d.ts
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 { 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, {
|
|
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
|
}
|
package/dist/use-query.d.ts
CHANGED
|
@@ -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
package/dist/use-raw-sql.d.ts
CHANGED
|
@@ -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>;
|
package/dist/use-raw-sql.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/use-sync-status.js
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/vite-hmr.js
ADDED
|
@@ -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.
|
|
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.
|
|
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.
|
|
59
|
-
"@syncular/server": "0.15.
|
|
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 {
|
|
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')
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
}
|
package/src/use-raw-sql.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/use-sync-status.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
}
|
package/src/vite-hmr.ts
ADDED
|
@@ -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
|
+
}
|