@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.
@@ -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'