mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
7-phase implementation of Convex-inspired realtime developer experience: - crdt/: Go CRDT engine (GCounter, PNCounter, LWWRegister, ORSet, RGA text) with state-vector sync protocol and convergence-tested types (31 tests) - apis/ws.go: Pure RFC 6455 WebSocket endpoint with JWT auth, CRDT sync, presence tracking, channel subscriptions - apis/presence.go: Channel-based presence manager for collaborative awareness - cmd/typegen/: CLI tool to generate TypeScript interfaces from Base schemas (4 tests, all 14 field types mapped) - plugins/scheduler/: Scheduled functions with exactly-once semantics, retry with backoff, JSVM bindings (8 tests) - plugins/platform/: Multi-tenant platform with IAM (hanzo.id) auth, KMS (kms.hanzo.ai) secrets, tenant isolation via collection prefixes, RBAC roles, API key management (7 tests) - sdk/base-js/: @hanzoai/base TypeScript SDK with reactive queries, query deduplication, optimistic updates, state versioning, CRDT client (72 smoke tests passing) - sdk/base-react/: @hanzoai/base-react React hooks (useQuery, useMutation, useAuth, useRealtime, useCRDT, usePresence) with useSyncExternalStore
264 lines
7.2 KiB
Go
264 lines
7.2 KiB
Go
package crdt
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/gob"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
)
|
|
|
|
// SyncMessage types for the state-vector based sync protocol.
|
|
const (
|
|
// SyncStep1 is sent by a connecting client: "here is my state vector".
|
|
SyncStep1 = "sync_step1"
|
|
// SyncStep2 is the server response: "here are ops you are missing".
|
|
SyncStep2 = "sync_step2"
|
|
// SyncUpdate is an incremental update (ops broadcast).
|
|
SyncUpdate = "sync_update"
|
|
)
|
|
|
|
// SyncMessage is the wire format for CRDT sync messages.
|
|
type SyncMessage struct {
|
|
Type string `json:"type"`
|
|
DocID string `json:"docId"`
|
|
ClientID string `json:"clientId,omitempty"`
|
|
StateVector StateVersion `json:"stateVector,omitempty"`
|
|
Ops []Operation `json:"ops,omitempty"`
|
|
}
|
|
|
|
// SyncBroadcastFunc is called when operations need to be broadcast to other clients.
|
|
type SyncBroadcastFunc func(docID string, excludeClient string, msg []byte)
|
|
|
|
// SyncManager handles CRDT document synchronization across clients.
|
|
type SyncManager struct {
|
|
mu sync.RWMutex
|
|
docs map[string]*Document
|
|
broadcast SyncBroadcastFunc
|
|
}
|
|
|
|
// NewSyncManager creates a new SyncManager.
|
|
// The broadcast function is called whenever operations should be sent to other clients.
|
|
func NewSyncManager(broadcast SyncBroadcastFunc) *SyncManager {
|
|
return &SyncManager{
|
|
docs: make(map[string]*Document),
|
|
broadcast: broadcast,
|
|
}
|
|
}
|
|
|
|
// RegisterDocument adds a document to the sync manager.
|
|
func (sm *SyncManager) RegisterDocument(id string, doc *Document) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
sm.docs[id] = doc
|
|
}
|
|
|
|
// UnregisterDocument removes a document from the sync manager.
|
|
func (sm *SyncManager) UnregisterDocument(id string) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
delete(sm.docs, id)
|
|
}
|
|
|
|
// GetDocument returns a registered document by ID.
|
|
func (sm *SyncManager) GetDocument(id string) *Document {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
return sm.docs[id]
|
|
}
|
|
|
|
// GetOrCreateDocument returns an existing document or creates a new one.
|
|
func (sm *SyncManager) GetOrCreateDocument(id string, nodeID NodeID) *Document {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
if doc, ok := sm.docs[id]; ok {
|
|
return doc
|
|
}
|
|
doc := NewDocument(id, nodeID)
|
|
sm.docs[id] = doc
|
|
return doc
|
|
}
|
|
|
|
// HandleSync processes an incoming sync message from a client and returns
|
|
// a response message (if any). This implements the state-vector sync protocol:
|
|
//
|
|
// 1. Client sends SyncStep1 with its state vector
|
|
// 2. Server responds with SyncStep2 containing ops the client is missing
|
|
// 3. Server also sends the server's state vector so the client can respond with SyncStep2
|
|
// 4. Incremental updates are broadcast as SyncUpdate
|
|
func (sm *SyncManager) HandleSync(clientID string, raw []byte) ([]byte, error) {
|
|
var msg SyncMessage
|
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
|
return nil, fmt.Errorf("sync unmarshal: %w", err)
|
|
}
|
|
|
|
sm.mu.RLock()
|
|
doc, ok := sm.docs[msg.DocID]
|
|
sm.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("document %q not found", msg.DocID)
|
|
}
|
|
|
|
switch msg.Type {
|
|
case SyncStep1:
|
|
return sm.handleSyncStep1(doc, clientID, msg)
|
|
case SyncStep2:
|
|
return sm.handleSyncStep2(doc, clientID, msg)
|
|
case SyncUpdate:
|
|
return sm.handleSyncUpdate(doc, clientID, msg)
|
|
default:
|
|
return nil, fmt.Errorf("unknown sync message type: %s", msg.Type)
|
|
}
|
|
}
|
|
|
|
// handleSyncStep1 processes a client's state vector and responds with missing ops.
|
|
func (sm *SyncManager) handleSyncStep1(doc *Document, clientID string, msg SyncMessage) ([]byte, error) {
|
|
ops := doc.Diff(msg.StateVector)
|
|
|
|
response := SyncMessage{
|
|
Type: SyncStep2,
|
|
DocID: msg.DocID,
|
|
StateVector: doc.Version(),
|
|
Ops: ops,
|
|
}
|
|
|
|
return json.Marshal(response)
|
|
}
|
|
|
|
// handleSyncStep2 processes ops from a client that we are missing.
|
|
func (sm *SyncManager) handleSyncStep2(doc *Document, clientID string, msg SyncMessage) ([]byte, error) {
|
|
if len(msg.Ops) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
if err := sm.applyOps(doc, msg.Ops); err != nil {
|
|
return nil, fmt.Errorf("apply ops: %w", err)
|
|
}
|
|
|
|
sm.broadcastOps(msg.DocID, clientID, msg.Ops)
|
|
return nil, nil
|
|
}
|
|
|
|
// handleSyncUpdate processes an incremental update from a client.
|
|
func (sm *SyncManager) handleSyncUpdate(doc *Document, clientID string, msg SyncMessage) ([]byte, error) {
|
|
if len(msg.Ops) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
if err := sm.applyOps(doc, msg.Ops); err != nil {
|
|
return nil, fmt.Errorf("apply ops: %w", err)
|
|
}
|
|
|
|
sm.broadcastOps(msg.DocID, clientID, msg.Ops)
|
|
return nil, nil
|
|
}
|
|
|
|
// applyOps applies a slice of operations to a document.
|
|
func (sm *SyncManager) applyOps(doc *Document, ops []Operation) error {
|
|
for _, op := range ops {
|
|
switch op.FieldType {
|
|
case FieldTypeText:
|
|
var rgaOps []RGAOp
|
|
if err := gobDecode(op.Data, &rgaOps); err != nil {
|
|
return fmt.Errorf("decode text ops for field %q: %w", op.Field, err)
|
|
}
|
|
rga := doc.GetText(op.Field)
|
|
for _, rgaOp := range rgaOps {
|
|
if err := rga.ApplyOp(rgaOp); err != nil {
|
|
// missing parent means out-of-order; skip for now
|
|
continue
|
|
}
|
|
}
|
|
|
|
case FieldTypeCounter:
|
|
var snap counterSnapshot
|
|
if err := gobDecode(op.Data, &snap); err != nil {
|
|
return fmt.Errorf("decode counter for field %q: %w", op.Field, err)
|
|
}
|
|
ctr := doc.GetCounter(op.Field)
|
|
remote := NewPNCounter()
|
|
remote.pos.mu.Lock()
|
|
remote.pos.state = snap.Pos
|
|
remote.pos.mu.Unlock()
|
|
remote.neg.mu.Lock()
|
|
remote.neg.state = snap.Neg
|
|
remote.neg.mu.Unlock()
|
|
ctr.Merge(remote)
|
|
|
|
case FieldTypeSet:
|
|
var snap setSnapshot
|
|
if err := gobDecode(op.Data, &snap); err != nil {
|
|
return fmt.Errorf("decode set for field %q: %w", op.Field, err)
|
|
}
|
|
s := doc.GetSet(op.Field)
|
|
remote := NewORSet("remote")
|
|
remote.mu.Lock()
|
|
remote.elems = snap.Elems
|
|
remote.mu.Unlock()
|
|
s.Merge(remote)
|
|
|
|
case FieldTypeRegister:
|
|
var snap registerSnapshot
|
|
if err := gobDecode(op.Data, &snap); err != nil {
|
|
return fmt.Errorf("decode register for field %q: %w", op.Field, err)
|
|
}
|
|
reg := doc.GetRegister(op.Field)
|
|
reg.Set(snap.Value, snap.Timestamp)
|
|
|
|
case FieldTypeMVRegister:
|
|
var snap mvRegisterSnapshot
|
|
if err := gobDecode(op.Data, &snap); err != nil {
|
|
return fmt.Errorf("decode mv register for field %q: %w", op.Field, err)
|
|
}
|
|
mv := doc.GetMVRegister(op.Field)
|
|
remote := NewMVRegister()
|
|
remote.mu.Lock()
|
|
remote.entries = snap.Entries
|
|
remote.mu.Unlock()
|
|
mv.Merge(remote)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BroadcastOps broadcasts operations to all clients connected to a document.
|
|
func (sm *SyncManager) BroadcastOps(docID string, ops []Operation) {
|
|
sm.broadcastOps(docID, "", ops)
|
|
}
|
|
|
|
func (sm *SyncManager) broadcastOps(docID string, excludeClient string, ops []Operation) {
|
|
if sm.broadcast == nil || len(ops) == 0 {
|
|
return
|
|
}
|
|
|
|
msg := SyncMessage{
|
|
Type: SyncUpdate,
|
|
DocID: docID,
|
|
Ops: ops,
|
|
}
|
|
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
sm.broadcast(docID, excludeClient, data)
|
|
}
|
|
|
|
// Documents returns a list of all registered document IDs.
|
|
func (sm *SyncManager) Documents() []string {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
ids := make([]string, 0, len(sm.docs))
|
|
for id := range sm.docs {
|
|
ids = append(ids, id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// gobDecode decodes gob-encoded data into v.
|
|
func gobDecode(data []byte, v any) error {
|
|
return gob.NewDecoder(bytes.NewReader(data)).Decode(v)
|
|
}
|