@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
package/src/crdt/text.ts
ADDED
|
@@ -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
|
+
}
|