@doync/react 0.1.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/src/index.tsx ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `@doync/react` — thin hooks over the {@link DoyncClient} call surface
3
+ * (`subscribe` / `once` / `local` / `mutate`), plus the platform-agnostic
4
+ * {@link useClient} lifecycle hook. Render may compute (mint a handle — pure
5
+ * and discardable); only commit may own (`retain` in a mount effect, `release`
6
+ * on cleanup). Row and status subscriptions use `useSyncExternalStore` over a
7
+ * handle whose `current()` returns a stable array reference until the rows
8
+ * move. StrictMode and abandoned renders own nothing by construction.
9
+ *
10
+ * Environment packages stay out of this entry: `useMobileClient` and
11
+ * `useWebClient` live on `@doync/react/mobile` and `@doync/react/web` with
12
+ * `@doync/mobile` / `@doync/web` as optional peers. Pass any
13
+ * {@link DoyncClient} (or the lifecycle hook's return) to `<DoyncProvider>`.
14
+ *
15
+ * Query-taking hooks accept a {@link BoundQuery} (or falsy = "no query"):
16
+ * `useQuery(queries.issues.open(args))`. Binding is pure — a fresh BoundQuery
17
+ * per render does not bust memoization (the key is name + JSON args). Falsy
18
+ * keeps stable hook order with no subscription.
19
+ */
20
+
21
+ export {
22
+ DoyncProvider,
23
+ type DoyncProviderProps,
24
+ useDoyncClient,
25
+ } from './provider'
26
+ export {
27
+ type LocalSource,
28
+ type OnceStatus,
29
+ useConnectionStatus,
30
+ useLocalQuery,
31
+ useMutation,
32
+ useQuery,
33
+ useQueryOnce,
34
+ useSchemaStatus,
35
+ type UseQueryOptions,
36
+ } from './hooks'
37
+ export {
38
+ useClient,
39
+ type ClientLifecycle,
40
+ type ClientOptionPolicy,
41
+ type OptionDisposition,
42
+ } from './use-client'
43
+ // Client call-surface family — apps import from here, never `@doync/client` (#269).
44
+ export type {
45
+ ConnectionStatus,
46
+ DoyncClient,
47
+ FalsyQuery,
48
+ LogoutBehavior,
49
+ MutationOptions,
50
+ MutationResult,
51
+ OnceView,
52
+ PreloadHandle,
53
+ PreloadOptions,
54
+ QueryStatus,
55
+ SchemaEvent,
56
+ SchemaEventKind,
57
+ SubscribeOptions,
58
+ View,
59
+ ViewStatus,
60
+ } from '@doync/client'
61
+ export type { BoundQuery } from '@doync/core'
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@doync/react/internal` (ADR-0033): placeholder entry — the current public
3
+ * surface stands. Kept so the nine-package `./internal` contract is uniform.
4
+ * May break in any release. Import React hooks/types from `@doync/react`.
5
+ *
6
+ * Main entry is `index.tsx` (TSX adapter); this file is extension-less so tsc
7
+ * resolves it the same way sibling imports do (allowImportingTsExtensions is
8
+ * off).
9
+ */
10
+
11
+ export {}
package/src/mobile.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `@doync/react/mobile` — {@link useMobileClient}, a thin wrapper around
3
+ * {@link useClient} that supplies `createMobileClient` and the mobile option
4
+ * policy. Imports `@doync/mobile` as an optional peer of `@doync/react`, so a
5
+ * web-only app never pulls it in.
6
+ *
7
+ * ```ts
8
+ * import { useMobileClient } from '@doync/react/mobile'
9
+ *
10
+ * const client = useMobileClient(
11
+ * id
12
+ * ? {
13
+ * name: id,
14
+ * schema,
15
+ * queries,
16
+ * mutations,
17
+ * url: 'wss://example.com/sync',
18
+ * token,
19
+ * userId,
20
+ * ctx,
21
+ * }
22
+ * : null,
23
+ * )
24
+ * // pass client to <DoyncProvider> once non-null
25
+ * ```
26
+ */
27
+
28
+ import type { CreateMobileClientOptions, MobileClient } from '@doync/mobile'
29
+
30
+ import { createMobileClient } from '@doync/mobile'
31
+
32
+ import { useClient, type ClientOptionPolicy } from './use-client'
33
+
34
+ /**
35
+ * Mobile option dispositions (ADR-0034). Schema / queries / mutations (and any
36
+ * monorepo-only injects on `CreateMobileClientOptionsForTest`) are
37
+ * construction- time constants — the hook does not police them (absent from the
38
+ * policy = not compared).
39
+ *
40
+ * `url` is throw-on-change: a config bug cannot silently reconnect elsewhere.
41
+ * There is no `worker` on mobile (single process).
42
+ */
43
+ const MOBILE_CLIENT_POLICY: ClientOptionPolicy<CreateMobileClientOptions> = {
44
+ name: 'identity',
45
+ url: 'throw',
46
+ token: 'auth',
47
+ userId: 'auth',
48
+ ctx: 'auth',
49
+ logoutBehavior: 'logoutBehavior',
50
+ }
51
+
52
+ /**
53
+ * Hold a {@link MobileClient} for as long as `options` is non-null. Auth and
54
+ * logout-behavior changes apply in place; a `name` change recreates the client;
55
+ * a `url` change throws. Pass `null` until the database id is known, then hand
56
+ * the result to `<DoyncProvider>`.
57
+ */
58
+ export function useMobileClient<TAuthData = unknown>(
59
+ options: CreateMobileClientOptions<TAuthData> | null,
60
+ ): MobileClient | null {
61
+ return useClient(
62
+ options as CreateMobileClientOptions | null,
63
+ createMobileClient,
64
+ MOBILE_CLIENT_POLICY as ClientOptionPolicy<CreateMobileClientOptions>,
65
+ )
66
+ }
67
+
68
+ export type { CreateMobileClientOptions, MobileClient }
@@ -0,0 +1,35 @@
1
+ import type { DoyncClient } from '@doync/client'
2
+ import type { ReactElement, ReactNode } from 'react'
3
+
4
+ import { createContext, useContext } from 'react'
5
+
6
+ const DoyncContext = createContext<DoyncClient | null>(null)
7
+
8
+ /** Props for {@link DoyncProvider}. */
9
+ export interface DoyncProviderProps {
10
+ /** Client from `createWebClient` / `createMobileClient` (or a test double). */
11
+ readonly client: DoyncClient
12
+ readonly children: ReactNode
13
+ }
14
+
15
+ /** Provide a {@link DoyncClient} to `useQuery`, `useMutation`, and siblings. */
16
+ export function DoyncProvider({
17
+ client,
18
+ children,
19
+ }: DoyncProviderProps): ReactElement {
20
+ return (
21
+ <DoyncContext.Provider value={client}>{children}</DoyncContext.Provider>
22
+ )
23
+ }
24
+
25
+ /**
26
+ * {@link DoyncClient} from the nearest {@link DoyncProvider}. Throws if used
27
+ * outside a provider.
28
+ */
29
+ export function useDoyncClient(): DoyncClient {
30
+ const client = useContext(DoyncContext)
31
+ if (client === null) {
32
+ throw new Error('doync: a doync hook was used outside a <DoyncProvider>')
33
+ }
34
+ return client
35
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Generic client lifecycle hook. Holds a client for as long as the options bag
3
+ * is non-null and routes option changes onto `createClient` / `close` /
4
+ * `setAuth` / `setLogoutBehavior`, or throws — never invents its own lifecycle.
5
+ * Platform wrappers (`useMobileClient`, `useWebClient`) supply the factory and
6
+ * a {@link ClientOptionPolicy}; this module imports no platform package.
7
+ *
8
+ * Options are compared by value (fresh object literals every render are fine;
9
+ * no `useMemo` required). `ctx` is compared by JSON. StrictMode double-mount is
10
+ * create → close → create and is safe: ownership lives in a layout-effect
11
+ * cleanup, not in render.
12
+ */
13
+
14
+ import type { LogoutBehavior } from '@doync/client'
15
+
16
+ import { useLayoutEffect, useRef, useState } from 'react'
17
+
18
+ /**
19
+ * Minimum call surface the hook routes onto. Platform clients (`MobileClient`,
20
+ * `WebClient`) both satisfy this; tests inject a fake.
21
+ */
22
+ export interface ClientLifecycle {
23
+ close(): void
24
+ setAuth(auth: {
25
+ token: string | null
26
+ ctx?: unknown
27
+ userId: string | null
28
+ }): void
29
+ setLogoutBehavior(behavior: LogoutBehavior): void
30
+ }
31
+
32
+ /**
33
+ * How a single options-bag key is handled when its value changes between
34
+ * renders. Keys absent from the policy are not compared (construction-only
35
+ * values like `schema` / `queries` — module-level constants in real apps).
36
+ *
37
+ * - `identity` — close the old client and create a new one (e.g. a different
38
+ * database `name` is a different client; no in-place path).
39
+ * - `auth` — `token` / `userId` / `ctx` feed a single `setAuth` call (`ctx` is
40
+ * compared by JSON).
41
+ * - `logoutBehavior` — call `setLogoutBehavior`.
42
+ * - `throw` — value change throws (e.g. `url`). Checked before `identity`, so a
43
+ * same-render name+url swap cannot silently reconnect elsewhere.
44
+ * - `ignore` — captured at creation; later changes are ignored (e.g. web's
45
+ * `worker` factory, whose required inline-literal form makes the reference
46
+ * unstable across renders).
47
+ */
48
+ export type OptionDisposition =
49
+ | 'identity'
50
+ | 'auth'
51
+ | 'logoutBehavior'
52
+ | 'throw'
53
+ | 'ignore'
54
+
55
+ /**
56
+ * Per-platform table mapping option keys to {@link OptionDisposition}. Keys not
57
+ * listed are not compared; unknown keys on the options bag are ignored.
58
+ */
59
+ export type ClientOptionPolicy<TOptions extends object> = {
60
+ readonly [K in keyof TOptions]?: OptionDisposition
61
+ }
62
+
63
+ /** Live ownership slot shared by the diff effect and the unmount cleanup. */
64
+ interface Owned<TOptions, TClient> {
65
+ client: TClient | null
66
+ options: TOptions | null
67
+ }
68
+
69
+ // ADR-0034 / closeio/doync#281 — routes onto create/close/setAuth/
70
+ // setLogoutBehavior; throw-before-identity; name-swap recreates.
71
+ /**
72
+ * Hold a client for as long as `options` is non-null. Pass `null` until the
73
+ * database id is known (e.g. after a create-session mutation acks). Option
74
+ * changes route onto client verbs per `policy` — see
75
+ * {@link OptionDisposition}.
76
+ */
77
+ export function useClient<
78
+ TOptions extends object,
79
+ TClient extends ClientLifecycle,
80
+ >(
81
+ options: TOptions | null,
82
+ createClient: (options: TOptions) => TClient,
83
+ policy: ClientOptionPolicy<TOptions>,
84
+ ): TClient | null {
85
+ const [client, setClient] = useState<TClient | null>(null)
86
+ // Ownership lives in a ref so the unmount cleanup and the diff path share one
87
+ // source of truth without putting unstable identities into effect deps.
88
+ const owned = useRef<Owned<TOptions, TClient>>({
89
+ client: null,
90
+ options: null,
91
+ })
92
+ const createRef = useRef(createClient)
93
+ createRef.current = createClient
94
+ const policyRef = useRef(policy)
95
+ policyRef.current = policy
96
+
97
+ // Diff-and-route runs after every commit. Equal-by-value options are a no-op
98
+ // so a fresh options literal every render neither recreates nor re-verbs.
99
+ // Creation/teardown ownership lives here — NOT in render — so StrictMode's
100
+ // double-mount is create → cleanup-close → create, and nothing leaks.
101
+ useLayoutEffect(() => {
102
+ const prev = owned.current.options
103
+ const next = options
104
+ const current = owned.current.client
105
+ const create = createRef.current
106
+ const pol = policyRef.current
107
+
108
+ if (next !== null && prev !== null && current !== null) {
109
+ if (optionsEqualByPolicy(prev, next, pol)) {
110
+ // Keep the latest reference so subsequent diffs start from what the
111
+ // caller last passed (in case a nested object was mutated — rare; the
112
+ // equality check already saw them as equal).
113
+ owned.current.options = next
114
+ return
115
+ }
116
+ const created = applyChange(prev, next, current, create, pol)
117
+ if (created !== null) {
118
+ owned.current = { client: created, options: next }
119
+ setClient(created)
120
+ } else {
121
+ // In-place verb path: live client kept, snapshot advances.
122
+ owned.current.options = next
123
+ }
124
+ return
125
+ }
126
+
127
+ if (next !== null && (prev === null || current === null)) {
128
+ // null → options (or remount after StrictMode cleanup cleared the refs).
129
+ const created = create(next)
130
+ owned.current = { client: created, options: next }
131
+ setClient(created)
132
+ return
133
+ }
134
+
135
+ if (next === null && current !== null) {
136
+ // options → null: close and drop.
137
+ current.close()
138
+ owned.current = { client: null, options: null }
139
+ setClient(null)
140
+ }
141
+ })
142
+
143
+ // Unmount-only cleanup. Separate effect so the diff path above can re-run on
144
+ // every options-change commit WITHOUT closing between equal renders.
145
+ useLayoutEffect(() => {
146
+ return () => {
147
+ owned.current.client?.close()
148
+ owned.current = { client: null, options: null }
149
+ }
150
+ }, [])
151
+
152
+ return client
153
+ }
154
+
155
+ // ── internals ──────────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Route a non-equal options change onto existing verbs. Returns a freshly
159
+ * created client when identity keys moved (after closing the old one); returns
160
+ * null when the live client was kept (auth / logoutBehavior routing, or throw
161
+ * aborted before any mutation — throw propagates).
162
+ */
163
+ function applyChange<TOptions extends object, TClient extends ClientLifecycle>(
164
+ prev: TOptions,
165
+ next: TOptions,
166
+ current: TClient,
167
+ create: (options: TOptions) => TClient,
168
+ policy: ClientOptionPolicy<TOptions>,
169
+ ): TClient | null {
170
+ // Throw-on-change FIRST (url): a config bug must never silently reconnect
171
+ // elsewhere, even when bundled with a name swap that would otherwise rebuild.
172
+ for (const key of policyKeys(policy)) {
173
+ if (policy[key] !== 'throw') continue
174
+ if (!valueEqual(prev[key], next[key])) {
175
+ throw new Error(
176
+ `doync: useClient cannot change ${String(key)} after creation — close and recreate with the new value`,
177
+ )
178
+ }
179
+ }
180
+
181
+ // Identity keys — a name swap is a different Client; no in-place path. Fresh
182
+ // create carries the full next bag (auth/logout already in the new options).
183
+ for (const key of policyKeys(policy)) {
184
+ if (policy[key] !== 'identity') continue
185
+ if (!valueEqual(prev[key], next[key])) {
186
+ current.close()
187
+ return create(next)
188
+ }
189
+ }
190
+
191
+ // Auth triple — one setAuth if any of token/userId/ctx drifted.
192
+ const auth = resolveAuthTriple(prev, next, policy)
193
+ if (auth !== null) current.setAuth(auth)
194
+
195
+ // logoutBehavior — only call when it actually moved.
196
+ for (const key of policyKeys(policy)) {
197
+ if (policy[key] !== 'logoutBehavior') continue
198
+ if (!valueEqual(prev[key], next[key])) {
199
+ const behavior = next[key] as LogoutBehavior | undefined
200
+ if (behavior !== undefined) current.setLogoutBehavior(behavior)
201
+ }
202
+ }
203
+
204
+ return null
205
+ }
206
+
207
+ /**
208
+ * Returns the asserted auth triple when any policy-`auth` key moved, else null.
209
+ * setAuth's contract is a full identity assert, never a patch — fall back to
210
+ * prev for any auth key the next bag omitted.
211
+ */
212
+ function resolveAuthTriple<TOptions extends object>(
213
+ prev: TOptions,
214
+ next: TOptions,
215
+ policy: ClientOptionPolicy<TOptions>,
216
+ ): {
217
+ token: string | null
218
+ userId: string | null
219
+ ctx?: unknown
220
+ } | null {
221
+ let changed = false
222
+ let sawToken = false
223
+ let sawUserId = false
224
+ let sawCtx = false
225
+ let token: unknown
226
+ let userId: unknown
227
+ let ctx: unknown
228
+ for (const key of policyKeys(policy)) {
229
+ if (policy[key] !== 'auth') continue
230
+ const pk = key as keyof TOptions & string
231
+ const serialize = pk === 'ctx'
232
+ if (!valueEqual(prev[key], next[key], serialize)) changed = true
233
+ if (pk === 'token') {
234
+ sawToken = true
235
+ token = next[key]
236
+ } else if (pk === 'userId') {
237
+ sawUserId = true
238
+ userId = next[key]
239
+ } else if (pk === 'ctx') {
240
+ sawCtx = true
241
+ ctx = next[key]
242
+ }
243
+ }
244
+ if (!changed) return null
245
+ const auth: {
246
+ token: string | null
247
+ userId: string | null
248
+ ctx?: unknown
249
+ } = {
250
+ token: sawToken
251
+ ? ((token as string | null | undefined) ?? null)
252
+ : ((prev as { token?: string | null }).token ?? null),
253
+ userId: sawUserId
254
+ ? ((userId as string | null | undefined) ?? null)
255
+ : ((prev as { userId?: string | null }).userId ?? null),
256
+ }
257
+ if (sawCtx) auth.ctx = ctx
258
+ else if ('ctx' in (prev as object)) {
259
+ auth.ctx = (prev as { ctx?: unknown }).ctx
260
+ }
261
+ return auth
262
+ }
263
+
264
+ function optionsEqualByPolicy<TOptions extends object>(
265
+ a: TOptions,
266
+ b: TOptions,
267
+ policy: ClientOptionPolicy<TOptions>,
268
+ ): boolean {
269
+ for (const key of policyKeys(policy)) {
270
+ const disposition = policy[key]
271
+ if (disposition === undefined || disposition === 'ignore') continue
272
+ const serialize = disposition === 'auth' && key === 'ctx'
273
+ if (!valueEqual(a[key], b[key], serialize)) return false
274
+ }
275
+ return true
276
+ }
277
+
278
+ function valueEqual(a: unknown, b: unknown, serialize = false): boolean {
279
+ // ctx compared serialized (shared-hub posture, ADR-0034) when asked.
280
+ if (serialize) return jsonStable(a) === jsonStable(b)
281
+ return Object.is(a, b)
282
+ }
283
+
284
+ function jsonStable(value: unknown): string {
285
+ try {
286
+ return JSON.stringify(value) ?? 'undefined'
287
+ } catch {
288
+ // Non-serializable ctx: fall back to type tag so a throw here never
289
+ // bubbles out of a compare. Reference identity is lost — non-JSON ctx is
290
+ // already outside the hub's contract.
291
+ return `\0ref:${typeof value}`
292
+ }
293
+ }
294
+
295
+ function policyKeys<TOptions extends object>(
296
+ policy: ClientOptionPolicy<TOptions>,
297
+ ): (keyof TOptions)[] {
298
+ return Object.keys(policy) as (keyof TOptions)[]
299
+ }
package/src/web.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `@doync/react/web` — {@link useWebClient}, a thin wrapper around
3
+ * {@link useClient} that supplies `createWebClient` and the web option policy.
4
+ * Imports `@doync/web` as an optional peer of `@doync/react`, so a mobile-only
5
+ * app never pulls it in.
6
+ *
7
+ * ```ts
8
+ * import { useWebClient } from '@doync/react/web'
9
+ *
10
+ * const client = useWebClient(
11
+ * id
12
+ * ? {
13
+ * name: id,
14
+ * worker: () =>
15
+ * new Worker(new URL('./worker.ts', import.meta.url), {
16
+ * type: 'module',
17
+ * }),
18
+ * url: 'wss://example.com/sync',
19
+ * token,
20
+ * userId,
21
+ * ctx,
22
+ * }
23
+ * : null,
24
+ * )
25
+ * // pass client to <DoyncProvider> once non-null
26
+ * ```
27
+ */
28
+
29
+ import type { CreateWebClientOptions, WebClient } from '@doync/web'
30
+
31
+ import { createWebClient } from '@doync/web'
32
+
33
+ import { useClient, type ClientOptionPolicy } from './use-client'
34
+
35
+ /**
36
+ * Web option dispositions (ADR-0034). Schema / queries / mutations (and any
37
+ * monorepo-only injects on `CreateWebClientOptionsForTest`) are construction-
38
+ * time constants — the hook does not police them (absent from the policy = not
39
+ * compared).
40
+ *
41
+ * `url` is throw-on-change: a config bug cannot silently reconnect elsewhere.
42
+ * `worker` is IGNORE: captured at creation; later changes are ignored — its
43
+ * required inline-literal form makes the reference unstable across renders.
44
+ */
45
+ const WEB_CLIENT_POLICY: ClientOptionPolicy<CreateWebClientOptions> = {
46
+ name: 'identity',
47
+ url: 'throw',
48
+ token: 'auth',
49
+ userId: 'auth',
50
+ ctx: 'auth',
51
+ logoutBehavior: 'logoutBehavior',
52
+ worker: 'ignore',
53
+ }
54
+
55
+ /**
56
+ * Hold a {@link WebClient} for as long as `options` is non-null. Auth and
57
+ * logout-behavior changes apply in place; a `name` change recreates the client;
58
+ * a `url` change throws; `worker` is fixed at creation. Pass `null` until the
59
+ * database id is known, then hand the result to `<DoyncProvider>`.
60
+ */
61
+ export function useWebClient<TAuthData = unknown>(
62
+ options: CreateWebClientOptions<TAuthData> | null,
63
+ ): WebClient | null {
64
+ return useClient(
65
+ options as CreateWebClientOptions | null,
66
+ createWebClient,
67
+ WEB_CLIENT_POLICY as ClientOptionPolicy<CreateWebClientOptions>,
68
+ )
69
+ }
70
+
71
+ export type { CreateWebClientOptions, WebClient }