@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 +62 -3
- package/dist/client.d.ts +15 -1
- package/dist/client.js +8 -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 +5 -5
- package/src/client.ts +28 -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,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. `
|
|
176
|
-
means an inbound
|
|
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';
|
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.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.
|
|
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": "^
|
|
58
|
-
"@syncular/core": "0.15.
|
|
59
|
-
"@syncular/server": "0.15.
|
|
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 {
|
|
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
|
+
}
|