@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,274 @@
1
+ /**
2
+ * CRDTText -- collaborative text based on RGA (Replicated Growable Array).
3
+ *
4
+ * Each character has a unique position id. Insertions reference the position
5
+ * id of the character they follow. Deletions tombstone position ids.
6
+ * The structure resolves conflicts by using HLC comparison on concurrent
7
+ * inserts at the same position.
8
+ */
9
+
10
+ import type { HLCTimestamp } from './clock.js'
11
+ import { HybridLogicalClock, compareHLC } from './clock.js'
12
+ import type { Operation, TextInsertPayload, TextDeletePayload } from './operations.js'
13
+ import { makeOpId, makePositionId } from './operations.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Internal node structure
17
+ // ---------------------------------------------------------------------------
18
+
19
+ interface TextNode {
20
+ id: string // unique position id
21
+ char: string // single character
22
+ hlc: HLCTimestamp // creation timestamp
23
+ tombstone: boolean // soft-deleted
24
+ afterId: string | null // the id this node was inserted after
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // CRDTText
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type TextChangeCallback = (text: string) => void
32
+
33
+ export class CRDTText {
34
+ private _nodes: TextNode[] = []
35
+ private readonly _clock: HybridLogicalClock
36
+ private readonly _documentId: string
37
+ private readonly _field: string
38
+ private readonly _pendingOps: Operation[] = []
39
+ private _listeners = new Set<TextChangeCallback>()
40
+
41
+ /** Index from position id to array index for O(1) lookup. */
42
+ private _index = new Map<string, number>()
43
+
44
+ constructor(documentId: string, field: string, clock: HybridLogicalClock) {
45
+ this._documentId = documentId
46
+ this._field = field
47
+ this._clock = clock
48
+ }
49
+
50
+ // ---- Public API ---------------------------------------------------------
51
+
52
+ /** Insert text at a visible character position (0-based). */
53
+ insert(position: number, text: string): Operation {
54
+ if (text.length === 0) {
55
+ throw new Error('CRDTText.insert: empty text')
56
+ }
57
+
58
+ const afterId = this._visibleIdAtPosition(position - 1)
59
+ const hlc = this._clock.now()
60
+ const positionIds: string[] = []
61
+
62
+ // Create nodes for each character, chaining them.
63
+ let prevId = afterId
64
+ for (let i = 0; i < text.length; i++) {
65
+ const posId = makePositionId(hlc, i)
66
+ positionIds.push(posId)
67
+
68
+ const node: TextNode = {
69
+ id: posId,
70
+ char: text[i],
71
+ hlc: { ...hlc, counter: hlc.counter + i },
72
+ tombstone: false,
73
+ afterId: prevId,
74
+ }
75
+
76
+ this._insertNode(node)
77
+ prevId = posId
78
+ }
79
+
80
+ const op: Operation = {
81
+ id: makeOpId(hlc),
82
+ documentId: this._documentId,
83
+ field: this._field,
84
+ hlc,
85
+ type: 'text.insert',
86
+ payload: { afterId, content: text, positionIds } satisfies TextInsertPayload,
87
+ }
88
+
89
+ this._pendingOps.push(op)
90
+ this._notifyChange()
91
+ return op
92
+ }
93
+
94
+ /** Delete `length` visible characters starting at `position` (0-based). */
95
+ delete(position: number, length: number): Operation {
96
+ const ids = this._visibleIdsInRange(position, length)
97
+ if (ids.length === 0) {
98
+ throw new Error('CRDTText.delete: nothing to delete')
99
+ }
100
+
101
+ for (const id of ids) {
102
+ const idx = this._index.get(id)
103
+ if (idx !== undefined) {
104
+ this._nodes[idx].tombstone = true
105
+ }
106
+ }
107
+
108
+ const hlc = this._clock.now()
109
+ const op: Operation = {
110
+ id: makeOpId(hlc),
111
+ documentId: this._documentId,
112
+ field: this._field,
113
+ hlc,
114
+ type: 'text.delete',
115
+ payload: { positionIds: ids } satisfies TextDeletePayload,
116
+ }
117
+
118
+ this._pendingOps.push(op)
119
+ this._notifyChange()
120
+ return op
121
+ }
122
+
123
+ /** Apply a remote operation. */
124
+ applyRemote(op: Operation): void {
125
+ this._clock.receive(op.hlc)
126
+
127
+ if (op.type === 'text.insert') {
128
+ const payload = op.payload as TextInsertPayload
129
+ for (let i = 0; i < payload.content.length; i++) {
130
+ const node: TextNode = {
131
+ id: payload.positionIds[i],
132
+ char: payload.content[i],
133
+ hlc: { ...op.hlc, counter: op.hlc.counter + i },
134
+ tombstone: false,
135
+ afterId: i === 0 ? payload.afterId : payload.positionIds[i - 1],
136
+ }
137
+ this._insertNode(node)
138
+ }
139
+ } else if (op.type === 'text.delete') {
140
+ const payload = op.payload as TextDeletePayload
141
+ for (const posId of payload.positionIds) {
142
+ const idx = this._index.get(posId)
143
+ if (idx !== undefined) {
144
+ this._nodes[idx].tombstone = true
145
+ }
146
+ }
147
+ }
148
+
149
+ this._notifyChange()
150
+ }
151
+
152
+ /** Get the current visible text. */
153
+ toString(): string {
154
+ let result = ''
155
+ for (const node of this._nodes) {
156
+ if (!node.tombstone) {
157
+ result += node.char
158
+ }
159
+ }
160
+ return result
161
+ }
162
+
163
+ /** Number of visible characters. */
164
+ get length(): number {
165
+ let count = 0
166
+ for (const node of this._nodes) {
167
+ if (!node.tombstone) count++
168
+ }
169
+ return count
170
+ }
171
+
172
+ /** Drain pending local operations. */
173
+ drainOps(): Operation[] {
174
+ return this._pendingOps.splice(0, this._pendingOps.length)
175
+ }
176
+
177
+ onChange(callback: TextChangeCallback): () => void {
178
+ this._listeners.add(callback)
179
+ return () => {
180
+ this._listeners.delete(callback)
181
+ }
182
+ }
183
+
184
+ // ---- Internal -----------------------------------------------------------
185
+
186
+ /**
187
+ * Insert a node into the correct position in the array.
188
+ * RGA rule: among siblings sharing the same afterId, order by HLC descending
189
+ * (newer inserts appear first, pushing older ones right).
190
+ */
191
+ private _insertNode(node: TextNode): void {
192
+ // Find the position of the parent (afterId).
193
+ let parentIdx: number
194
+ if (node.afterId === null) {
195
+ parentIdx = -1 // insert at beginning
196
+ } else {
197
+ const idx = this._index.get(node.afterId)
198
+ if (idx === undefined) {
199
+ // Parent not found -- append at end (out-of-order delivery).
200
+ parentIdx = this._nodes.length - 1
201
+ } else {
202
+ parentIdx = idx
203
+ }
204
+ }
205
+
206
+ // Scan right from parentIdx+1 to find insertion point.
207
+ // Skip nodes that were also inserted after the same parent but have a
208
+ // higher HLC (they should appear before this node).
209
+ let insertIdx = parentIdx + 1
210
+ while (insertIdx < this._nodes.length) {
211
+ const existing = this._nodes[insertIdx]
212
+ // Only compare with siblings of the same parent.
213
+ if (existing.afterId !== node.afterId) break
214
+ // Higher HLC goes first (left).
215
+ if (compareHLC(existing.hlc, node.hlc) <= 0) break
216
+ insertIdx++
217
+ }
218
+
219
+ // Insert and rebuild index for shifted elements.
220
+ this._nodes.splice(insertIdx, 0, node)
221
+ this._rebuildIndex(insertIdx)
222
+ }
223
+
224
+ /** Rebuild the id->index map from `startIdx` onward. */
225
+ private _rebuildIndex(startIdx: number): void {
226
+ for (let i = startIdx; i < this._nodes.length; i++) {
227
+ this._index.set(this._nodes[i].id, i)
228
+ }
229
+ }
230
+
231
+ /** Get the position id of the visible character at position `pos`, or null for before-head. */
232
+ private _visibleIdAtPosition(pos: number): string | null {
233
+ if (pos < 0) return null
234
+ let visible = -1
235
+ for (const node of this._nodes) {
236
+ if (!node.tombstone) {
237
+ visible++
238
+ if (visible === pos) return node.id
239
+ }
240
+ }
241
+ // pos is past end -- return last visible id.
242
+ for (let i = this._nodes.length - 1; i >= 0; i--) {
243
+ if (!this._nodes[i].tombstone) return this._nodes[i].id
244
+ }
245
+ return null
246
+ }
247
+
248
+ /** Get position ids for `length` visible characters starting at `position`. */
249
+ private _visibleIdsInRange(position: number, length: number): string[] {
250
+ const ids: string[] = []
251
+ let visible = -1
252
+ for (const node of this._nodes) {
253
+ if (node.tombstone) continue
254
+ visible++
255
+ if (visible >= position && visible < position + length) {
256
+ ids.push(node.id)
257
+ }
258
+ if (ids.length === length) break
259
+ }
260
+ return ids
261
+ }
262
+
263
+ private _notifyChange(): void {
264
+ if (this._listeners.size === 0) return
265
+ const text = this.toString()
266
+ for (const cb of this._listeners) {
267
+ try {
268
+ cb(text)
269
+ } catch {
270
+ // listener errors must not break notification
271
+ }
272
+ }
273
+ }
274
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * BaseProvider -- React context providing the BaseClient instance.
3
+ *
4
+ * Usage:
5
+ * <BaseProvider url="https://myapp.hanzo.ai">
6
+ * <App />
7
+ * </BaseProvider>
8
+ *
9
+ * // or with an existing client:
10
+ * <BaseProvider client={myClient}>
11
+ * <App />
12
+ * </BaseProvider>
13
+ */
14
+
15
+ import { createContext, useContext, useRef, useEffect, type ReactNode } from 'react'
16
+ import { BaseClient, type AuthStore, type ClientConfig } from '../core/client.js'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Context
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const BaseContext = createContext<BaseClient | null>(null)
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Provider props
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface BaseProviderProps {
29
+ /** Base URL for automatic client creation. */
30
+ url?: string
31
+ /** Optional auth store override. */
32
+ authStore?: AuthStore
33
+ /** Pre-created BaseClient (takes precedence over url). */
34
+ client?: BaseClient
35
+ children: ReactNode
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Provider
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export function BaseProvider({ url, authStore, client: externalClient, children }: BaseProviderProps) {
43
+ // Use a ref to hold the client so it survives re-renders without
44
+ // creating a new client on every render.
45
+ const clientRef = useRef<BaseClient | null>(null)
46
+
47
+ if (externalClient) {
48
+ clientRef.current = externalClient
49
+ } else if (!clientRef.current && url) {
50
+ const config: ClientConfig = { url }
51
+ if (authStore) config.authStore = authStore
52
+ clientRef.current = new BaseClient(config)
53
+ }
54
+
55
+ const client = clientRef.current
56
+ if (!client) {
57
+ throw new Error('BaseProvider: provide either `url` or `client` prop')
58
+ }
59
+
60
+ // Cleanup: disconnect realtime on unmount.
61
+ useEffect(() => {
62
+ return () => {
63
+ // Only disconnect if we own the client (created from url).
64
+ if (!externalClient) {
65
+ client.disconnect()
66
+ }
67
+ }
68
+ }, [client, externalClient])
69
+
70
+ return <BaseContext value={client}>{children}</BaseContext>
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Hook
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Get the BaseClient from the nearest BaseProvider.
79
+ * Throws if used outside a provider.
80
+ */
81
+ export function useBase(): BaseClient {
82
+ const client = useContext(BaseContext)
83
+ if (!client) {
84
+ throw new Error('useBase: must be used within a <BaseProvider>')
85
+ }
86
+ return client
87
+ }