@hanzo/base 0.2.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/dist/chunk-5NHFZRMO.js +1011 -0
- package/dist/chunk-5NHFZRMO.js.map +1 -0
- package/dist/chunk-LBAV5X5P.js +996 -0
- package/dist/chunk-LBAV5X5P.js.map +1 -0
- package/dist/compat/index.d.ts +1 -0
- package/dist/compat/index.js +3 -0
- package/dist/compat/index.js.map +1 -0
- package/dist/core/index.d.ts +368 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/crdt/index.d.ts +372 -0
- package/dist/crdt/index.js +3 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/react/index.d.ts +144 -0
- package/dist/react/index.js +283 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +71 -0
- package/src/compat/index.ts +24 -0
- package/src/core/client.ts +432 -0
- package/src/core/collection.ts +474 -0
- package/src/core/index.ts +37 -0
- package/src/core/realtime.ts +303 -0
- package/src/core/state.ts +112 -0
- package/src/core/store.ts +241 -0
- package/src/crdt/clock.ts +78 -0
- package/src/crdt/counter.ts +130 -0
- package/src/crdt/document.ts +194 -0
- package/src/crdt/index.ts +56 -0
- package/src/crdt/operations.ts +101 -0
- package/src/crdt/register.ts +106 -0
- package/src/crdt/set.ts +172 -0
- package/src/crdt/sync.ts +412 -0
- package/src/crdt/text.ts +274 -0
- package/src/react/context.tsx +87 -0
- package/src/react/hooks.ts +489 -0
- package/src/react/index.ts +56 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hooks for Hanzo Base.
|
|
3
|
+
*
|
|
4
|
+
* All hooks use the BaseClient from the nearest BaseProvider context.
|
|
5
|
+
* They manage subscriptions, cleanup, and state transitions correctly
|
|
6
|
+
* with React 19 patterns (useEffect, useState, useCallback, useRef).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
useState,
|
|
11
|
+
useEffect,
|
|
12
|
+
useCallback,
|
|
13
|
+
useRef,
|
|
14
|
+
} from 'react'
|
|
15
|
+
|
|
16
|
+
import { useBase } from './context.js'
|
|
17
|
+
import type { BaseRecord } from '../core/state.js'
|
|
18
|
+
import type { ListOptions, AuthStore } from '../core/client.js'
|
|
19
|
+
import type { RealtimeEvent, ConnectionState } from '../core/realtime.js'
|
|
20
|
+
import type { CRDTDocument } from '../crdt/document.js'
|
|
21
|
+
import type { CRDTText } from '../crdt/text.js'
|
|
22
|
+
import type { CRDTCounter } from '../crdt/counter.js'
|
|
23
|
+
import type { CRDTSet } from '../crdt/set.js'
|
|
24
|
+
import type { CRDTRegister } from '../crdt/register.js'
|
|
25
|
+
import type { PeerState, SyncState } from '../crdt/sync.js'
|
|
26
|
+
import { CRDTSync } from '../crdt/sync.js'
|
|
27
|
+
import { CRDTDocument as CRDTDocumentImpl } from '../crdt/document.js'
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// useQuery -- reactive collection query with SSE subscription
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface UseQueryOptions extends ListOptions {
|
|
34
|
+
/** Whether to auto-subscribe to realtime updates. Default: true. */
|
|
35
|
+
realtime?: boolean
|
|
36
|
+
/** Whether to run the query immediately. Default: true. */
|
|
37
|
+
enabled?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseQueryResult<T = BaseRecord> {
|
|
41
|
+
data: T[]
|
|
42
|
+
isLoading: boolean
|
|
43
|
+
error: Error | null
|
|
44
|
+
/** Manually refetch the query. */
|
|
45
|
+
refetch: () => Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reactive query hook.
|
|
50
|
+
*
|
|
51
|
+
* Fetches records from a collection and subscribes to realtime updates.
|
|
52
|
+
* Results are cached in the QueryStore for deduplication across components.
|
|
53
|
+
*/
|
|
54
|
+
export function useQuery<T extends BaseRecord = BaseRecord>(
|
|
55
|
+
collection: string,
|
|
56
|
+
options?: UseQueryOptions,
|
|
57
|
+
): UseQueryResult<T> {
|
|
58
|
+
const client = useBase()
|
|
59
|
+
const [data, setData] = useState<T[]>([])
|
|
60
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
61
|
+
const [error, setError] = useState<Error | null>(null)
|
|
62
|
+
|
|
63
|
+
// Stabilize options reference.
|
|
64
|
+
const filter = options?.filter ?? ''
|
|
65
|
+
const sort = options?.sort
|
|
66
|
+
const expand = options?.expand
|
|
67
|
+
const fields = options?.fields
|
|
68
|
+
const page = options?.page
|
|
69
|
+
const perPage = options?.perPage
|
|
70
|
+
const realtimeEnabled = options?.realtime !== false
|
|
71
|
+
const enabled = options?.enabled !== false
|
|
72
|
+
|
|
73
|
+
const fetchData = useCallback(async () => {
|
|
74
|
+
if (!enabled) return
|
|
75
|
+
setIsLoading(true)
|
|
76
|
+
setError(null)
|
|
77
|
+
try {
|
|
78
|
+
const result = await client.list(collection, {
|
|
79
|
+
filter, sort, expand, fields, page, perPage,
|
|
80
|
+
})
|
|
81
|
+
setData(result.items as T[])
|
|
82
|
+
} catch (err) {
|
|
83
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
84
|
+
} finally {
|
|
85
|
+
setIsLoading(false)
|
|
86
|
+
}
|
|
87
|
+
}, [client, collection, filter, sort, expand, fields, page, perPage, enabled])
|
|
88
|
+
|
|
89
|
+
// Initial fetch.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
fetchData()
|
|
92
|
+
}, [fetchData])
|
|
93
|
+
|
|
94
|
+
// Subscribe to QueryStore for optimistic + server updates.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!enabled) return
|
|
97
|
+
|
|
98
|
+
const unsubscribe = client.store.subscribe(collection, filter, (records) => {
|
|
99
|
+
setData(records as T[])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return unsubscribe
|
|
103
|
+
}, [client, collection, filter, enabled])
|
|
104
|
+
|
|
105
|
+
// SSE realtime subscription.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!realtimeEnabled || !enabled) return
|
|
108
|
+
|
|
109
|
+
const unsubscribe = client.subscribeAndSync(collection, '*')
|
|
110
|
+
return unsubscribe
|
|
111
|
+
}, [client, collection, realtimeEnabled, enabled])
|
|
112
|
+
|
|
113
|
+
return { data, isLoading, error, refetch: fetchData }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// useMutation -- mutation with optimistic update support
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export type MutationAction = 'create' | 'update' | 'delete'
|
|
121
|
+
|
|
122
|
+
export interface MutateOptions {
|
|
123
|
+
/** If true, apply an optimistic update to the QueryStore before the server responds. */
|
|
124
|
+
optimistic?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface UseMutationResult {
|
|
128
|
+
mutate: (data: Record<string, unknown>, options?: MutateOptions) => Promise<BaseRecord | void>
|
|
129
|
+
isLoading: boolean
|
|
130
|
+
error: Error | null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Mutation hook.
|
|
135
|
+
*
|
|
136
|
+
* Performs a create, update, or delete mutation against a collection.
|
|
137
|
+
* Supports optimistic updates that are automatically rolled back on failure.
|
|
138
|
+
*/
|
|
139
|
+
export function useMutation(
|
|
140
|
+
collection: string,
|
|
141
|
+
action: MutationAction,
|
|
142
|
+
): UseMutationResult {
|
|
143
|
+
const client = useBase()
|
|
144
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
145
|
+
const [error, setError] = useState<Error | null>(null)
|
|
146
|
+
|
|
147
|
+
const mutate = useCallback(
|
|
148
|
+
async (data: Record<string, unknown>, opts?: MutateOptions): Promise<BaseRecord | void> => {
|
|
149
|
+
setIsLoading(true)
|
|
150
|
+
setError(null)
|
|
151
|
+
|
|
152
|
+
let optimisticId: string | undefined
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Optimistic update.
|
|
156
|
+
if (opts?.optimistic && action !== 'delete') {
|
|
157
|
+
const tempRecord: BaseRecord = {
|
|
158
|
+
id: data.id as string ?? `_temp_${Date.now()}`,
|
|
159
|
+
...data,
|
|
160
|
+
}
|
|
161
|
+
optimisticId = client.store.optimisticSet(collection, tempRecord)
|
|
162
|
+
} else if (opts?.optimistic && action === 'delete' && data.id) {
|
|
163
|
+
optimisticId = client.store.optimisticDelete(collection, data.id as string)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Server request.
|
|
167
|
+
let result: BaseRecord | void = undefined
|
|
168
|
+
switch (action) {
|
|
169
|
+
case 'create':
|
|
170
|
+
result = await client.create(collection, data)
|
|
171
|
+
break
|
|
172
|
+
case 'update': {
|
|
173
|
+
const id = data.id as string
|
|
174
|
+
if (!id) throw new Error('useMutation: update requires data.id')
|
|
175
|
+
const { id: _id, ...rest } = data
|
|
176
|
+
result = await client.update(collection, id, rest)
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
case 'delete': {
|
|
180
|
+
const id = data.id as string
|
|
181
|
+
if (!id) throw new Error('useMutation: delete requires data.id')
|
|
182
|
+
await client.delete(collection, id)
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// On success, remove optimistic entry (server truth takes over via applyServerUpdate).
|
|
188
|
+
if (optimisticId) {
|
|
189
|
+
client.store.rollbackOptimistic(optimisticId)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// Rollback optimistic on failure.
|
|
195
|
+
if (optimisticId) {
|
|
196
|
+
client.store.rollbackOptimistic(optimisticId)
|
|
197
|
+
}
|
|
198
|
+
const e = err instanceof Error ? err : new Error(String(err))
|
|
199
|
+
setError(e)
|
|
200
|
+
throw e
|
|
201
|
+
} finally {
|
|
202
|
+
setIsLoading(false)
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
[client, collection, action],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return { mutate, isLoading, error }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// useAuth -- auth state
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export interface UseAuthResult {
|
|
216
|
+
user: BaseRecord | null
|
|
217
|
+
token: string
|
|
218
|
+
isValid: boolean
|
|
219
|
+
signIn: (identity: string, password: string) => Promise<void>
|
|
220
|
+
signUp: (data: Record<string, unknown>) => Promise<BaseRecord>
|
|
221
|
+
signOut: () => void
|
|
222
|
+
/** Subscribe to auth changes. Returns unsubscribe. */
|
|
223
|
+
onChange: AuthStore['onChange']
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Auth state hook.
|
|
228
|
+
*
|
|
229
|
+
* Returns the current auth state and provides sign-in, sign-up, and
|
|
230
|
+
* sign-out methods. Re-renders when auth state changes.
|
|
231
|
+
*
|
|
232
|
+
* @param collection - Auth collection name (default: "users").
|
|
233
|
+
*/
|
|
234
|
+
export function useAuth(collection = 'users'): UseAuthResult {
|
|
235
|
+
const client = useBase()
|
|
236
|
+
const [user, setUser] = useState<BaseRecord | null>(client.authStore.record)
|
|
237
|
+
const [token, setToken] = useState(client.authStore.token)
|
|
238
|
+
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const unsubscribe = client.authStore.onChange((newToken, newRecord) => {
|
|
241
|
+
setToken(newToken)
|
|
242
|
+
setUser(newRecord)
|
|
243
|
+
})
|
|
244
|
+
return unsubscribe
|
|
245
|
+
}, [client])
|
|
246
|
+
|
|
247
|
+
const signIn = useCallback(
|
|
248
|
+
async (identity: string, password: string) => {
|
|
249
|
+
await client.signInWithPassword(collection, identity, password)
|
|
250
|
+
},
|
|
251
|
+
[client, collection],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const signUp = useCallback(
|
|
255
|
+
async (data: Record<string, unknown>) => {
|
|
256
|
+
return client.signUp(collection, data)
|
|
257
|
+
},
|
|
258
|
+
[client, collection],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
const signOut = useCallback(() => {
|
|
262
|
+
client.signOut()
|
|
263
|
+
}, [client])
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
user,
|
|
267
|
+
token,
|
|
268
|
+
isValid: client.authStore.isValid,
|
|
269
|
+
signIn,
|
|
270
|
+
signUp,
|
|
271
|
+
signOut,
|
|
272
|
+
onChange: client.authStore.onChange.bind(client.authStore),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// useRealtime -- low-level SSE subscription
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Low-level realtime subscription hook.
|
|
282
|
+
*
|
|
283
|
+
* Subscribes to SSE events for a collection topic on mount and
|
|
284
|
+
* unsubscribes on unmount.
|
|
285
|
+
*/
|
|
286
|
+
export function useRealtime(
|
|
287
|
+
collection: string,
|
|
288
|
+
topic: string,
|
|
289
|
+
callback: (event: RealtimeEvent) => void,
|
|
290
|
+
): void {
|
|
291
|
+
const client = useBase()
|
|
292
|
+
const callbackRef = useRef(callback)
|
|
293
|
+
callbackRef.current = callback
|
|
294
|
+
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const unsubscribe = client.realtime.subscribe(collection, topic, (event) => {
|
|
297
|
+
callbackRef.current(event)
|
|
298
|
+
})
|
|
299
|
+
return unsubscribe
|
|
300
|
+
}, [client, collection, topic])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// useConnectionState -- realtime connection state
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
export function useConnectionState(): ConnectionState {
|
|
308
|
+
const client = useBase()
|
|
309
|
+
const [state, setState] = useState<ConnectionState>(client.realtime.state)
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
const unsubscribe = client.realtime.onConnectionChange((s) => {
|
|
313
|
+
setState(s)
|
|
314
|
+
})
|
|
315
|
+
return unsubscribe
|
|
316
|
+
}, [client])
|
|
317
|
+
|
|
318
|
+
return state
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// usePresence -- presence tracking via CRDT sync
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
export interface UsePresenceResult {
|
|
326
|
+
/** Map of peer siteId to their state. */
|
|
327
|
+
peers: Map<string, PeerState>
|
|
328
|
+
/** Our own presence metadata. */
|
|
329
|
+
myState: Record<string, unknown>
|
|
330
|
+
/** Update our presence metadata. */
|
|
331
|
+
updateState: (meta: Record<string, unknown>) => void
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Presence tracking hook.
|
|
336
|
+
*
|
|
337
|
+
* Requires a CRDTSync instance (usually obtained from useCRDT).
|
|
338
|
+
* Tracks peers connected to the same document.
|
|
339
|
+
*/
|
|
340
|
+
export function usePresence(sync: CRDTSync): UsePresenceResult {
|
|
341
|
+
const [peers, setPeers] = useState<Map<string, PeerState>>(new Map())
|
|
342
|
+
const [myState, setMyState] = useState<Record<string, unknown>>({})
|
|
343
|
+
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
const unsubscribe = sync.onPeersChange((p) => {
|
|
346
|
+
setPeers(new Map(p))
|
|
347
|
+
})
|
|
348
|
+
return unsubscribe
|
|
349
|
+
}, [sync])
|
|
350
|
+
|
|
351
|
+
const updateState = useCallback(
|
|
352
|
+
(meta: Record<string, unknown>) => {
|
|
353
|
+
setMyState(meta)
|
|
354
|
+
sync.updatePresence(meta)
|
|
355
|
+
},
|
|
356
|
+
[sync],
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return { peers, myState, updateState }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// useCRDT -- CRDT document hook with auto-sync
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
export interface UseCRDTResult {
|
|
367
|
+
/** The CRDTDocument instance. */
|
|
368
|
+
doc: CRDTDocument
|
|
369
|
+
/** Convenience accessor for text fields. */
|
|
370
|
+
text: (field: string) => CRDTText
|
|
371
|
+
/** Convenience accessor for counter fields. */
|
|
372
|
+
counter: (field: string) => CRDTCounter
|
|
373
|
+
/** Convenience accessor for set fields. */
|
|
374
|
+
set: <T = unknown>(field: string) => CRDTSet<T>
|
|
375
|
+
/** Convenience accessor for register fields. */
|
|
376
|
+
register: <T = unknown>(field: string) => CRDTRegister<T>
|
|
377
|
+
/** The CRDTSync instance for advanced usage (presence, state listeners). */
|
|
378
|
+
sync: CRDTSync
|
|
379
|
+
/** Current sync state. */
|
|
380
|
+
syncState: SyncState
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* CRDT document hook.
|
|
385
|
+
*
|
|
386
|
+
* Creates a CRDTDocument and CRDTSync, connects to the server via
|
|
387
|
+
* WebSocket, and auto-syncs all local operations.
|
|
388
|
+
*
|
|
389
|
+
* @param documentId - Unique document identifier.
|
|
390
|
+
* @param wsUrl - WebSocket URL for the CRDT sync endpoint. If not provided,
|
|
391
|
+
* derives it from the BaseClient URL (http->ws, https->wss).
|
|
392
|
+
*/
|
|
393
|
+
export function useCRDT(documentId: string, wsUrl?: string): UseCRDTResult {
|
|
394
|
+
const client = useBase()
|
|
395
|
+
|
|
396
|
+
// Stable refs for document and sync -- created once per documentId.
|
|
397
|
+
const docRef = useRef<CRDTDocument | null>(null)
|
|
398
|
+
const syncRef = useRef<CRDTSync | null>(null)
|
|
399
|
+
const [syncState, setSyncState] = useState<SyncState>('disconnected')
|
|
400
|
+
|
|
401
|
+
// Create document and sync on first render or documentId change.
|
|
402
|
+
if (!docRef.current || docRef.current.id !== documentId) {
|
|
403
|
+
docRef.current = new CRDTDocumentImpl(documentId)
|
|
404
|
+
}
|
|
405
|
+
if (!syncRef.current) {
|
|
406
|
+
syncRef.current = new CRDTSync()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const doc = docRef.current
|
|
410
|
+
const sync = syncRef.current
|
|
411
|
+
|
|
412
|
+
// Connect on mount, disconnect on unmount.
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
const url = wsUrl ?? deriveCrdtWsUrl(client.url)
|
|
415
|
+
const token = client.authStore.token || undefined
|
|
416
|
+
|
|
417
|
+
sync.connect(url, doc, token)
|
|
418
|
+
|
|
419
|
+
const unsubState = sync.onStateChange((s) => {
|
|
420
|
+
setSyncState(s)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
return () => {
|
|
424
|
+
unsubState()
|
|
425
|
+
sync.disconnect()
|
|
426
|
+
}
|
|
427
|
+
}, [doc, sync, client, wsUrl])
|
|
428
|
+
|
|
429
|
+
// Convenience accessors that delegate to the document.
|
|
430
|
+
const text = useCallback((field: string) => doc.getText(field), [doc])
|
|
431
|
+
const counter = useCallback((field: string) => doc.getCounter(field), [doc])
|
|
432
|
+
const set = useCallback(<T = unknown>(field: string) => doc.getSet<T>(field), [doc])
|
|
433
|
+
const register = useCallback(<T = unknown>(field: string) => doc.getRegister<T>(field), [doc])
|
|
434
|
+
|
|
435
|
+
return { doc, text, counter, set, register, sync, syncState }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// useCRDTText -- subscribe to a CRDT text field's content
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Subscribe to a specific CRDTText field's current string value.
|
|
444
|
+
* Re-renders whenever the text changes (local or remote).
|
|
445
|
+
*/
|
|
446
|
+
export function useCRDTText(doc: CRDTDocument, field: string): string {
|
|
447
|
+
const textCrdt = doc.getText(field)
|
|
448
|
+
const [value, setValue] = useState(() => textCrdt.toString())
|
|
449
|
+
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
const unsubscribe = textCrdt.onChange((text) => {
|
|
452
|
+
setValue(text)
|
|
453
|
+
})
|
|
454
|
+
return unsubscribe
|
|
455
|
+
}, [textCrdt])
|
|
456
|
+
|
|
457
|
+
return value
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// useCRDTCounter -- subscribe to a CRDT counter field
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
export function useCRDTCounter(doc: CRDTDocument, field: string): number {
|
|
465
|
+
const counterCrdt = doc.getCounter(field)
|
|
466
|
+
const [value, setValue] = useState(() => counterCrdt.value)
|
|
467
|
+
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
const unsubscribe = counterCrdt.onChange((v) => {
|
|
470
|
+
setValue(v)
|
|
471
|
+
})
|
|
472
|
+
return unsubscribe
|
|
473
|
+
}, [counterCrdt])
|
|
474
|
+
|
|
475
|
+
return value
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Helpers
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
/** Derive WebSocket URL from HTTP URL: https://x -> wss://x/api/crdt */
|
|
483
|
+
function deriveCrdtWsUrl(httpUrl: string): string {
|
|
484
|
+
const url = httpUrl.replace(/\/$/, '')
|
|
485
|
+
if (url.startsWith('https://')) {
|
|
486
|
+
return url.replace('https://', 'wss://') + '/api/crdt'
|
|
487
|
+
}
|
|
488
|
+
return url.replace('http://', 'ws://') + '/api/crdt'
|
|
489
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hanzoai/base/react -- React hooks and context for Hanzo Base.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - BaseProvider: context providing the BaseClient
|
|
6
|
+
* - useBase: access the BaseClient instance
|
|
7
|
+
* - useQuery: reactive collection query with SSE subscription
|
|
8
|
+
* - useMutation: mutation with optimistic update support
|
|
9
|
+
* - useAuth: auth state management
|
|
10
|
+
* - useRealtime: low-level SSE subscription
|
|
11
|
+
* - useConnectionState: realtime connection state
|
|
12
|
+
* - usePresence: presence tracking via CRDT sync
|
|
13
|
+
* - useCRDT: CRDT document with auto-sync
|
|
14
|
+
* - useCRDTText: subscribe to a CRDT text field
|
|
15
|
+
* - useCRDTCounter: subscribe to a CRDT counter field
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Context & Provider
|
|
19
|
+
export { BaseProvider, useBase } from './context.js'
|
|
20
|
+
export type { BaseProviderProps } from './context.js'
|
|
21
|
+
|
|
22
|
+
// Hooks
|
|
23
|
+
export {
|
|
24
|
+
useQuery,
|
|
25
|
+
useMutation,
|
|
26
|
+
useAuth,
|
|
27
|
+
useRealtime,
|
|
28
|
+
useConnectionState,
|
|
29
|
+
usePresence,
|
|
30
|
+
useCRDT,
|
|
31
|
+
useCRDTText,
|
|
32
|
+
useCRDTCounter,
|
|
33
|
+
} from './hooks.js'
|
|
34
|
+
export type {
|
|
35
|
+
UseQueryOptions,
|
|
36
|
+
UseQueryResult,
|
|
37
|
+
MutationAction,
|
|
38
|
+
MutateOptions,
|
|
39
|
+
UseMutationResult,
|
|
40
|
+
UseAuthResult,
|
|
41
|
+
UsePresenceResult,
|
|
42
|
+
UseCRDTResult,
|
|
43
|
+
} from './hooks.js'
|
|
44
|
+
|
|
45
|
+
// Re-export core types commonly needed in React components
|
|
46
|
+
export { BaseClient, BaseClientError } from '../core/client.js'
|
|
47
|
+
export type {
|
|
48
|
+
BaseRecord,
|
|
49
|
+
AuthStore,
|
|
50
|
+
ClientConfig,
|
|
51
|
+
ListOptions,
|
|
52
|
+
ListResult,
|
|
53
|
+
StateVersion,
|
|
54
|
+
RealtimeEvent,
|
|
55
|
+
ConnectionState,
|
|
56
|
+
} from '../core/index.js'
|