@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid logical clock for CRDT operation ordering.
|
|
3
|
+
*
|
|
4
|
+
* Each operation is tagged with (timestamp, counter, siteId) to provide
|
|
5
|
+
* a total order across all sites. Compatible with the Go HLC implementation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface HLCTimestamp {
|
|
9
|
+
/** Wall-clock milliseconds since epoch. */
|
|
10
|
+
ts: number
|
|
11
|
+
/** Monotonic counter to break ties within the same ms. */
|
|
12
|
+
counter: number
|
|
13
|
+
/** Unique site identifier (usually client session id). */
|
|
14
|
+
siteId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class HybridLogicalClock {
|
|
18
|
+
private _ts: number
|
|
19
|
+
private _counter: number
|
|
20
|
+
readonly siteId: string
|
|
21
|
+
|
|
22
|
+
constructor(siteId?: string) {
|
|
23
|
+
this.siteId = siteId ?? randomSiteId()
|
|
24
|
+
this._ts = Date.now()
|
|
25
|
+
this._counter = 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Generate a new timestamp, guaranteed > any previous. */
|
|
29
|
+
now(): HLCTimestamp {
|
|
30
|
+
const wall = Date.now()
|
|
31
|
+
if (wall > this._ts) {
|
|
32
|
+
this._ts = wall
|
|
33
|
+
this._counter = 0
|
|
34
|
+
} else {
|
|
35
|
+
this._counter++
|
|
36
|
+
}
|
|
37
|
+
return { ts: this._ts, counter: this._counter, siteId: this.siteId }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Receive a remote timestamp and merge with local clock. */
|
|
41
|
+
receive(remote: HLCTimestamp): HLCTimestamp {
|
|
42
|
+
const wall = Date.now()
|
|
43
|
+
if (wall > this._ts && wall > remote.ts) {
|
|
44
|
+
this._ts = wall
|
|
45
|
+
this._counter = 0
|
|
46
|
+
} else if (remote.ts > this._ts) {
|
|
47
|
+
this._ts = remote.ts
|
|
48
|
+
this._counter = remote.counter + 1
|
|
49
|
+
} else if (this._ts > remote.ts) {
|
|
50
|
+
this._counter++
|
|
51
|
+
} else {
|
|
52
|
+
// Same ts -- take max counter + 1
|
|
53
|
+
this._counter = Math.max(this._counter, remote.counter) + 1
|
|
54
|
+
}
|
|
55
|
+
return { ts: this._ts, counter: this._counter, siteId: this.siteId }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Compare two HLC timestamps. Returns <0, 0, or >0. */
|
|
60
|
+
export function compareHLC(a: HLCTimestamp, b: HLCTimestamp): number {
|
|
61
|
+
if (a.ts !== b.ts) return a.ts - b.ts
|
|
62
|
+
if (a.counter !== b.counter) return a.counter - b.counter
|
|
63
|
+
if (a.siteId < b.siteId) return -1
|
|
64
|
+
if (a.siteId > b.siteId) return 1
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function randomSiteId(): string {
|
|
69
|
+
const buf = new Uint8Array(8)
|
|
70
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
71
|
+
crypto.getRandomValues(buf)
|
|
72
|
+
} else {
|
|
73
|
+
for (let i = 0; i < buf.length; i++) {
|
|
74
|
+
buf[i] = (Math.random() * 256) | 0
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
|
78
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDTCounter -- PN-Counter (positive-negative counter).
|
|
3
|
+
*
|
|
4
|
+
* Each site tracks its own positive and negative totals.
|
|
5
|
+
* The value is sum(positives) - sum(negatives) across all sites.
|
|
6
|
+
* Merge is max() per-site per-direction.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { HybridLogicalClock } from './clock.js'
|
|
10
|
+
import type { Operation, CounterIncrementPayload } from './operations.js'
|
|
11
|
+
import { makeOpId } from './operations.js'
|
|
12
|
+
|
|
13
|
+
export type CounterChangeCallback = (value: number) => void
|
|
14
|
+
|
|
15
|
+
export class CRDTCounter {
|
|
16
|
+
/** Per-site positive accumulator. */
|
|
17
|
+
private _positive = new Map<string, number>()
|
|
18
|
+
/** Per-site negative accumulator. */
|
|
19
|
+
private _negative = new Map<string, number>()
|
|
20
|
+
|
|
21
|
+
private readonly _clock: HybridLogicalClock
|
|
22
|
+
private readonly _documentId: string
|
|
23
|
+
private readonly _field: string
|
|
24
|
+
private readonly _pendingOps: Operation[] = []
|
|
25
|
+
private _listeners = new Set<CounterChangeCallback>()
|
|
26
|
+
|
|
27
|
+
constructor(documentId: string, field: string, clock: HybridLogicalClock) {
|
|
28
|
+
this._documentId = documentId
|
|
29
|
+
this._field = field
|
|
30
|
+
this._clock = clock
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---- Public API ---------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
get value(): number {
|
|
36
|
+
let pos = 0
|
|
37
|
+
let neg = 0
|
|
38
|
+
for (const v of this._positive.values()) pos += v
|
|
39
|
+
for (const v of this._negative.values()) neg += v
|
|
40
|
+
return pos - neg
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
increment(amount = 1): Operation {
|
|
44
|
+
if (amount === 0) throw new Error('CRDTCounter: amount must be nonzero')
|
|
45
|
+
|
|
46
|
+
const siteId = this._clock.siteId
|
|
47
|
+
if (amount > 0) {
|
|
48
|
+
this._positive.set(siteId, (this._positive.get(siteId) ?? 0) + amount)
|
|
49
|
+
} else {
|
|
50
|
+
this._negative.set(siteId, (this._negative.get(siteId) ?? 0) + Math.abs(amount))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hlc = this._clock.now()
|
|
54
|
+
const op: Operation = {
|
|
55
|
+
id: makeOpId(hlc),
|
|
56
|
+
documentId: this._documentId,
|
|
57
|
+
field: this._field,
|
|
58
|
+
hlc,
|
|
59
|
+
type: 'counter.increment',
|
|
60
|
+
payload: { delta: amount } satisfies CounterIncrementPayload,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this._pendingOps.push(op)
|
|
64
|
+
this._notifyChange()
|
|
65
|
+
return op
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
decrement(amount = 1): Operation {
|
|
69
|
+
return this.increment(-amount)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Apply a remote increment operation. */
|
|
73
|
+
applyRemote(op: Operation): void {
|
|
74
|
+
if (op.type !== 'counter.increment') return
|
|
75
|
+
this._clock.receive(op.hlc)
|
|
76
|
+
|
|
77
|
+
const payload = op.payload as CounterIncrementPayload
|
|
78
|
+
const siteId = op.hlc.siteId
|
|
79
|
+
|
|
80
|
+
if (payload.delta > 0) {
|
|
81
|
+
this._positive.set(siteId, (this._positive.get(siteId) ?? 0) + payload.delta)
|
|
82
|
+
} else {
|
|
83
|
+
this._negative.set(siteId, (this._negative.get(siteId) ?? 0) + Math.abs(payload.delta))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this._notifyChange()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Merge with a remote counter state (for full-state sync). */
|
|
90
|
+
mergeState(positives: Record<string, number>, negatives: Record<string, number>): void {
|
|
91
|
+
for (const [site, val] of Object.entries(positives)) {
|
|
92
|
+
this._positive.set(site, Math.max(this._positive.get(site) ?? 0, val))
|
|
93
|
+
}
|
|
94
|
+
for (const [site, val] of Object.entries(negatives)) {
|
|
95
|
+
this._negative.set(site, Math.max(this._negative.get(site) ?? 0, val))
|
|
96
|
+
}
|
|
97
|
+
this._notifyChange()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Export state for full-state sync. */
|
|
101
|
+
exportState(): { positive: Record<string, number>; negative: Record<string, number> } {
|
|
102
|
+
return {
|
|
103
|
+
positive: Object.fromEntries(this._positive),
|
|
104
|
+
negative: Object.fromEntries(this._negative),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
drainOps(): Operation[] {
|
|
109
|
+
return this._pendingOps.splice(0, this._pendingOps.length)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onChange(callback: CounterChangeCallback): () => void {
|
|
113
|
+
this._listeners.add(callback)
|
|
114
|
+
return () => {
|
|
115
|
+
this._listeners.delete(callback)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private _notifyChange(): void {
|
|
120
|
+
if (this._listeners.size === 0) return
|
|
121
|
+
const val = this.value
|
|
122
|
+
for (const cb of this._listeners) {
|
|
123
|
+
try {
|
|
124
|
+
cb(val)
|
|
125
|
+
} catch {
|
|
126
|
+
// listener errors must not break notification
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDTDocument -- container for collaborative fields.
|
|
3
|
+
*
|
|
4
|
+
* A document holds named CRDT fields (text, counter, set, register) that
|
|
5
|
+
* share a single HLC clock and sync channel. Operations from all fields
|
|
6
|
+
* are collected and sent to the server together.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { HybridLogicalClock } from './clock.js'
|
|
10
|
+
import { CRDTText } from './text.js'
|
|
11
|
+
import { CRDTCounter } from './counter.js'
|
|
12
|
+
import { CRDTSet } from './set.js'
|
|
13
|
+
import { CRDTRegister } from './register.js'
|
|
14
|
+
import type { Operation } from './operations.js'
|
|
15
|
+
|
|
16
|
+
export type DocumentChangeCallback = (ops: Operation[]) => void
|
|
17
|
+
|
|
18
|
+
export class CRDTDocument {
|
|
19
|
+
readonly id: string
|
|
20
|
+
readonly clock: HybridLogicalClock
|
|
21
|
+
|
|
22
|
+
private _texts = new Map<string, CRDTText>()
|
|
23
|
+
private _counters = new Map<string, CRDTCounter>()
|
|
24
|
+
private _sets = new Map<string, CRDTSet>()
|
|
25
|
+
private _registers = new Map<string, CRDTRegister>()
|
|
26
|
+
private _listeners = new Set<DocumentChangeCallback>()
|
|
27
|
+
|
|
28
|
+
/** Buffer for ops not yet synced. */
|
|
29
|
+
private _unsyncedOps: Operation[] = []
|
|
30
|
+
|
|
31
|
+
constructor(id: string, siteId?: string) {
|
|
32
|
+
this.id = id
|
|
33
|
+
this.clock = new HybridLogicalClock(siteId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- Field accessors ----------------------------------------------------
|
|
37
|
+
|
|
38
|
+
getText(field: string): CRDTText {
|
|
39
|
+
let t = this._texts.get(field)
|
|
40
|
+
if (!t) {
|
|
41
|
+
t = new CRDTText(this.id, field, this.clock)
|
|
42
|
+
this._texts.set(field, t)
|
|
43
|
+
}
|
|
44
|
+
return t
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getCounter(field: string): CRDTCounter {
|
|
48
|
+
let c = this._counters.get(field)
|
|
49
|
+
if (!c) {
|
|
50
|
+
c = new CRDTCounter(this.id, field, this.clock)
|
|
51
|
+
this._counters.set(field, c)
|
|
52
|
+
}
|
|
53
|
+
return c
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getSet<T = unknown>(field: string): CRDTSet<T> {
|
|
57
|
+
let s = this._sets.get(field)
|
|
58
|
+
if (!s) {
|
|
59
|
+
s = new CRDTSet(this.id, field, this.clock)
|
|
60
|
+
this._sets.set(field, s)
|
|
61
|
+
}
|
|
62
|
+
return s as CRDTSet<T>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getRegister<T = unknown>(field: string): CRDTRegister<T> {
|
|
66
|
+
let r = this._registers.get(field)
|
|
67
|
+
if (!r) {
|
|
68
|
+
r = new CRDTRegister(this.id, field, this.clock)
|
|
69
|
+
this._registers.set(field, r)
|
|
70
|
+
}
|
|
71
|
+
return r as CRDTRegister<T>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- Operation collection -----------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Collect all pending local operations from all fields.
|
|
78
|
+
* Drains the per-field op buffers and returns them.
|
|
79
|
+
*/
|
|
80
|
+
collectOps(): Operation[] {
|
|
81
|
+
const ops: Operation[] = []
|
|
82
|
+
for (const t of this._texts.values()) ops.push(...t.drainOps())
|
|
83
|
+
for (const c of this._counters.values()) ops.push(...c.drainOps())
|
|
84
|
+
for (const s of this._sets.values()) ops.push(...s.drainOps())
|
|
85
|
+
for (const r of this._registers.values()) ops.push(...r.drainOps())
|
|
86
|
+
this._unsyncedOps.push(...ops)
|
|
87
|
+
return ops
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Acknowledge that operations up to and including `opId` have been
|
|
92
|
+
* persisted by the server. Removes them from the unsynced buffer.
|
|
93
|
+
*/
|
|
94
|
+
acknowledge(opId: string): void {
|
|
95
|
+
const idx = this._unsyncedOps.findIndex((op) => op.id === opId)
|
|
96
|
+
if (idx >= 0) {
|
|
97
|
+
this._unsyncedOps.splice(0, idx + 1)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Get operations that have not been acknowledged by the server. */
|
|
102
|
+
getUnsyncedOps(): readonly Operation[] {
|
|
103
|
+
return this._unsyncedOps
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- Remote operation application ---------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Apply a batch of remote operations to the appropriate CRDT fields.
|
|
110
|
+
*/
|
|
111
|
+
applyRemoteOps(ops: Operation[]): void {
|
|
112
|
+
for (const op of ops) {
|
|
113
|
+
if (op.documentId !== this.id) continue
|
|
114
|
+
this._applyRemoteOp(op)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private _applyRemoteOp(op: Operation): void {
|
|
119
|
+
switch (op.type) {
|
|
120
|
+
case 'text.insert':
|
|
121
|
+
case 'text.delete': {
|
|
122
|
+
const t = this.getText(op.field)
|
|
123
|
+
t.applyRemote(op)
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
case 'counter.increment': {
|
|
127
|
+
const c = this.getCounter(op.field)
|
|
128
|
+
c.applyRemote(op)
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
case 'set.add':
|
|
132
|
+
case 'set.remove': {
|
|
133
|
+
const s = this.getSet(op.field)
|
|
134
|
+
s.applyRemote(op)
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
case 'register.set': {
|
|
138
|
+
const r = this.getRegister(op.field)
|
|
139
|
+
r.applyRemote(op)
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- Serialization ------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Encode all pending operations as a Uint8Array (JSON wire format).
|
|
149
|
+
* Used for WebSocket transmission.
|
|
150
|
+
*/
|
|
151
|
+
encode(): Uint8Array {
|
|
152
|
+
const ops = this.collectOps()
|
|
153
|
+
const json = JSON.stringify({
|
|
154
|
+
documentId: this.id,
|
|
155
|
+
siteId: this.clock.siteId,
|
|
156
|
+
ops,
|
|
157
|
+
})
|
|
158
|
+
return new TextEncoder().encode(json)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Decode and apply a remote message (Uint8Array or string).
|
|
163
|
+
*/
|
|
164
|
+
decode(data: Uint8Array | string): void {
|
|
165
|
+
const text = typeof data === 'string' ? data : new TextDecoder().decode(data)
|
|
166
|
+
const msg = JSON.parse(text) as { documentId: string; siteId: string; ops: Operation[] }
|
|
167
|
+
|
|
168
|
+
if (msg.documentId !== this.id) return
|
|
169
|
+
// Do not apply our own operations.
|
|
170
|
+
if (msg.siteId === this.clock.siteId) return
|
|
171
|
+
|
|
172
|
+
this.applyRemoteOps(msg.ops)
|
|
173
|
+
this._notifyChange(msg.ops)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- Change notifications -----------------------------------------------
|
|
177
|
+
|
|
178
|
+
onChange(callback: DocumentChangeCallback): () => void {
|
|
179
|
+
this._listeners.add(callback)
|
|
180
|
+
return () => {
|
|
181
|
+
this._listeners.delete(callback)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private _notifyChange(ops: Operation[]): void {
|
|
186
|
+
for (const cb of this._listeners) {
|
|
187
|
+
try {
|
|
188
|
+
cb(ops)
|
|
189
|
+
} catch {
|
|
190
|
+
// listener errors must not break notification
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hanzoai/base/crdt -- CRDT primitives for offline-first sync.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - HybridLogicalClock for causal ordering
|
|
6
|
+
* - Operation types for wire format
|
|
7
|
+
* - CRDTText (RGA-based collaborative text)
|
|
8
|
+
* - CRDTCounter (PN-Counter)
|
|
9
|
+
* - CRDTSet (OR-Set with add-wins semantics)
|
|
10
|
+
* - CRDTRegister (LWW-Register)
|
|
11
|
+
* - CRDTDocument (container for collaborative fields)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Clock
|
|
15
|
+
export { HybridLogicalClock, compareHLC } from './clock.js'
|
|
16
|
+
export type { HLCTimestamp } from './clock.js'
|
|
17
|
+
|
|
18
|
+
// Operations
|
|
19
|
+
export { makeOpId, makePositionId } from './operations.js'
|
|
20
|
+
export type {
|
|
21
|
+
OperationType,
|
|
22
|
+
Operation,
|
|
23
|
+
OperationPayload,
|
|
24
|
+
TextInsertPayload,
|
|
25
|
+
TextDeletePayload,
|
|
26
|
+
CounterIncrementPayload,
|
|
27
|
+
SetAddPayload,
|
|
28
|
+
SetRemovePayload,
|
|
29
|
+
RegisterSetPayload,
|
|
30
|
+
} from './operations.js'
|
|
31
|
+
|
|
32
|
+
// CRDT types
|
|
33
|
+
export { CRDTText } from './text.js'
|
|
34
|
+
export type { TextChangeCallback } from './text.js'
|
|
35
|
+
|
|
36
|
+
export { CRDTCounter } from './counter.js'
|
|
37
|
+
export type { CounterChangeCallback } from './counter.js'
|
|
38
|
+
|
|
39
|
+
export { CRDTSet } from './set.js'
|
|
40
|
+
export type { SetChangeCallback } from './set.js'
|
|
41
|
+
|
|
42
|
+
export { CRDTRegister } from './register.js'
|
|
43
|
+
export type { RegisterChangeCallback } from './register.js'
|
|
44
|
+
|
|
45
|
+
export { CRDTDocument } from './document.js'
|
|
46
|
+
export type { DocumentChangeCallback } from './document.js'
|
|
47
|
+
|
|
48
|
+
// Sync
|
|
49
|
+
export { CRDTSync } from './sync.js'
|
|
50
|
+
export type {
|
|
51
|
+
SyncState,
|
|
52
|
+
PeerState,
|
|
53
|
+
SyncStateCallback,
|
|
54
|
+
PeerCallback,
|
|
55
|
+
RemoteChangeCallback,
|
|
56
|
+
} from './sync.js'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDT operation types -- wire format for sync between client and server.
|
|
3
|
+
*
|
|
4
|
+
* All operations are serializable to JSON and designed for
|
|
5
|
+
* eventual compatibility with a Go CRDT server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HLCTimestamp } from './clock.js'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Operation type tags
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export type OperationType =
|
|
15
|
+
| 'text.insert'
|
|
16
|
+
| 'text.delete'
|
|
17
|
+
| 'counter.increment'
|
|
18
|
+
| 'set.add'
|
|
19
|
+
| 'set.remove'
|
|
20
|
+
| 'register.set'
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Base operation
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface Operation {
|
|
27
|
+
/** Unique operation id: `{siteId}:{ts}:{counter}` */
|
|
28
|
+
id: string
|
|
29
|
+
/** The CRDT document id this operation belongs to. */
|
|
30
|
+
documentId: string
|
|
31
|
+
/** Which field within the document. */
|
|
32
|
+
field: string
|
|
33
|
+
/** HLC timestamp for causal ordering. */
|
|
34
|
+
hlc: HLCTimestamp
|
|
35
|
+
/** Operation type tag. */
|
|
36
|
+
type: OperationType
|
|
37
|
+
/** Type-specific payload. */
|
|
38
|
+
payload: OperationPayload
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Payloads
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface TextInsertPayload {
|
|
46
|
+
/** Position id of the character after which to insert (null = head). */
|
|
47
|
+
afterId: string | null
|
|
48
|
+
/** The text content to insert. Each character gets its own positional id. */
|
|
49
|
+
content: string
|
|
50
|
+
/** Assigned position ids for each character (same length as content). */
|
|
51
|
+
positionIds: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TextDeletePayload {
|
|
55
|
+
/** Position ids of the characters to tombstone. */
|
|
56
|
+
positionIds: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CounterIncrementPayload {
|
|
60
|
+
/** Delta (positive for increment, negative for decrement). */
|
|
61
|
+
delta: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SetAddPayload {
|
|
65
|
+
/** The value to add (JSON-serializable). */
|
|
66
|
+
value: unknown
|
|
67
|
+
/** Unique tag for this add operation (for OR-Set semantics). */
|
|
68
|
+
tag: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SetRemovePayload {
|
|
72
|
+
/** The value to remove. */
|
|
73
|
+
value: unknown
|
|
74
|
+
/** Tags being causally removed (observed tags at removal time). */
|
|
75
|
+
tags: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface RegisterSetPayload {
|
|
79
|
+
/** The new value. */
|
|
80
|
+
value: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type OperationPayload =
|
|
84
|
+
| TextInsertPayload
|
|
85
|
+
| TextDeletePayload
|
|
86
|
+
| CounterIncrementPayload
|
|
87
|
+
| SetAddPayload
|
|
88
|
+
| SetRemovePayload
|
|
89
|
+
| RegisterSetPayload
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export function makeOpId(hlc: HLCTimestamp): string {
|
|
96
|
+
return `${hlc.siteId}:${hlc.ts}:${hlc.counter}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function makePositionId(hlc: HLCTimestamp, charIndex: number): string {
|
|
100
|
+
return `${hlc.siteId}:${hlc.ts}:${hlc.counter}:${charIndex}`
|
|
101
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDTRegister<T> -- Last-Writer-Wins Register (LWW-Register).
|
|
3
|
+
*
|
|
4
|
+
* The value with the highest HLC timestamp wins.
|
|
5
|
+
* Ties are broken by site id comparison.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { HLCTimestamp } from './clock.js'
|
|
9
|
+
import { HybridLogicalClock, compareHLC } from './clock.js'
|
|
10
|
+
import type { Operation, RegisterSetPayload } from './operations.js'
|
|
11
|
+
import { makeOpId } from './operations.js'
|
|
12
|
+
|
|
13
|
+
export type RegisterChangeCallback<T> = (value: T | undefined) => void
|
|
14
|
+
|
|
15
|
+
export class CRDTRegister<T = unknown> {
|
|
16
|
+
private _value: T | undefined = undefined
|
|
17
|
+
private _hlc: HLCTimestamp | null = null
|
|
18
|
+
|
|
19
|
+
private readonly _clock: HybridLogicalClock
|
|
20
|
+
private readonly _documentId: string
|
|
21
|
+
private readonly _field: string
|
|
22
|
+
private readonly _pendingOps: Operation[] = []
|
|
23
|
+
private _listeners = new Set<RegisterChangeCallback<T>>()
|
|
24
|
+
|
|
25
|
+
constructor(documentId: string, field: string, clock: HybridLogicalClock) {
|
|
26
|
+
this._documentId = documentId
|
|
27
|
+
this._field = field
|
|
28
|
+
this._clock = clock
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- Public API ---------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
get value(): T | undefined {
|
|
34
|
+
return this._value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
set(value: T): Operation {
|
|
38
|
+
const hlc = this._clock.now()
|
|
39
|
+
this._value = value
|
|
40
|
+
this._hlc = hlc
|
|
41
|
+
|
|
42
|
+
const op: Operation = {
|
|
43
|
+
id: makeOpId(hlc),
|
|
44
|
+
documentId: this._documentId,
|
|
45
|
+
field: this._field,
|
|
46
|
+
hlc,
|
|
47
|
+
type: 'register.set',
|
|
48
|
+
payload: { value } satisfies RegisterSetPayload,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._pendingOps.push(op)
|
|
52
|
+
this._notifyChange()
|
|
53
|
+
return op
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Apply a remote set operation. LWW: only apply if remote HLC > current. */
|
|
57
|
+
applyRemote(op: Operation): void {
|
|
58
|
+
if (op.type !== 'register.set') return
|
|
59
|
+
this._clock.receive(op.hlc)
|
|
60
|
+
|
|
61
|
+
const payload = op.payload as RegisterSetPayload
|
|
62
|
+
|
|
63
|
+
if (this._hlc === null || compareHLC(op.hlc, this._hlc) > 0) {
|
|
64
|
+
this._value = payload.value as T
|
|
65
|
+
this._hlc = op.hlc
|
|
66
|
+
this._notifyChange()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Export current state for full-state sync. */
|
|
71
|
+
exportState(): { value: T | undefined; hlc: HLCTimestamp | null } {
|
|
72
|
+
return { value: this._value, hlc: this._hlc }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Import state from full-state sync. LWW merge. */
|
|
76
|
+
importState(value: T, hlc: HLCTimestamp): void {
|
|
77
|
+
if (this._hlc === null || compareHLC(hlc, this._hlc) > 0) {
|
|
78
|
+
this._value = value
|
|
79
|
+
this._hlc = hlc
|
|
80
|
+
this._notifyChange()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
drainOps(): Operation[] {
|
|
85
|
+
return this._pendingOps.splice(0, this._pendingOps.length)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
onChange(callback: RegisterChangeCallback<T>): () => void {
|
|
89
|
+
this._listeners.add(callback)
|
|
90
|
+
return () => {
|
|
91
|
+
this._listeners.delete(callback)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private _notifyChange(): void {
|
|
96
|
+
if (this._listeners.size === 0) return
|
|
97
|
+
const val = this._value
|
|
98
|
+
for (const cb of this._listeners) {
|
|
99
|
+
try {
|
|
100
|
+
cb(val)
|
|
101
|
+
} catch {
|
|
102
|
+
// listener errors must not break notification
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|