@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/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +146 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.cjs +0 -0
- package/dist/internal.d.cts +1 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +0 -0
- package/dist/mobile.cjs +1 -0
- package/dist/mobile.d.cts +13 -0
- package/dist/mobile.d.cts.map +1 -0
- package/dist/mobile.d.ts +13 -0
- package/dist/mobile.d.ts.map +1 -0
- package/dist/mobile.js +2 -0
- package/dist/mobile.js.map +1 -0
- package/dist/use-client-D1I3o0t5.cjs +1 -0
- package/dist/use-client-DanwzoBF.js +2 -0
- package/dist/use-client-DanwzoBF.js.map +1 -0
- package/dist/web.cjs +1 -0
- package/dist/web.d.cts +13 -0
- package/dist/web.d.cts.map +1 -0
- package/dist/web.d.ts +13 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +2 -0
- package/dist/web.js.map +1 -0
- package/package.json +104 -0
- package/src/hooks.tsx +442 -0
- package/src/index.tsx +61 -0
- package/src/internal.ts +11 -0
- package/src/mobile.ts +68 -0
- package/src/provider.tsx +35 -0
- package/src/use-client.ts +299 -0
- package/src/web.ts +71 -0
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'
|
package/src/internal.ts
ADDED
|
@@ -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 }
|
package/src/provider.tsx
ADDED
|
@@ -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 }
|