@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,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
+ }