@syncular/react 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -117
- package/dist/client.d.ts +13 -1
- package/dist/client.js +4 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/provider.d.ts +6 -1
- package/dist/provider.js +53 -4
- package/dist/resource.d.ts +18 -0
- package/dist/resource.js +56 -0
- package/dist/use-client.d.ts +2 -0
- package/dist/use-client.js +8 -1
- package/dist/use-conflicts.d.ts +2 -5
- package/dist/use-conflicts.js +12 -35
- package/dist/use-mutation.d.ts +22 -12
- package/dist/use-mutation.js +61 -21
- package/dist/use-query.d.ts +10 -3
- package/dist/use-query.js +7 -2
- package/dist/use-raw-sql.d.ts +15 -29
- package/dist/use-raw-sql.js +54 -186
- package/dist/use-sync-status.d.ts +1 -18
- package/dist/use-sync-status.js +15 -65
- package/dist/use-window.d.ts +13 -25
- package/dist/use-window.js +55 -91
- package/package.json +4 -4
- package/src/client.ts +29 -0
- package/src/index.ts +26 -4
- package/src/provider.ts +89 -7
- package/src/resource.ts +77 -0
- package/src/use-client.ts +12 -1
- package/src/use-conflicts.ts +18 -37
- package/src/use-mutation.ts +126 -25
- package/src/use-query.ts +27 -8
- package/src/use-raw-sql.ts +83 -212
- package/src/use-sync-status.ts +20 -73
- package/src/use-window.ts +90 -103
- package/dist/query-churn.d.ts +0 -109
- package/dist/query-churn.js +0 -276
- package/src/query-churn.ts +0 -317
package/README.md
CHANGED
|
@@ -1,154 +1,171 @@
|
|
|
1
1
|
# @syncular/react
|
|
2
2
|
|
|
3
|
-
React bindings for
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
apply batch — never "re-run everything on any change".
|
|
3
|
+
React 18+ bindings for Syncular's revisioned reactive store. The same hooks
|
|
4
|
+
work with the direct TypeScript client, the browser worker handle, and the
|
|
5
|
+
Tauri/React Native bridges.
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
The store is client-scoped, not hook-scoped. Equal queries share one local SQL
|
|
8
|
+
read per revision, rows and window completeness come from one SQLite snapshot,
|
|
9
|
+
and stale promises cannot overwrite a newer revision. React is only a
|
|
10
|
+
`useSyncExternalStore` adapter over that renderer-independent state.
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
- `SyncClientHandle` — the worker-mode proxy (the whole core in an OPFS
|
|
12
|
-
worker).
|
|
12
|
+
## Recommended query and mutation path
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
React 18+ is a **peer dependency**. There are no other runtime dependencies.
|
|
18
|
-
|
|
19
|
-
## Quick start
|
|
14
|
+
Author reads in `queries/*.sql` or `queries/*.syql`, run `syncular generate`,
|
|
15
|
+
and pass the generated descriptor to `useQuery`:
|
|
20
16
|
|
|
21
17
|
```tsx
|
|
22
|
-
import { SyncProvider,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function App({ client }) {
|
|
26
|
-
return (
|
|
27
|
-
<SyncProvider client={client}>
|
|
28
|
-
<Tasks />
|
|
29
|
-
</SyncProvider>
|
|
30
|
-
);
|
|
31
|
-
}
|
|
18
|
+
import { SyncProvider, useMutation, useQuery } from '@syncular/react';
|
|
19
|
+
import { tasksTable } from './syncular.generated';
|
|
20
|
+
import { listTasksQuery } from './syncular.queries';
|
|
32
21
|
|
|
33
|
-
function Tasks() {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
const { mutate } = useMutation();
|
|
22
|
+
function Tasks({ projectId }: { projectId: string }) {
|
|
23
|
+
const tasks = useQuery(listTasksQuery, { projectId });
|
|
24
|
+
const mutation = useMutation(tasksTable);
|
|
38
25
|
|
|
39
|
-
if (
|
|
40
|
-
if (error) return <p>
|
|
26
|
+
if (tasks.phase === 'loading') return <p>Loading…</p>;
|
|
27
|
+
if (tasks.phase === 'error') return <p>{tasks.error?.message}</p>;
|
|
28
|
+
if (tasks.phase === 'ready' && tasks.rows.length === 0) return <p>Empty</p>;
|
|
41
29
|
|
|
42
30
|
return (
|
|
43
31
|
<ul>
|
|
44
|
-
{rows.map((
|
|
45
|
-
<li key={
|
|
32
|
+
{tasks.rows.map((task) => (
|
|
33
|
+
<li key={task.id}>
|
|
34
|
+
<button onClick={() => mutation.patch(task.id, { done: !task.done })}>
|
|
35
|
+
{task.title}
|
|
36
|
+
</button>
|
|
37
|
+
</li>
|
|
46
38
|
))}
|
|
47
|
-
<button
|
|
48
|
-
onClick={() =>
|
|
49
|
-
mutate([
|
|
50
|
-
{
|
|
51
|
-
table: 'tasks',
|
|
52
|
-
op: 'upsert',
|
|
53
|
-
values: { id: crypto.randomUUID(), project_id: 'p1', title: 'new', done: false },
|
|
54
|
-
},
|
|
55
|
-
])
|
|
56
|
-
}
|
|
57
|
-
>
|
|
58
|
-
Add
|
|
59
|
-
</button>
|
|
60
39
|
</ul>
|
|
61
40
|
);
|
|
62
41
|
}
|
|
63
42
|
```
|
|
64
43
|
|
|
65
|
-
|
|
66
|
-
the list updates immediately — no manual refetch.
|
|
44
|
+
Typegen puts these facts on the descriptor:
|
|
67
45
|
|
|
68
|
-
|
|
46
|
+
- a QueryIR-derived cache id, so SQL-only edits cannot reuse old state;
|
|
47
|
+
- exact table/scope dependencies for change routing;
|
|
48
|
+
- provable window coverage, claimed automatically while observed;
|
|
49
|
+
- a stable row key when the projection proves one.
|
|
69
50
|
|
|
70
|
-
|
|
71
|
-
|
|
51
|
+
`useQuery` returns `{ rows, phase, revision, isLoading, isRefreshing, error,
|
|
52
|
+
refresh }`. `phase` is:
|
|
72
53
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
54
|
+
- `loading`: there is not yet a complete answer and there are no partial rows;
|
|
55
|
+
- `partial`: rows exist, but some required window coverage is incomplete;
|
|
56
|
+
- `ready`: the atomic snapshot says the answer is complete, including an
|
|
57
|
+
honestly empty result;
|
|
58
|
+
- `error`: the initial read failed. A later refresh error keeps existing rows
|
|
59
|
+
and phase visible through `error`/`isRefreshing`.
|
|
76
60
|
|
|
77
|
-
|
|
78
|
-
is the **reliable floor**: it is always present and always correct.
|
|
79
|
-
- **`scopeKeys`** — `prefix:value` scope keys (§3.1), present **where the
|
|
80
|
-
source carried them**:
|
|
81
|
-
- `COMMIT` frames carry per-row stored scopes (§4.5), so commit-driven
|
|
82
|
-
invalidation carries **precise** scope keys.
|
|
83
|
-
- **Segments carry no per-row scope keys** — only a table + a scope digest.
|
|
84
|
-
A segment (bootstrap / re-bootstrap) invalidation therefore carries the
|
|
85
|
-
table plus the **subscription's effective scope keys**, the coarsest
|
|
86
|
-
honest key for bulk data. It never fabricates per-row keys the wire did
|
|
87
|
-
not deliver.
|
|
88
|
-
- Purge / reset / optimistic writes are keyed by table (and by effective
|
|
89
|
-
scope keys where a scope map is in hand).
|
|
61
|
+
## Provider and async initialization
|
|
90
62
|
|
|
91
|
-
|
|
92
|
-
depended-on **table** is touched. You may narrow further with `scopeKeys`
|
|
93
|
-
(below), but a **table-level** event (a segment bootstrap, a reset — one that
|
|
94
|
-
carries no scope keys) **always** re-runs a matching query, because it carries
|
|
95
|
-
no key to discriminate on. This is deliberate: under-running is a stale query,
|
|
96
|
-
the one thing a live-query layer must never do.
|
|
63
|
+
A ready client can be passed directly:
|
|
97
64
|
|
|
98
|
-
|
|
65
|
+
```tsx
|
|
66
|
+
<SyncProvider client={client}><Tasks projectId="p1" /></SyncProvider>
|
|
67
|
+
```
|
|
99
68
|
|
|
100
|
-
|
|
69
|
+
For async engines, create one resource outside React render. It owns one
|
|
70
|
+
initialization attempt across StrictMode remounts and closes the client exactly
|
|
71
|
+
once when explicitly disposed:
|
|
101
72
|
|
|
102
|
-
|
|
73
|
+
```tsx
|
|
74
|
+
import { createSyncClientResource, SyncProvider } from '@syncular/react';
|
|
103
75
|
|
|
104
|
-
|
|
76
|
+
const clientResource = createSyncClientResource(() => createClient());
|
|
105
77
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
78
|
+
<SyncProvider
|
|
79
|
+
client={clientResource}
|
|
80
|
+
fallback={<p>Starting local database…</p>}
|
|
81
|
+
renderError={(error) => <p>{error.message}</p>}
|
|
82
|
+
>
|
|
83
|
+
<App />
|
|
84
|
+
</SyncProvider>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Call `await clientResource.dispose()` from the application's real lifecycle
|
|
88
|
+
owner, not from a StrictMode-sensitive child effect.
|
|
89
|
+
|
|
90
|
+
## `useMutation`
|
|
110
91
|
|
|
111
|
-
|
|
112
|
-
|
|
92
|
+
`useMutation()` retains the raw batch API. `useMutation(generatedTable)` adds
|
|
93
|
+
typed `upsert`, `patch`, and `remove` helpers:
|
|
113
94
|
|
|
114
95
|
```tsx
|
|
115
|
-
|
|
96
|
+
const mutation = useMutation(tasksTable, {
|
|
97
|
+
onSuccess(commitId) {},
|
|
98
|
+
onError(error) {},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await mutation.upsert({ id, projectId, title, done: false });
|
|
102
|
+
await mutation.patch(id, { title: 'Renamed' });
|
|
103
|
+
await mutation.remove(id);
|
|
116
104
|
```
|
|
117
105
|
|
|
118
|
-
|
|
106
|
+
It returns `pendingCount`, `isPending`, `error`, and `resetError`. Overlapping
|
|
107
|
+
writes remain pending until all calls settle. Every method still returns a
|
|
108
|
+
promise and rejects on failure; callbacks do not replace error handling.
|
|
119
109
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
110
|
+
## `useRawSql`
|
|
111
|
+
|
|
112
|
+
`useRawSql(sql, params?, options?)` is the read-only escape hatch for dynamic
|
|
113
|
+
SQL. It returns the same phase/revision result as `useQuery`.
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
const summary = useRawSql<{ total: number }>(
|
|
117
|
+
'SELECT count(*) AS total FROM tasks WHERE project_id = ?',
|
|
118
|
+
[projectId],
|
|
119
|
+
{
|
|
120
|
+
dependencies: [{ table: 'tasks', scopeKeys: [`project:${projectId}`] }],
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Options:
|
|
126
|
+
|
|
127
|
+
| Option | Meaning |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `dependencies` | Exact table-associated dependencies. |
|
|
130
|
+
| `coverage` | Required window units read atomically with the rows. |
|
|
131
|
+
| `rowKey` | Stable identity fields used to retain unchanged row objects. |
|
|
132
|
+
| `claimCoverage` | Claim declared coverage while observed; default `true`. |
|
|
133
|
+
| `enabled` | Disable observation and reads while `false`. |
|
|
134
|
+
| `id` | Stable cache identity for a raw query. |
|
|
135
|
+
| `tables`, `scopeKeys` | Compatibility shorthand; prefer `dependencies`. |
|
|
136
|
+
|
|
137
|
+
Without explicit dependencies, raw SQL uses a conservative `FROM`/`JOIN`
|
|
138
|
+
scan. Generated queries are preferred whenever the statement is known at
|
|
139
|
+
build time because typegen can prove more.
|
|
140
|
+
|
|
141
|
+
## Exact changes and windows
|
|
142
|
+
|
|
143
|
+
Both cores emit one revisioned `ClientChangeBatch` per observer transaction.
|
|
144
|
+
Table changes keep scope keys associated with their table; window changes can
|
|
145
|
+
complete a zero-row unit without inventing a row change; status/conflict-only
|
|
146
|
+
batches do not rerun SQL. Bridges forward this batch unchanged.
|
|
147
|
+
|
|
148
|
+
Generated query coverage uses composable claims. Multiple consumers of the
|
|
149
|
+
same window base contribute a union, and unmounting one removes only its own
|
|
150
|
+
units. `useWindow(base)` remains the lower-level imperative interface for
|
|
151
|
+
prefetching or dynamic query builders. Applications normally do not need it
|
|
152
|
+
beside a generated `useQuery`.
|
|
153
|
+
|
|
154
|
+
For a small known navigation working set,
|
|
155
|
+
`useRetainedWindow(base, units)` prefetches and retains those units through the
|
|
156
|
+
same coordinator. It returns `{ isPending, error }`, normalizes duplicate
|
|
157
|
+
units, and releases only its own claim on unmount, so application code does
|
|
158
|
+
not need a custom retention effect.
|
|
125
159
|
|
|
126
160
|
## Other hooks
|
|
127
161
|
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
immediately, and dependent `useRawSql`s re-run on the resulting batch.
|
|
139
|
-
|
|
140
|
-
## SSR
|
|
141
|
-
|
|
142
|
-
The hooks are SSR-safe: on the server they render their initial state and the
|
|
143
|
-
query fires only in the client-side mount effect. `renderToString` never
|
|
144
|
-
crashes.
|
|
145
|
-
|
|
146
|
-
## Design note — the window registry (forward-looking)
|
|
147
|
-
|
|
148
|
-
Per `DESIGN-eviction.md` I3, query bindings must be able to route a query's
|
|
149
|
-
scope footprint through the window registry once windowed sync (TODO §5
|
|
150
|
-
item 2) lands, so a query can report **completeness** (answerable from the
|
|
151
|
-
local replica vs a window miss). Today the registry trivially contains
|
|
152
|
-
"everything subscribed", so `useRawSql` always answers from the local
|
|
153
|
-
replica. The `scopeKeys` option is the seam through which per-scope
|
|
154
|
-
completeness will be surfaced without an API break.
|
|
162
|
+
- `useSyncStatus()` observes the status domain without a follow-up read after
|
|
163
|
+
every row change. `outbox` is local push work; `syncNeeded` specifically
|
|
164
|
+
means an inbound pull/catch-up signal.
|
|
165
|
+
- `useConflicts()` observes conflicts and rejections only when that domain
|
|
166
|
+
changes.
|
|
167
|
+
- `usePresence(scopeKey)` observes ephemeral realtime peers.
|
|
168
|
+
- `useSyncClient()` and `useReactiveStore()` expose the normalized low-level
|
|
169
|
+
surfaces for integrations.
|
|
170
|
+
|
|
171
|
+
The hooks are SSR-safe: no local query runs during server rendering.
|
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 { ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SqlRow, SqlValue, WindowBase, WindowState } from '@syncular/client';
|
|
14
|
+
import type { ClientChangeListener, ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, 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,10 +19,16 @@ import type { ConflictRecord, InvalidationListener, LeaseState, MutationInput, P
|
|
|
19
19
|
* never reach past this surface.
|
|
20
20
|
*/
|
|
21
21
|
export interface SyncClientLike {
|
|
22
|
+
onChange(listener: ClientChangeListener): () => void;
|
|
22
23
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
23
24
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
24
25
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[] | Promise<SqlRow[]>;
|
|
25
26
|
mutate(mutations: readonly MutationInput[]): string | Promise<string>;
|
|
27
|
+
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
28
|
+
readonly baseVersion?: number;
|
|
29
|
+
}): string | Promise<string>;
|
|
30
|
+
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
31
|
+
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
26
32
|
conflicts: readonly ConflictRecord[] | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
|
|
27
33
|
rejections: readonly RejectionRecord[] | (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
|
|
28
34
|
schemaFloor: SchemaFloor | undefined | (() => SchemaFloor | undefined | Promise<SchemaFloor | undefined>);
|
|
@@ -39,10 +45,16 @@ export interface SyncClientLike {
|
|
|
39
45
|
}
|
|
40
46
|
/** The uniform async facade the hooks actually call. */
|
|
41
47
|
export interface NormalizedClient {
|
|
48
|
+
onChange(listener: ClientChangeListener): () => void;
|
|
42
49
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
43
50
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
44
51
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
45
52
|
mutate(mutations: readonly MutationInput[]): Promise<string>;
|
|
53
|
+
patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
|
|
54
|
+
readonly baseVersion?: number;
|
|
55
|
+
}): Promise<string>;
|
|
56
|
+
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
57
|
+
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
46
58
|
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
47
59
|
rejections(): Promise<readonly RejectionRecord[]>;
|
|
48
60
|
schemaFloor(): Promise<SchemaFloor | undefined>;
|
package/dist/client.js
CHANGED
|
@@ -12,10 +12,14 @@ function resolveMember(client, key) {
|
|
|
12
12
|
}
|
|
13
13
|
export function normalizeClient(client) {
|
|
14
14
|
return {
|
|
15
|
+
onChange: (listener) => client.onChange(listener),
|
|
15
16
|
onInvalidate: (listener) => client.onInvalidate(listener),
|
|
16
17
|
onPresence: (listener) => client.onPresence(listener),
|
|
17
18
|
query: (sql, params) => Promise.resolve(client.query(sql, params)),
|
|
18
19
|
mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
|
|
20
|
+
patch: (table, rowId, partial, options) => Promise.resolve(client.patch(table, rowId, partial, options)),
|
|
21
|
+
querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
|
|
22
|
+
statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
|
|
19
23
|
conflicts: () => resolveMember(client, 'conflicts'),
|
|
20
24
|
rejections: () => resolveMember(client, 'rejections'),
|
|
21
25
|
schemaFloor: () => resolveMember(client, 'schemaFloor'),
|
package/dist/index.d.ts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
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 } from './provider.js';
|
|
14
|
-
export {
|
|
13
|
+
export { SyncContext, SyncProvider, type SyncProviderProps, SyncStoreContext, } from './provider.js';
|
|
14
|
+
export { createSyncClientResource, isSyncClientResource, type SyncClientResource, type SyncClientResourceSnapshot, } from './resource.js';
|
|
15
|
+
export { useReactiveStore, useSyncClient } from './use-client.js';
|
|
15
16
|
export { type UseConflictsResult, useConflicts } from './use-conflicts.js';
|
|
16
|
-
export { type UseMutationResult, useMutation } from './use-mutation.js';
|
|
17
|
+
export { type SyncTableDescriptor, type UseMutationOptions, type UseMutationResult, type UseTableMutationResult, useMutation, } from './use-mutation.js';
|
|
17
18
|
export { usePresence } from './use-presence.js';
|
|
18
19
|
export { type NamedQueryDescriptor, useQuery, } from './use-query.js';
|
|
19
20
|
export { type UseRawSqlOptions, type UseRawSqlResult, useRawSql, } from './use-raw-sql.js';
|
|
20
21
|
export { type SyncStatus, useSyncStatus } from './use-sync-status.js';
|
|
21
|
-
export { type UseWindowResult, useWindow } from './use-window.js';
|
|
22
|
+
export { type UseRetainedWindowResult, type UseWindowResult, useRetainedWindow, useWindow, } from './use-window.js';
|
package/dist/index.js
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// the exact normalizer the bindings use.
|
|
4
4
|
export { normalizeClient } from './client.js';
|
|
5
5
|
export { inferTables } from './infer-tables.js';
|
|
6
|
-
export { SyncContext, SyncProvider } from './provider.js';
|
|
7
|
-
export {
|
|
6
|
+
export { SyncContext, SyncProvider, SyncStoreContext, } from './provider.js';
|
|
7
|
+
export { createSyncClientResource, isSyncClientResource, } from './resource.js';
|
|
8
|
+
export { useReactiveStore, useSyncClient } from './use-client.js';
|
|
8
9
|
export { useConflicts } from './use-conflicts.js';
|
|
9
|
-
export { useMutation } from './use-mutation.js';
|
|
10
|
+
export { useMutation, } from './use-mutation.js';
|
|
10
11
|
export { usePresence } from './use-presence.js';
|
|
11
12
|
export { useQuery, } from './use-query.js';
|
|
12
13
|
export { useRawSql, } from './use-raw-sql.js';
|
|
13
14
|
export { useSyncStatus } from './use-sync-status.js';
|
|
14
|
-
export { useWindow } from './use-window.js';
|
|
15
|
+
export { useRetainedWindow, useWindow, } from './use-window.js';
|
package/dist/provider.d.ts
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
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
9
|
import { type ReactNode } from 'react';
|
|
9
10
|
import { type NormalizedClient, type SyncClientLike } from './client.js';
|
|
11
|
+
import { type SyncClientResource } from './resource.js';
|
|
10
12
|
export declare const SyncContext: import("react").Context<NormalizedClient | undefined>;
|
|
13
|
+
export declare const SyncStoreContext: import("react").Context<ReactiveClientStore | undefined>;
|
|
11
14
|
export interface SyncProviderProps {
|
|
12
15
|
/** A `SyncClient` (direct) or `SyncClientHandle` (worker) — both satisfy it. */
|
|
13
|
-
readonly client: SyncClientLike;
|
|
16
|
+
readonly client: SyncClientLike | SyncClientResource;
|
|
14
17
|
readonly children?: ReactNode;
|
|
18
|
+
readonly fallback?: ReactNode;
|
|
19
|
+
readonly renderError?: (error: Error) => ReactNode;
|
|
15
20
|
}
|
|
16
21
|
export declare function SyncProvider(props: SyncProviderProps): ReactNode;
|
package/dist/provider.js
CHANGED
|
@@ -5,11 +5,60 @@
|
|
|
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 { ReactiveClientStore } from '@syncular/client';
|
|
9
|
+
import { createContext, createElement, useEffect, useMemo, useSyncExternalStore, } from 'react';
|
|
9
10
|
import { normalizeClient, } from './client.js';
|
|
11
|
+
import { isSyncClientResource } from './resource.js';
|
|
10
12
|
export const SyncContext = createContext(undefined);
|
|
13
|
+
export const SyncStoreContext = createContext(undefined);
|
|
14
|
+
const stores = new WeakMap();
|
|
15
|
+
function recordFor(client) {
|
|
16
|
+
const key = client;
|
|
17
|
+
let record = stores.get(key);
|
|
18
|
+
if (record === undefined) {
|
|
19
|
+
const normalized = normalizeClient(client);
|
|
20
|
+
record = {
|
|
21
|
+
normalized,
|
|
22
|
+
store: new ReactiveClientStore(normalized),
|
|
23
|
+
refs: 0,
|
|
24
|
+
};
|
|
25
|
+
stores.set(key, record);
|
|
26
|
+
}
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
function ReadySyncProvider(props) {
|
|
30
|
+
const record = useMemo(() => recordFor(props.client), [props.client]);
|
|
31
|
+
const { normalized, store } = record;
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
record.refs += 1;
|
|
34
|
+
store.start();
|
|
35
|
+
return () => {
|
|
36
|
+
record.refs -= 1;
|
|
37
|
+
queueMicrotask(() => {
|
|
38
|
+
if (record.refs === 0)
|
|
39
|
+
store.dispose();
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
}, [record, store]);
|
|
43
|
+
return createElement(SyncContext.Provider, { value: normalized }, createElement(SyncStoreContext.Provider, { value: store }, props.children));
|
|
44
|
+
}
|
|
45
|
+
const noSubscribe = () => () => { };
|
|
11
46
|
export function SyncProvider(props) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
47
|
+
const resource = isSyncClientResource(props.client)
|
|
48
|
+
? props.client
|
|
49
|
+
: undefined;
|
|
50
|
+
const readySnapshot = useMemo(() => resource === undefined
|
|
51
|
+
? { phase: 'ready', client: props.client }
|
|
52
|
+
: undefined, [props.client, resource]);
|
|
53
|
+
const snapshot = useSyncExternalStore(resource?.subscribe ?? noSubscribe, resource?.getSnapshot ??
|
|
54
|
+
(() => readySnapshot), resource?.getSnapshot ??
|
|
55
|
+
(() => readySnapshot));
|
|
56
|
+
if (snapshot.phase === 'pending')
|
|
57
|
+
return props.fallback ?? null;
|
|
58
|
+
if (snapshot.phase === 'error') {
|
|
59
|
+
if (props.renderError !== undefined)
|
|
60
|
+
return props.renderError(snapshot.error);
|
|
61
|
+
throw snapshot.error;
|
|
62
|
+
}
|
|
63
|
+
return createElement(ReadySyncProvider, { client: snapshot.client }, props.children);
|
|
15
64
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SyncClientLike } from './client.js';
|
|
2
|
+
export type SyncClientResourceSnapshot = {
|
|
3
|
+
readonly phase: 'pending';
|
|
4
|
+
} | {
|
|
5
|
+
readonly phase: 'ready';
|
|
6
|
+
readonly client: SyncClientLike;
|
|
7
|
+
} | {
|
|
8
|
+
readonly phase: 'error';
|
|
9
|
+
readonly error: Error;
|
|
10
|
+
};
|
|
11
|
+
export interface SyncClientResource {
|
|
12
|
+
readonly kind: 'syncular-client-resource';
|
|
13
|
+
subscribe(listener: () => void): () => void;
|
|
14
|
+
getSnapshot(): SyncClientResourceSnapshot;
|
|
15
|
+
dispose(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createSyncClientResource(factory: () => SyncClientLike | Promise<SyncClientLike>): SyncClientResource;
|
|
18
|
+
export declare function isSyncClientResource(value: SyncClientLike | SyncClientResource): value is SyncClientResource;
|
package/dist/resource.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function createSyncClientResource(factory) {
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
let snapshot = { phase: 'pending' };
|
|
4
|
+
let disposed = false;
|
|
5
|
+
let closed = false;
|
|
6
|
+
const initialized = Promise.resolve()
|
|
7
|
+
.then(factory)
|
|
8
|
+
.then(async (client) => {
|
|
9
|
+
if (disposed) {
|
|
10
|
+
const close = client
|
|
11
|
+
.close;
|
|
12
|
+
if (close !== undefined && !closed) {
|
|
13
|
+
closed = true;
|
|
14
|
+
await close.call(client);
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
snapshot = { phase: 'ready', client };
|
|
19
|
+
for (const listener of listeners)
|
|
20
|
+
listener();
|
|
21
|
+
}, (error) => {
|
|
22
|
+
if (disposed)
|
|
23
|
+
return;
|
|
24
|
+
snapshot = {
|
|
25
|
+
phase: 'error',
|
|
26
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
27
|
+
};
|
|
28
|
+
for (const listener of listeners)
|
|
29
|
+
listener();
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
kind: 'syncular-client-resource',
|
|
33
|
+
subscribe(listener) {
|
|
34
|
+
listeners.add(listener);
|
|
35
|
+
return () => listeners.delete(listener);
|
|
36
|
+
},
|
|
37
|
+
getSnapshot: () => snapshot,
|
|
38
|
+
async dispose() {
|
|
39
|
+
if (disposed)
|
|
40
|
+
return;
|
|
41
|
+
disposed = true;
|
|
42
|
+
await initialized;
|
|
43
|
+
if (snapshot.phase === 'ready' && !closed) {
|
|
44
|
+
const close = snapshot.client.close;
|
|
45
|
+
if (close !== undefined) {
|
|
46
|
+
closed = true;
|
|
47
|
+
await close.call(snapshot.client);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
listeners.clear();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function isSyncClientResource(value) {
|
|
55
|
+
return value.kind === 'syncular-client-resource';
|
|
56
|
+
}
|
package/dist/use-client.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ReactiveClientStore } from '@syncular/client';
|
|
1
2
|
import type { NormalizedClient } from './client.js';
|
|
2
3
|
/** Read the normalized client from context; throws outside a `SyncProvider`. */
|
|
3
4
|
export declare function useSyncClient(): NormalizedClient;
|
|
5
|
+
export declare function useReactiveStore(): ReactiveClientStore;
|
package/dist/use-client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useContext } from 'react';
|
|
2
|
-
import { SyncContext } from './provider.js';
|
|
2
|
+
import { SyncContext, SyncStoreContext } from './provider.js';
|
|
3
3
|
/** Read the normalized client from context; throws outside a `SyncProvider`. */
|
|
4
4
|
export function useSyncClient() {
|
|
5
5
|
const client = useContext(SyncContext);
|
|
@@ -8,3 +8,10 @@ export function useSyncClient() {
|
|
|
8
8
|
}
|
|
9
9
|
return client;
|
|
10
10
|
}
|
|
11
|
+
export function useReactiveStore() {
|
|
12
|
+
const store = useContext(SyncStoreContext);
|
|
13
|
+
if (store === undefined) {
|
|
14
|
+
throw new Error('@syncular/react: no client in context (reactive store unavailable) — wrap your tree in <SyncProvider client={…}>');
|
|
15
|
+
}
|
|
16
|
+
return store;
|
|
17
|
+
}
|
package/dist/use-conflicts.d.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useConflicts` — the accumulated conflict records (§6.2/§6.5) and
|
|
3
|
-
* rejections (§6.3) the client has surfaced. Re-read after every apply
|
|
4
|
-
* batch (a push result lands through the same choke point) plus on mount.
|
|
5
|
-
*/
|
|
6
1
|
import type { ConflictRecord, RejectionRecord } from '@syncular/client';
|
|
7
2
|
export interface UseConflictsResult {
|
|
8
3
|
readonly conflicts: readonly ConflictRecord[];
|
|
9
4
|
readonly rejections: readonly RejectionRecord[];
|
|
5
|
+
readonly isLoading: boolean;
|
|
6
|
+
readonly error: Error | undefined;
|
|
10
7
|
readonly refresh: () => void;
|
|
11
8
|
}
|
|
12
9
|
export declare function useConflicts(): UseConflictsResult;
|
package/dist/use-conflicts.js
CHANGED
|
@@ -1,37 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* rejections (§6.3) the client has surfaced. Re-read after every apply
|
|
4
|
-
* batch (a push result lands through the same choke point) plus on mount.
|
|
5
|
-
*/
|
|
6
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
7
|
-
import { useSyncClient } from './use-client.js';
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useReactiveStore } from './use-client.js';
|
|
8
3
|
export function useConflicts() {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (cancelled)
|
|
20
|
-
return;
|
|
21
|
-
setConflicts(c);
|
|
22
|
-
setRejections(r);
|
|
23
|
-
})
|
|
24
|
-
.catch(() => {
|
|
25
|
-
/* transient read failure — the next batch re-reads */
|
|
26
|
-
});
|
|
27
|
-
};
|
|
28
|
-
readRef.current = read;
|
|
29
|
-
read();
|
|
30
|
-
const unsubscribe = client.onInvalidate(read);
|
|
31
|
-
return () => {
|
|
32
|
-
cancelled = true;
|
|
33
|
-
unsubscribe();
|
|
34
|
-
};
|
|
35
|
-
}, [client]);
|
|
36
|
-
return { conflicts, rejections, refresh };
|
|
4
|
+
const entry = useReactiveStore().conflicts;
|
|
5
|
+
const snapshot = useSyncExternalStore(entry.subscribe, entry.getSnapshot, entry.getSnapshot);
|
|
6
|
+
const refresh = useCallback(() => entry.refresh(), [entry]);
|
|
7
|
+
return {
|
|
8
|
+
conflicts: snapshot.conflicts,
|
|
9
|
+
rejections: snapshot.rejections,
|
|
10
|
+
isLoading: snapshot.isLoading,
|
|
11
|
+
error: snapshot.error,
|
|
12
|
+
refresh,
|
|
13
|
+
};
|
|
37
14
|
}
|