Documentation
¶
Overview ¶
Package crdt provides an optional CRDT (Conflict-Free Replicated Data Type) layer for Grove. It enables offline-first, multi-node, and eventually-consistent use cases by tracking field-level changes with Hybrid Logical Clocks and merging them automatically during sync.
Tag model fields with crdt:lww, crdt:counter, or crdt:set to opt into CRDT behavior. Fields without CRDT tags are unaffected.
type Document struct {
grove.BaseModel `grove:"table:documents,alias:d"`
ID string `grove:"id,pk"`
Title string `grove:"title,crdt:lww"`
ViewCount int64 `grove:"view_count,crdt:counter"`
Tags []string `grove:"tags,type:jsonb,crdt:set"`
}
Package crdt provides conflict-free replicated data types for Grove.
The crdt package enables offline-first, multi-node, and eventually-consistent use cases by adding an optional CRDT layer on top of Grove's existing ORM. It supports three CRDT types (LWW-Register, PN-Counter, OR-Set), multiple sync topologies (edge-to-cloud, peer-to-peer, hub-and-spoke), and integrates with the Forge ecosystem for routing, streaming, and middleware.
CRDT Types ¶
Tag struct fields with crdt:"type" to enable CRDT merge semantics:
- crdt:lww — Last-Writer-Wins Register. Higher HLC timestamp wins.
- crdt:counter — PN-Counter. Per-node increment/decrement, merged by max.
- crdt:set — OR-Set. Add-wins observed-remove set.
Fields without crdt: tags work normally with zero overhead.
Quick Start ¶
// 1. Define a CRDT-enabled model
type Document struct {
grove.BaseModel `grove:"table:documents,alias:d"`
ID string `grove:"id,pk"`
Title string `grove:"title,crdt:lww"`
ViewCount int64 `grove:"view_count,crdt:counter"`
Tags []string `grove:"tags,type:jsonb,crdt:set"`
}
// 2. Create the CRDT plugin
plugin := crdt.New(crdt.WithNodeID("node-1"))
db.Hooks().AddHook(plugin, hook.Scope{Tables: []string{"documents"}})
// 3. Sync between nodes
syncer := crdt.NewSyncer(plugin,
crdt.WithTransport(crdt.HTTPTransport("https://cloud.example.com/sync")),
crdt.WithSyncTables("documents"),
)
report, err := syncer.Sync(ctx)
Forge Integration ¶
When running inside a Forge app, use the grove/extension package with WithCRDT to get automatic route registration, SSE streaming, and middleware support:
ext := extension.New(
extension.WithDriver(pgdb),
extension.WithCRDT(crdtPlugin, hook.Scope{Tables: []string{"documents"}}),
extension.WithSyncer(syncer),
extension.WithMigrations(crdt.Migrations),
)
app.RegisterExtension(ext)
// Routes registered automatically:
// POST /sync/pull — pull changes from this node
// POST /sync/push — push changes to this node
// GET /sync/stream — SSE real-time change stream
Sync Hooks ¶
Implement SyncHook to intercept data during sync operations for validation, transformation, filtering, or auditing:
type MyHook struct { crdt.BaseSyncHook }
func (h *MyHook) BeforeInboundChange(ctx context.Context, c *crdt.ChangeRecord) (*crdt.ChangeRecord, error) {
// validate, transform, or reject incoming changes
return c, nil
}
func (h *MyHook) BeforeOutboundRead(ctx context.Context, changes []crdt.ChangeRecord) ([]crdt.ChangeRecord, error) {
// filter changes before sending to remote peers
return changes, nil
}
plugin := crdt.New(
crdt.WithNodeID("node-1"),
crdt.WithSyncHook(&MyHook{}),
)
Streaming Transport ¶
Use StreamingTransport for real-time SSE-based change propagation alongside the periodic poll-based sync:
syncer := crdt.NewSyncer(crdtPlugin,
crdt.WithTransport(crdt.NewStreamingTransport("https://cloud.example.com/sync",
crdt.WithStreamTables("documents"),
)),
crdt.WithSyncInterval(30*time.Second), // Fallback poll interval
)
// Start both: SSE streaming for real-time + periodic poll as fallback
go syncer.Run(ctx) // Periodic pull/push
go syncer.StreamSync(ctx) // SSE real-time (if transport supports it)
Hybrid Logical Clock ¶
All CRDT operations use a Hybrid Logical Clock (HLC) that combines physical wall-clock time with a logical counter and node ID. This provides total ordering without coordination between nodes.
Storage ¶
CRDT metadata is stored in shadow tables (_<table>_crdt) alongside the primary table. The primary table schema is never modified. Shadow tables are created automatically via migrations or Plugin.EnsureShadowTable.
Standalone Usage (No Forge) ¶
The crdt package works without Forge. Use NewHTTPHandler to create a standard http.Handler for sync endpoints:
handler := crdt.NewHTTPHandler(crdtPlugin)
mux.Handle("/sync/", handler)
Collaborative Text ¶
crdt:text fields are character-level collaborative sequences with formatting attributes (the YText analog): TextState with origin-span addressing, stable cursor positions (TextRef, TextState.RefAt, TextState.IndexOf), Quill-style deltas (TextState.Delta) and run-coalesced sequential typing. ORM writes reconcile whole strings via TextState.SetString; transports carry TextOp payloads.
Op Application And Compaction ¶
ApplyChange is the canonical ChangeRecord → FieldState fold every transport and downstream store uses: it honors all type-specific payloads (counter deltas, set/list/text ops, document path writes, and full-state carriers via ChangeRecord.State). Long-lived states GC their tombstones with the horizon-based Compact methods (State.Compact, RGAListState.Compact, ORSetState.Compact, TextState.Compact).
Architecture ¶
grove/crdt/ ├── crdt.go — Core types: CRDTType, State, FieldState, ChangeRecord ├── clock.go — HLC implementation ├── register.go — LWW-Register merge ├── counter.go — PN-Counter merge ├── set.go — OR-Set merge (add-wins) ├── list.go — RGA list merge (cached document order) ├── text.go — Text CRDT (origin-span fragments, formatting) ├── document.go — Nested path-keyed document merge ├── merge.go — MergeEngine: field + state merging ├── apply.go — ApplyChange: canonical op application ├── compact.go — Horizon-based tombstone compaction (GC) ├── plugin.go — Plugin (Grove hook integration) ├── hooks.go — PostMutation/PreQuery hooks for CRDT ├── metadata.go — Shadow table read/write operations ├── sync.go — Syncer: push/pull orchestration + StreamSync ├── sync_hooks.go — SyncHook interface for intercepting sync data ├── transport.go — Transport interface, HTTPClient, StreamingTransport ├── server.go — SyncController + HTTPHandler (standalone) ├── options.go — Functional options ├── inspect.go — Debug/inspect utilities └── migrations.go — Shadow table DDL
Index ¶
- Constants
- func DocumentRoomID(table, pk string) string
- func DropShadowTableDDL(table string) string
- func MarshalPresenceEvent(event PresenceEvent) ([]byte, error)
- func NewHTTPHandler(plugin *Plugin, opts ...SyncControllerOption) http.Handler
- func RoomHTTPHandler(rm *RoomManager) http.Handler
- func ShadowTableDDL(table string) string
- func ShadowTableName(table string) string
- func ShadowTableSyncIndex(table string) string
- func ValidCRDTType(t string) bool
- type AttrState
- type BaseCRDTPlugin
- func (BaseCRDTPlugin) AfterHistoryRead(_ context.Context, _, _ string, s *State) (*State, error)
- func (BaseCRDTPlugin) AfterMerge(_ context.Context, _ *MergeEvent) error
- func (BaseCRDTPlugin) AfterMetadataRead(_ context.Context, _ *MetadataReadEvent, s *State) (*State, error)
- func (BaseCRDTPlugin) AfterMetadataWrite(_ context.Context, _ *MetadataWriteEvent) error
- func (BaseCRDTPlugin) AfterPresenceEvent(_ context.Context, _ *PresenceEvent) error
- func (BaseCRDTPlugin) AfterRoomJoin(_ context.Context, _, _ string) error
- func (BaseCRDTPlugin) AfterRoomLeave(_ context.Context, _, _ string) error
- func (BaseCRDTPlugin) BeforeHistoryRead(_ context.Context, _, _ string, _ HLC) error
- func (BaseCRDTPlugin) BeforeMerge(_ context.Context, ev *MergeEvent) (*FieldState, error)
- func (BaseCRDTPlugin) BeforeMetadataRead(_ context.Context, _ *MetadataReadEvent) error
- func (BaseCRDTPlugin) BeforeMetadataWrite(_ context.Context, ev *MetadataWriteEvent) (*FieldState, error)
- func (BaseCRDTPlugin) BeforePresenceUpdate(_ context.Context, u *PresenceUpdate) (*PresenceUpdate, error)
- func (BaseCRDTPlugin) BeforeRoomJoin(_ context.Context, _, _ string) error
- func (BaseCRDTPlugin) BeforeRoomLeave(_ context.Context, _, _ string) error
- func (BaseCRDTPlugin) Name() string
- func (BaseCRDTPlugin) OnClientConnect(_ context.Context, _, _ string) error
- func (BaseCRDTPlugin) OnClientDisconnect(_ context.Context, _, _ string) error
- func (BaseCRDTPlugin) OnRoomCreated(_ context.Context, _ *Room) error
- func (BaseCRDTPlugin) OnRoomDestroyed(_ context.Context, _ *Room) error
- type BaseSyncHook
- func (BaseSyncHook) AfterInboundChange(_ context.Context, _ *ChangeRecord) error
- func (BaseSyncHook) BeforeInboundChange(_ context.Context, c *ChangeRecord) (*ChangeRecord, error)
- func (BaseSyncHook) BeforeOutboundChange(_ context.Context, c *ChangeRecord) (*ChangeRecord, error)
- func (BaseSyncHook) BeforeOutboundRead(_ context.Context, cs []ChangeRecord) ([]ChangeRecord, error)
- type CRDTPlugin
- type CRDTType
- type ChangeRecord
- type Clock
- type ClockOption
- type ConnectionInterceptor
- type CounterDelta
- type CursorPosition
- type DocumentCRDTState
- func (d *DocumentCRDTState) DeleteField(path string)
- func (d *DocumentCRDTState) GetField(path string) *FieldState
- func (d *DocumentCRDTState) Paths() []string
- func (d *DocumentCRDTState) Resolve() map[string]any
- func (d *DocumentCRDTState) SetField(path string, value any, clock HLC, nodeID string) error
- func (d *DocumentCRDTState) SetFieldState(path string, fs *FieldState)
- func (d *DocumentCRDTState) ToFieldState(clock HLC, nodeID string) *FieldState
- type ExecResult
- type Executor
- type FieldHistoryEntry
- type FieldHistoryRequest
- type FieldHistoryResponse
- type FieldState
- type ForgeWSConn
- type HLC
- type HTTPClient
- type HistoryRequest
- type HistoryResponse
- type HybridClock
- type InspectField
- type InspectResult
- type LWWRegister
- type ListOp
- type ListOpType
- type MergeEngine
- type MergeEvent
- type MergeInterceptor
- type MetadataInterceptor
- type MetadataReadEvent
- type MetadataRow
- type MetadataStore
- func (ms *MetadataStore) CleanTombstones(ctx context.Context, table string, olderThan int64) error
- func (ms *MetadataStore) ReadChangesSince(ctx context.Context, table string, since HLC, limits ...int) ([]ChangeRecord, error)
- func (ms *MetadataStore) ReadFieldHistory(ctx context.Context, table, pk, field string, since HLC, limit int) ([]FieldHistoryEntry, error)
- func (ms *MetadataStore) ReadState(ctx context.Context, table, pk string) (*State, error)
- func (ms *MetadataStore) ReadStateAt(ctx context.Context, table, pk string, at HLC) (*State, error)
- func (ms *MetadataStore) WriteFieldState(ctx context.Context, table, pk, field string, fs *FieldState) error
- func (ms *MetadataStore) WriteFieldStatesAtomic(ctx context.Context, table, pk string, fields map[string]*FieldState) error
- func (ms *MetadataStore) WriteTombstone(ctx context.Context, table, pk string, clock HLC, nodeID string) error
- type MetadataWriteEvent
- type Metrics
- type MetricsSnapshot
- type ORSetState
- func (s *ORSetState) Add(element any, nodeID string, clock HLC) error
- func (s *ORSetState) Compact(before HLC) int
- func (s *ORSetState) Contains(element any) (bool, error)
- func (s *ORSetState) Elements() []json.RawMessage
- func (s *ORSetState) Remove(element any) error
- func (s *ORSetState) ToFieldState(clock HLC, nodeID string) *FieldState
- type Option
- type PNCounterState
- type ParticipantData
- type Plugin
- func (p *Plugin) AfterMutation(ctx context.Context, qc *hook.QueryContext, data, _ any) error
- func (p *Plugin) BeforeQuery(_ context.Context, _ *hook.QueryContext) (*hook.HookResult, error)
- func (p *Plugin) CleanupTombstones(ctx context.Context, table string) error
- func (p *Plugin) Clock() Clock
- func (p *Plugin) EnsureShadowTable(ctx context.Context, table string) error
- func (p *Plugin) Init(_ context.Context, _ any) error
- func (p *Plugin) Inspect(ctx context.Context, table, pk string) (*State, error)
- func (p *Plugin) MergeEngine() *MergeEngine
- func (p *Plugin) MetadataStore() *MetadataStore
- func (p *Plugin) Name() string
- func (p *Plugin) NodeID() string
- func (p *Plugin) RunTombstoneCleanup(ctx context.Context, interval time.Duration, tables ...string)
- func (p *Plugin) SetExecutor(exec Executor)
- func (p *Plugin) SyncHooks() *SyncHookChain
- type PluginChain
- func (pc *PluginChain) Add(p CRDTPlugin)
- func (pc *PluginChain) DispatchAfterHistoryRead(ctx context.Context, table, pk string, state *State) (*State, error)
- func (pc *PluginChain) DispatchAfterMerge(ctx context.Context, ev *MergeEvent)
- func (pc *PluginChain) DispatchAfterMetadataRead(ctx context.Context, ev *MetadataReadEvent, state *State) (*State, error)
- func (pc *PluginChain) DispatchAfterMetadataWrite(ctx context.Context, ev *MetadataWriteEvent)
- func (pc *PluginChain) DispatchAfterPresenceEvent(ctx context.Context, event *PresenceEvent)
- func (pc *PluginChain) DispatchAfterRoomJoin(ctx context.Context, roomID, nodeID string)
- func (pc *PluginChain) DispatchBeforeHistoryRead(ctx context.Context, table, pk string, atHLC HLC) error
- func (pc *PluginChain) DispatchBeforeMerge(ctx context.Context, ev *MergeEvent) (*FieldState, error)
- func (pc *PluginChain) DispatchBeforeMetadataRead(ctx context.Context, ev *MetadataReadEvent) error
- func (pc *PluginChain) DispatchBeforeMetadataWrite(ctx context.Context, ev *MetadataWriteEvent) (*FieldState, error)
- func (pc *PluginChain) DispatchBeforePresenceUpdate(ctx context.Context, update *PresenceUpdate) (*PresenceUpdate, error)
- func (pc *PluginChain) DispatchBeforeRoomJoin(ctx context.Context, roomID, nodeID string) error
- func (pc *PluginChain) DispatchOnClientConnect(ctx context.Context, nodeID, transport string) error
- func (pc *PluginChain) DispatchOnClientDisconnect(ctx context.Context, nodeID, transport string)
- func (pc *PluginChain) Len() int
- func (pc *PluginChain) Plugins() []CRDTPlugin
- type PresenceEvent
- type PresenceInterceptor
- type PresenceManager
- func (pm *PresenceManager) Close()
- func (pm *PresenceManager) Get(topic string) []PresenceState
- func (pm *PresenceManager) GetTopicsForNode(nodeID string) []string
- func (pm *PresenceManager) Remove(topic, nodeID string) *PresenceEvent
- func (pm *PresenceManager) RemoveNode(nodeID string) []PresenceEvent
- func (pm *PresenceManager) Update(update PresenceUpdate) PresenceEvent
- type PresenceSnapshot
- type PresenceState
- type PresenceUpdate
- type PullRequest
- type PullResponse
- type PushRequest
- type PushResponse
- type RGAListState
- func (l *RGAListState) Compact(before HLC) int
- func (l *RGAListState) Delete(id HLC)
- func (l *RGAListState) Elements() []json.RawMessage
- func (l *RGAListState) Insert(value any, parentID HLC, nodeID string, clock HLC) error
- func (l *RGAListState) Len() int
- func (l *RGAListState) Move(id, newParentID HLC, nodeID string, clock HLC)
- func (l *RGAListState) NodeIDs() []HLC
- func (l *RGAListState) ToFieldState(clock HLC, nodeID string) *FieldState
- type RGANode
- type Room
- type RoomEvent
- type RoomEventType
- type RoomHook
- type RoomInfo
- type RoomInterceptor
- type RoomManager
- func (rm *RoomManager) AddHook(hook RoomHook)
- func (rm *RoomManager) Close()
- func (rm *RoomManager) CreateDocumentRoom(ctx context.Context, table, pk string, opts ...RoomOption) (*Room, error)
- func (rm *RoomManager) CreateRoom(ctx context.Context, id, roomType string, opts ...RoomOption) (*Room, error)
- func (rm *RoomManager) GetDocumentParticipants(table, pk string) []PresenceState
- func (rm *RoomManager) GetRoom(id string) *Room
- func (rm *RoomManager) GetRoomInfo(id string) *RoomInfo
- func (rm *RoomManager) JoinDocumentRoom(ctx context.Context, table, pk, nodeID string, data json.RawMessage) error
- func (rm *RoomManager) JoinRoom(ctx context.Context, roomID, nodeID string, data json.RawMessage) error
- func (rm *RoomManager) LeaveAllRooms(ctx context.Context, nodeID string)
- func (rm *RoomManager) LeaveDocumentRoom(ctx context.Context, table, pk, nodeID string)
- func (rm *RoomManager) LeaveRoom(ctx context.Context, roomID, nodeID string)
- func (rm *RoomManager) ListRooms() []RoomInfo
- func (rm *RoomManager) ListRoomsByType(roomType string) []RoomInfo
- func (rm *RoomManager) ParticipantCount(roomID string) int
- func (rm *RoomManager) SetRoomMetadata(roomID string, metadata any) error
- func (rm *RoomManager) UpdateCursor(roomID, nodeID string, cursor CursorPosition)
- func (rm *RoomManager) UpdateTypingStatus(roomID, nodeID string, isTyping bool)
- type RoomOption
- type Rows
- type SetOp
- type SetOperation
- type State
- type StreamingOption
- type StreamingTransport
- type SyncController
- func (c *SyncController) AddPlugin(p CRDTPlugin)
- func (c *SyncController) Close()
- func (c *SyncController) HandleFieldHistory(ctx context.Context, req *FieldHistoryRequest) (*FieldHistoryResponse, error)
- func (c *SyncController) HandleGetPresence(_ context.Context, topic string) (*PresenceSnapshot, error)
- func (c *SyncController) HandleHistory(ctx context.Context, req *HistoryRequest) (*HistoryResponse, error)
- func (c *SyncController) HandlePresenceUpdate(ctx context.Context, update *PresenceUpdate) (*PresenceEvent, error)
- func (c *SyncController) HandlePull(ctx context.Context, req *PullRequest) (*PullResponse, error)
- func (c *SyncController) HandlePush(ctx context.Context, req *PushRequest) (*PushResponse, error)
- func (c *SyncController) Logger() log.Logger
- func (c *SyncController) Metrics() *Metrics
- func (c *SyncController) PluginChain() *PluginChain
- func (c *SyncController) Presence() *PresenceManager
- func (c *SyncController) PresenceChannel() <-chan PresenceEvent
- func (c *SyncController) Rooms() *RoomManager
- func (c *SyncController) StreamChangesSince(ctx context.Context, tables []string, since HLC) (<-chan []ChangeRecord, error)
- func (c *SyncController) TimeTravelEnabled() bool
- type SyncControllerOption
- func WithControllerPlugin(plugin CRDTPlugin) SyncControllerOption
- func WithControllerSyncHook(hook SyncHook) SyncControllerOption
- func WithMetrics() SyncControllerOption
- func WithPresenceBufferSize(size int) SyncControllerOption
- func WithPresenceEnabled(enabled bool) SyncControllerOption
- func WithPresenceTTL(d time.Duration) SyncControllerOption
- func WithRoomManager(enabled bool) SyncControllerOption
- func WithStreamKeepAlive(d time.Duration) SyncControllerOption
- func WithStreamPollInterval(d time.Duration) SyncControllerOption
- func WithTimeTravelEnabled(enabled bool) SyncControllerOption
- func WithTimeTravelMaxDepth(depth int) SyncControllerOption
- func WithValidation(cfg *ValidationConfig) SyncControllerOption
- type SyncFilter
- type SyncHook
- type SyncHookChain
- func (c *SyncHookChain) Add(hook SyncHook)
- func (c *SyncHookChain) AfterInboundChange(ctx context.Context, change *ChangeRecord) error
- func (c *SyncHookChain) BeforeInboundChange(ctx context.Context, change *ChangeRecord) (*ChangeRecord, error)
- func (c *SyncHookChain) BeforeOutboundChange(ctx context.Context, change *ChangeRecord) (*ChangeRecord, error)
- func (c *SyncHookChain) BeforeOutboundRead(ctx context.Context, changes []ChangeRecord) ([]ChangeRecord, error)
- func (c *SyncHookChain) Len() int
- type SyncReport
- type Syncer
- type SyncerOption
- type Tag
- type TextDelta
- type TextFragment
- type TextOp
- type TextOpType
- type TextRef
- type TextSpan
- type TextState
- func (t *TextState) Apply(op *TextOp, nodeID string, clock HLC) error
- func (t *TextState) Compact(before HLC) int
- func (t *TextState) Delete(ref TextRef, length int) (*TextOp, error)
- func (t *TextState) Delta() []TextDelta
- func (t *TextState) Format(ref TextRef, length int, attrs map[string]json.RawMessage, nodeID string, ...) (*TextOp, error)
- func (t *TextState) IndexOf(ref TextRef) (int, bool)
- func (t *TextState) Insert(ref TextRef, s, nodeID string, clock HLC) (*TextOp, error)
- func (t *TextState) Len() int
- func (t *TextState) RefAt(index int) (TextRef, bool)
- func (t *TextState) SetString(s, nodeID string, clock HLC) ([]*TextOp, error)
- func (t *TextState) ToFieldState(clock HLC, nodeID string) *FieldState
- func (t *TextState) Value() string
- type TimeTravelConfig
- type TimeTravelInterceptor
- type Transport
- type TxExecutor
- type TxHandle
- type ValidationConfig
- type WSMessageType
- type WebSocketConn
- type WebSocketDialer
- type WebSocketHandler
- type WebSocketMessage
- type WebSocketOption
- type WebSocketTransport
- func (t *WebSocketTransport) Close() error
- func (t *WebSocketTransport) OnChange(handler func(ChangeRecord))
- func (t *WebSocketTransport) OnPresence(handler func(PresenceEvent))
- func (t *WebSocketTransport) Pull(ctx context.Context, req *PullRequest) (*PullResponse, error)
- func (t *WebSocketTransport) Push(ctx context.Context, req *PushRequest) (*PushResponse, error)
- func (t *WebSocketTransport) Start(ctx context.Context) error
- func (t *WebSocketTransport) StartWithReconnect(ctx context.Context, dial WebSocketDialer) error
Constants ¶
const ( PresenceJoin = "join" PresenceUpdateEvt = "update" PresenceLeave = "leave" )
Presence event type constants.
const DefaultChangesLimit = 10000
DefaultChangesLimit is the maximum number of change records returned by ReadChangesSince when no explicit limit is provided. This prevents unbounded result sets on large shadow tables.
Variables ¶
This section is empty.
Functions ¶
func DocumentRoomID ¶
DocumentRoomID returns the standard room ID for a table + primary key. Use this for per-document collaboration rooms.
func DropShadowTableDDL ¶
DropShadowTableDDL generates the DROP TABLE statement for a shadow table.
func MarshalPresenceEvent ¶
func MarshalPresenceEvent(event PresenceEvent) ([]byte, error)
MarshalEvent serializes a PresenceEvent to JSON bytes for SSE transport.
func NewHTTPHandler ¶
func NewHTTPHandler(plugin *Plugin, opts ...SyncControllerOption) http.Handler
NewHTTPHandler creates a standard http.Handler for sync endpoints. Use this when not running inside a Forge app. For Forge apps, use grove/extension.WithCRDT() which auto-registers routes.
Endpoints:
- POST /pull — remote nodes pull changes from this node
- POST /push — remote nodes push changes to this node
func RoomHTTPHandler ¶
func RoomHTTPHandler(rm *RoomManager) http.Handler
RoomHTTPHandler provides HTTP endpoints for room management. Mount under the sync server path (e.g., /sync/rooms).
func ShadowTableDDL ¶
ShadowTableDDL generates the CREATE TABLE statement for a CRDT shadow table. The DDL uses $1-style placeholders that are compatible with PostgreSQL. For SQLite, the caller should substitute the appropriate syntax.
func ShadowTableName ¶
ShadowTableName returns the shadow table name for a given table.
func ShadowTableSyncIndex ¶
ShadowTableSyncIndex generates the CREATE INDEX statement for efficient sync queries. The index covers (hlc_ts, hlc_counter) to match the ReadChangesSince query which filters on both columns.
func ValidCRDTType ¶
ValidCRDTType returns true if t is a recognized CRDT type.
Types ¶
type AttrState ¶
type AttrState struct {
Value json.RawMessage `json:"value"`
HLC HLC `json:"hlc"`
NodeID string `json:"node_id"`
}
AttrState is one formatting attribute's LWW register on a fragment. A JSON null Value clears the attribute (kept as an LWW tombstone).
type BaseCRDTPlugin ¶
type BaseCRDTPlugin struct{}
BaseCRDTPlugin provides no-op defaults for all plugin interfaces. Embed this in your plugin struct and override only what you need.
func (BaseCRDTPlugin) AfterHistoryRead ¶
func (BaseCRDTPlugin) AfterMerge ¶
func (BaseCRDTPlugin) AfterMerge(_ context.Context, _ *MergeEvent) error
func (BaseCRDTPlugin) AfterMetadataRead ¶
func (BaseCRDTPlugin) AfterMetadataRead(_ context.Context, _ *MetadataReadEvent, s *State) (*State, error)
func (BaseCRDTPlugin) AfterMetadataWrite ¶
func (BaseCRDTPlugin) AfterMetadataWrite(_ context.Context, _ *MetadataWriteEvent) error
func (BaseCRDTPlugin) AfterPresenceEvent ¶
func (BaseCRDTPlugin) AfterPresenceEvent(_ context.Context, _ *PresenceEvent) error
func (BaseCRDTPlugin) AfterRoomJoin ¶
func (BaseCRDTPlugin) AfterRoomJoin(_ context.Context, _, _ string) error
func (BaseCRDTPlugin) AfterRoomLeave ¶
func (BaseCRDTPlugin) AfterRoomLeave(_ context.Context, _, _ string) error
func (BaseCRDTPlugin) BeforeHistoryRead ¶
TimeTravelInterceptor no-ops.
func (BaseCRDTPlugin) BeforeMerge ¶
func (BaseCRDTPlugin) BeforeMerge(_ context.Context, ev *MergeEvent) (*FieldState, error)
MergeInterceptor no-ops.
func (BaseCRDTPlugin) BeforeMetadataRead ¶
func (BaseCRDTPlugin) BeforeMetadataRead(_ context.Context, _ *MetadataReadEvent) error
func (BaseCRDTPlugin) BeforeMetadataWrite ¶
func (BaseCRDTPlugin) BeforeMetadataWrite(_ context.Context, ev *MetadataWriteEvent) (*FieldState, error)
MetadataInterceptor no-ops.
func (BaseCRDTPlugin) BeforePresenceUpdate ¶
func (BaseCRDTPlugin) BeforePresenceUpdate(_ context.Context, u *PresenceUpdate) (*PresenceUpdate, error)
PresenceInterceptor no-ops.
func (BaseCRDTPlugin) BeforeRoomJoin ¶
func (BaseCRDTPlugin) BeforeRoomJoin(_ context.Context, _, _ string) error
RoomInterceptor no-ops.
func (BaseCRDTPlugin) BeforeRoomLeave ¶
func (BaseCRDTPlugin) BeforeRoomLeave(_ context.Context, _, _ string) error
func (BaseCRDTPlugin) Name ¶
func (BaseCRDTPlugin) Name() string
func (BaseCRDTPlugin) OnClientConnect ¶
func (BaseCRDTPlugin) OnClientConnect(_ context.Context, _, _ string) error
ConnectionInterceptor no-ops.
func (BaseCRDTPlugin) OnClientDisconnect ¶
func (BaseCRDTPlugin) OnClientDisconnect(_ context.Context, _, _ string) error
func (BaseCRDTPlugin) OnRoomCreated ¶
func (BaseCRDTPlugin) OnRoomCreated(_ context.Context, _ *Room) error
func (BaseCRDTPlugin) OnRoomDestroyed ¶
func (BaseCRDTPlugin) OnRoomDestroyed(_ context.Context, _ *Room) error
type BaseSyncHook ¶
type BaseSyncHook struct{}
BaseSyncHook provides no-op implementations of all SyncHook methods. Embed it in your struct and override only the methods you need:
type MyHook struct { crdt.BaseSyncHook }
func (h *MyHook) BeforeInboundChange(ctx context.Context, c *crdt.ChangeRecord) (*crdt.ChangeRecord, error) {
// your logic here
return c, nil
}
func (BaseSyncHook) AfterInboundChange ¶
func (BaseSyncHook) AfterInboundChange(_ context.Context, _ *ChangeRecord) error
AfterInboundChange does nothing.
func (BaseSyncHook) BeforeInboundChange ¶
func (BaseSyncHook) BeforeInboundChange(_ context.Context, c *ChangeRecord) (*ChangeRecord, error)
BeforeInboundChange passes the change through unchanged.
func (BaseSyncHook) BeforeOutboundChange ¶
func (BaseSyncHook) BeforeOutboundChange(_ context.Context, c *ChangeRecord) (*ChangeRecord, error)
BeforeOutboundChange passes the change through unchanged.
func (BaseSyncHook) BeforeOutboundRead ¶
func (BaseSyncHook) BeforeOutboundRead(_ context.Context, cs []ChangeRecord) ([]ChangeRecord, error)
BeforeOutboundRead passes the slice through unchanged.
type CRDTPlugin ¶
type CRDTPlugin interface {
// Name returns a unique identifier for this plugin.
Name() string
}
CRDTPlugin is the interface for extending the CRDT server with custom logic. Implement any subset of the specialized interfaces below to hook into specific parts of the CRDT lifecycle. Use BaseCRDTPlugin for no-op defaults.
Plugins are registered via SyncController.AddPlugin or WithControllerPlugin option and are called in registration order.
Example:
type AuditPlugin struct { crdt.BaseCRDTPlugin }
func (p *AuditPlugin) Name() string { return "audit" }
func (p *AuditPlugin) AfterMerge(ctx context.Context, ev *MergeEvent) error {
log.Printf("merged %s/%s field=%s winner=%s", ev.Table, ev.PK, ev.Field, ev.WinnerNodeID)
return nil
}
type CRDTType ¶
type CRDTType string //nolint:revive // CRDTType is the established public API name
CRDTType identifies the conflict resolution strategy for a field.
const ( // TypeLWW is a Last-Writer-Wins register. The value with the highest HLC wins. TypeLWW CRDTType = "lww" // TypeCounter is a PN-Counter. Each node tracks its own increments and // decrements; the global value is the sum across all nodes. TypeCounter CRDTType = "counter" // TypeSet is an Observed-Remove Set (OR-Set) with add-wins semantics. // Concurrent add and remove of the same element results in the element // being present (add wins). TypeSet CRDTType = "set" // TypeList is a Replicated Growable Array (RGA) for ordered sequences. // Elements have stable positions and can be inserted, deleted, or moved // concurrently without conflicts. TypeList CRDTType = "list" // TypeDocument is a recursive CRDT map that supports nested paths. // Each nested field is independently mergeable with its own CRDT type, // enabling JSON-like nested structures. TypeDocument CRDTType = "document" // TypeText is a character-level collaborative text sequence with // formatting attributes (origin-span fragments; see text.go). TypeText CRDTType = "text" )
type ChangeRecord ¶
type ChangeRecord struct {
Table string `json:"table"`
PK string `json:"pk"`
Field string `json:"field"`
CRDTType CRDTType `json:"crdt_type"`
HLC HLC `json:"hlc"`
NodeID string `json:"node_id"`
Value json.RawMessage `json:"value,omitempty"`
Tombstone bool `json:"tombstone,omitempty"`
// Type-specific payloads (only one is set based on CRDTType).
CounterDelta *CounterDelta `json:"counter_delta,omitempty"`
SetOp *SetOperation `json:"set_op,omitempty"`
ListOp *ListOp `json:"list_op,omitempty"`
TextOp *TextOp `json:"text_op,omitempty"`
// State optionally carries the field's full CRDT state for state-based
// propagation (sets/lists/documents sync losslessly this way). When set,
// ApplyChange merges it directly and ignores the op payloads.
State *FieldState `json:"state,omitempty"`
}
ChangeRecord represents a single field-level change for sync transport.
type Clock ¶
type Clock interface {
// Now returns a new HLC value that is causally after all previously
// observed values.
Now() HLC
// Update merges a received remote HLC into the local clock state,
// ensuring the next Now() is causally after both the local and remote values.
Update(remote HLC)
}
Clock is the interface for generating HLC values.
type ClockOption ¶
type ClockOption func(*HybridClock)
ClockOption configures a HybridClock.
func WithMaxDrift ¶
func WithMaxDrift(d time.Duration) ClockOption
WithMaxDrift sets the maximum tolerable clock drift. Update() will return values clamped to this drift from the physical clock.
func WithNowFunc ¶
func WithNowFunc(fn func() time.Time) ClockOption
WithNowFunc overrides the wall clock source (useful for testing).
type ConnectionInterceptor ¶
type ConnectionInterceptor interface {
// OnClientConnect is called when a client connects via SSE or WebSocket.
OnClientConnect(ctx context.Context, nodeID string, transport string) error
// OnClientDisconnect is called when a client disconnects.
OnClientDisconnect(ctx context.Context, nodeID string, transport string) error
}
ConnectionInterceptor intercepts client connections (WebSocket, SSE).
type CounterDelta ¶
CounterDelta represents a single node's counter change.
type CursorPosition ¶
type CursorPosition struct {
// X/Y for canvas-style cursors.
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
// Offset for text-style cursors (character position).
Offset int `json:"offset,omitempty"`
// Line/Column for code-style cursors.
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
// SelectionStart/End for text selections.
SelectionStart int `json:"selection_start,omitempty"`
SelectionEnd int `json:"selection_end,omitempty"`
// Field identifies which field/element the cursor is in.
Field string `json:"field,omitempty"`
}
CursorPosition represents a cursor or selection in a document.
type DocumentCRDTState ¶
type DocumentCRDTState struct {
// Fields maps dot-separated paths to their field-level CRDT state.
// Paths use "." as the separator for nesting.
// Example: "address.city" → LWW, "tags" → Set, "views" → Counter.
Fields map[string]*FieldState `json:"fields"`
}
DocumentCRDTState holds nested document CRDT state. Each path (e.g., "address.city", "tags", "meta.count") is independently mergeable with its own CRDT type, enabling JSON-like nested structures where different subtrees can use different conflict resolution strategies.
func DocumentFromFieldState ¶
func DocumentFromFieldState(fs *FieldState) *DocumentCRDTState
DocumentFromFieldState reconstructs a DocumentCRDTState from a FieldState.
func MergeDocument ¶
func MergeDocument(local, remote *DocumentCRDTState) (*DocumentCRDTState, error)
MergeDocument merges two document CRDT states by merging each path independently using the MergeEngine. This is commutative, associative, and idempotent.
func NewDocumentCRDTState ¶
func NewDocumentCRDTState() *DocumentCRDTState
NewDocumentCRDTState creates an empty document CRDT state.
func (*DocumentCRDTState) DeleteField ¶
func (d *DocumentCRDTState) DeleteField(path string)
DeleteField removes a field at the given path and all its children.
func (*DocumentCRDTState) GetField ¶
func (d *DocumentCRDTState) GetField(path string) *FieldState
GetField returns the resolved value at the given path. Returns nil if the path doesn't exist.
func (*DocumentCRDTState) Paths ¶
func (d *DocumentCRDTState) Paths() []string
Paths returns all field paths sorted alphabetically.
func (*DocumentCRDTState) Resolve ¶
func (d *DocumentCRDTState) Resolve() map[string]any
Resolve converts the document state to a nested map structure. This is the "materialized view" of the document.
func (*DocumentCRDTState) SetFieldState ¶
func (d *DocumentCRDTState) SetFieldState(path string, fs *FieldState)
SetFieldState sets a field at the given path with an explicit FieldState. Use this for non-LWW types (counters, sets, lists).
func (*DocumentCRDTState) ToFieldState ¶
func (d *DocumentCRDTState) ToFieldState(clock HLC, nodeID string) *FieldState
ToFieldState converts to the generic FieldState representation.
type ExecResult ¶
ExecResult is the result of an exec operation.
type Executor ¶
type Executor interface {
ExecContext(ctx context.Context, query string, args ...any) (ExecResult, error)
QueryContext(ctx context.Context, query string, args ...any) (Rows, error)
}
Executor is the minimal query interface needed by MetadataStore. Both grove.DB (via driver) and grove.Tx satisfy this via adapter.
type FieldHistoryEntry ¶
type FieldHistoryEntry struct {
HLC HLC `json:"hlc"`
NodeID string `json:"node_id"`
Value json.RawMessage `json:"value,omitempty"`
Type CRDTType `json:"type"`
}
FieldHistoryEntry is a single version of a field's state.
type FieldHistoryRequest ¶
type FieldHistoryRequest struct {
Table string `json:"table"`
PK string `json:"pk"`
Field string `json:"field"`
SinceHLC HLC `json:"since_hlc,omitempty"`
Limit int `json:"limit,omitempty"`
}
FieldHistoryRequest asks for the change history of a specific field.
type FieldHistoryResponse ¶
type FieldHistoryResponse struct {
Table string `json:"table"`
PK string `json:"pk"`
Field string `json:"field"`
Entries []FieldHistoryEntry `json:"entries"`
}
FieldHistoryResponse contains the change history for a field.
type FieldState ¶
type FieldState struct {
// Type is the CRDT type of this field.
Type CRDTType `json:"type"`
// HLC is the clock value of the last write (used by LWW).
HLC HLC `json:"hlc"`
// NodeID is the node that produced the last write (used by LWW).
NodeID string `json:"node_id"`
// Value is the current resolved value (LWW) or nil for Counter/Set.
Value json.RawMessage `json:"value,omitempty"`
// CounterState holds per-node increments/decrements (Counter only).
CounterState *PNCounterState `json:"counter_state,omitempty"`
// SetState holds the OR-Set state (Set only).
SetState *ORSetState `json:"set_state,omitempty"`
// ListState holds the RGA list state (List only).
ListState *RGAListState `json:"list_state,omitempty"`
// DocState holds nested document CRDT state (Document only).
DocState *DocumentCRDTState `json:"doc_state,omitempty"`
// TextState holds collaborative text state (Text only).
TextState *TextState `json:"text_state,omitempty"`
}
FieldState holds the CRDT state for a single field.
func ApplyChange ¶
func ApplyChange(engine *MergeEngine, local *FieldState, c *ChangeRecord) (*FieldState, error)
ApplyChange folds one ChangeRecord into a field's local state and returns the merged result. It is the canonical op-application seam: transports, stores and downstream systems (e.g. fabriq's document plane) fold update logs through it so every replica applies an op identically.
Application is type-aware:
- a change carrying State (a full FieldState) merges state-based;
- counter changes fold CounterDelta as that node's delta snapshot;
- set changes fold SetOp (adds tag entries; removes mark observed tags — exact when the op names Tags, otherwise every local tag older than the op's HLC, the legacy observed-remove approximation);
- list changes fold ListOp (insert/delete/move); deletes for unseen node ids are kept as tombstones so late-arriving inserts stay deleted;
- text changes fold TextOp (insert/delete/format);
- lww and document changes fold Value (document values are {path, value} path writes, or path deletes when the record is tombstoned).
local may be nil (first op for the field). The change is never mutated.
type ForgeWSConn ¶
type ForgeWSConn struct {
// contains filtered or unexported fields
}
ForgeWSConn adapts a Forge Connection (from forge.WebSocketHandler) to the WebSocketConn interface used by CRDT transport. This allows the CRDT WebSocket handler to work natively with Forge's built-in WebSocket upgrade machinery.
Usage inside a Forge WebSocket handler:
sync.WebSocket("/ws", func(ctx forge.Context, conn forge.Connection) error {
adapter := crdt.NewForgeWSConn(conn)
handler := crdt.NewWebSocketHandler(ctrl, adapter, logger)
return handler.Serve(conn.Context())
})
func NewForgeWSConn ¶
func NewForgeWSConn(conn forgeConnection) *ForgeWSConn
NewForgeWSConn creates a WebSocketConn adapter from a forge.Connection.
func (*ForgeWSConn) Close ¶
func (f *ForgeWSConn) Close() error
Close implements WebSocketConn using forge.Connection.Close().
func (*ForgeWSConn) ReadMessage ¶
func (f *ForgeWSConn) ReadMessage() ([]byte, error)
ReadMessage implements WebSocketConn using forge.Connection.Read().
func (*ForgeWSConn) WriteMessage ¶
func (f *ForgeWSConn) WriteMessage(data []byte) error
WriteMessage implements WebSocketConn using forge.Connection.Write().
type HLC ¶
type HLC struct {
// Timestamp is the physical time in nanoseconds since Unix epoch.
Timestamp int64 `json:"ts"`
// Counter is the logical counter, incremented when the physical clock
// hasn't advanced since the last event.
Counter uint32 `json:"c"`
// NodeID identifies the node that produced this clock value.
NodeID string `json:"node"`
}
HLC is a Hybrid Logical Clock value. It combines a physical timestamp (wall clock) with a logical counter to provide a totally ordered, causally consistent clock that requires no coordination between nodes.
func (HLC) Compare ¶
Compare returns -1 if h < other, 0 if equal, 1 if h > other. Ordering: Timestamp first, then Counter, then NodeID (lexicographic tiebreak).
func (HLC) MarshalJSON ¶
MarshalJSON implements json.Marshaler: ts as an exact decimal string.
func (*HLC) UnmarshalJSON ¶
UnmarshalJSON implements json.Unmarshaler: ts as string or legacy number.
type HTTPClient ¶
type HTTPClient struct {
// contains filtered or unexported fields
}
HTTPClient is a Transport implementation that communicates via HTTP.
func HTTPTransport ¶
func HTTPTransport(baseURL string) *HTTPClient
HTTPTransport creates a new HTTP transport client pointing at the given base URL. The sync server should be mounted at that URL (see NewHTTPHandler).
transport := crdt.HTTPTransport("https://cloud.example.com/sync")
func HTTPTransportWithClient ¶
func HTTPTransportWithClient(baseURL string, client *http.Client) *HTTPClient
HTTPTransportWithClient creates an HTTP transport with a custom http.Client.
func (*HTTPClient) Pull ¶
func (c *HTTPClient) Pull(ctx context.Context, req *PullRequest) (*PullResponse, error)
Pull requests changes from the remote node.
func (*HTTPClient) Push ¶
func (c *HTTPClient) Push(ctx context.Context, req *PushRequest) (*PushResponse, error)
Push sends local changes to the remote node.
type HistoryRequest ¶
type HistoryRequest struct {
Table string `json:"table"`
PK string `json:"pk"`
// AtHLC is the point in time to query. If zero, returns current state.
AtHLC HLC `json:"at_hlc,omitempty"`
}
HistoryRequest asks for the state of a record at a specific point in time.
type HistoryResponse ¶
type HistoryResponse struct {
Table string `json:"table"`
PK string `json:"pk"`
AtHLC HLC `json:"at_hlc"`
State *State `json:"state"`
}
HistoryResponse contains the historical state of a record.
type HybridClock ¶
type HybridClock struct {
// contains filtered or unexported fields
}
HybridClock is the default Clock implementation using a Hybrid Logical Clock.
func NewHybridClock ¶
func NewHybridClock(nodeID string, opts ...ClockOption) *HybridClock
NewHybridClock creates a new HLC for the given node.
func (*HybridClock) Now ¶
func (c *HybridClock) Now() HLC
Now produces a new HLC that is causally after the last observed value.
func (*HybridClock) Update ¶
func (c *HybridClock) Update(remote HLC)
Update merges a remote HLC into the local state. The next call to Now() will return a value that is causally after both local and remote.
type InspectField ¶
type InspectField struct {
Type CRDTType `json:"type"`
NodeID string `json:"node_id"`
HLC HLC `json:"hlc"`
// LWW fields
Value any `json:"value,omitempty"`
// Counter fields
CounterValue int64 `json:"counter_value,omitempty"`
NodeCounters map[string][2]int64 `json:"node_counters,omitempty"` // nodeID → [inc, dec]
// Set fields
Elements []any `json:"elements,omitempty"`
// List fields
ListLength int `json:"list_length,omitempty"`
// Document fields
DocPaths []string `json:"doc_paths,omitempty"`
}
InspectField is a human-readable representation of a single field's state.
type InspectResult ¶
type InspectResult struct {
Table string `json:"table"`
PK string `json:"pk"`
Tombstone bool `json:"tombstone"`
Fields map[string]*InspectField `json:"fields"`
}
InspectResult is a human-readable representation of a record's CRDT state. Useful for debugging and testing.
func InspectState ¶
func InspectState(state *State) *InspectResult
InspectState converts a State into a human-readable InspectResult.
func (*InspectResult) String ¶
func (r *InspectResult) String() string
String returns a human-readable summary of the inspect result.
type LWWRegister ¶
type LWWRegister struct {
Value json.RawMessage `json:"value"`
Clock HLC `json:"hlc"`
NodeID string `json:"node_id"`
}
LWWRegister is a Last-Writer-Wins register. The value with the highest HLC wins. Ties are broken deterministically by node ID.
func LWWFromFieldState ¶
func LWWFromFieldState(fs *FieldState) *LWWRegister
LWWFromFieldState reconstructs an LWWRegister from a FieldState.
func MergeLWW ¶
func MergeLWW(local, remote *LWWRegister) *LWWRegister
MergeLWW merges two LWW registers, returning the winning value. The register with the higher HLC wins. If HLCs are equal, the higher node ID wins (deterministic tiebreak).
func NewLWWRegister ¶
func NewLWWRegister(value any, clock HLC, nodeID string) (*LWWRegister, error)
NewLWWRegister creates a new LWW register with the given value and clock.
func (*LWWRegister) Decode ¶
func (r *LWWRegister) Decode(dest any) error
Decode unmarshals the register's value into dest.
func (*LWWRegister) ToFieldState ¶
func (r *LWWRegister) ToFieldState() *FieldState
ToFieldState converts to the generic FieldState representation.
type ListOp ¶
type ListOp struct {
Op ListOpType `json:"op"`
NodeID HLC `json:"node_id,omitempty"`
ParentID HLC `json:"parent_id,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
}
ListOp represents a list operation for the sync transport.
type ListOpType ¶
type ListOpType string
ListOpType identifies the list operation.
const ( ListOpInsert ListOpType = "insert" ListOpDelete ListOpType = "delete" ListOpMove ListOpType = "move" )
type MergeEngine ¶
type MergeEngine struct{}
MergeEngine resolves concurrent writes by dispatching to the appropriate CRDT merge function based on field type.
func (*MergeEngine) MergeField ¶
func (m *MergeEngine) MergeField(local, remote *FieldState) (*FieldState, error)
MergeField merges a single field's state from two sources. Both states must have the same CRDT type.
func (*MergeEngine) MergeState ¶
func (m *MergeEngine) MergeState(local, remote *State) (*State, error)
MergeState merges two full record states. Fields present in only one state are kept as-is. Fields present in both are merged using MergeField. Tombstones are resolved by taking the one with the higher HLC.
type MergeEvent ¶
type MergeEvent struct {
Table string `json:"table"`
PK string `json:"pk"`
Field string `json:"field"`
Local *FieldState `json:"local"`
Remote *FieldState `json:"remote"`
Result *FieldState `json:"result"` // Set after merge (in AfterMerge).
// WinnerNodeID is set in AfterMerge — the node whose value won.
WinnerNodeID string `json:"winner_node_id,omitempty"`
// ConflictDetected is true when both local and remote had changes.
ConflictDetected bool `json:"conflict_detected"`
}
MergeEvent provides context about a merge operation.
type MergeInterceptor ¶
type MergeInterceptor interface {
// BeforeMerge is called before two field states are merged.
// Return a modified remote state, nil to skip the merge, or an error to abort.
BeforeMerge(ctx context.Context, ev *MergeEvent) (*FieldState, error)
// AfterMerge is called after a merge completes with the winning state.
// Use for audit logging, analytics, or triggering side effects.
AfterMerge(ctx context.Context, ev *MergeEvent) error
}
MergeInterceptor intercepts merge operations, allowing custom merge logic, validation, or auditing of conflict resolution decisions.
type MetadataInterceptor ¶
type MetadataInterceptor interface {
// BeforeMetadataWrite is called before writing field state to the shadow table.
// Return a modified state, nil to skip the write, or an error to abort.
BeforeMetadataWrite(ctx context.Context, ev *MetadataWriteEvent) (*FieldState, error)
// AfterMetadataWrite is called after a successful shadow table write.
AfterMetadataWrite(ctx context.Context, ev *MetadataWriteEvent) error
// BeforeMetadataRead is called before reading state from the shadow table.
// Return an error to deny the read.
BeforeMetadataRead(ctx context.Context, ev *MetadataReadEvent) error
// AfterMetadataRead is called after reading state, allowing transformation.
// Return a modified state or the original.
AfterMetadataRead(ctx context.Context, ev *MetadataReadEvent, state *State) (*State, error)
}
MetadataInterceptor intercepts reads and writes to the shadow table.
type MetadataReadEvent ¶
type MetadataReadEvent struct {
Table string `json:"table"`
PK string `json:"pk"`
// AtHLC is set for time-travel reads (zero for current state).
AtHLC HLC `json:"at_hlc,omitempty"`
}
MetadataReadEvent describes a pending shadow table read.
type MetadataRow ¶
type MetadataRow struct {
PKHash string `json:"pk_hash"`
FieldName string `json:"field_name"`
HLCTS int64 `json:"hlc_ts"`
HLCCount uint32 `json:"hlc_counter"`
NodeID string `json:"node_id"`
Tombstone bool `json:"tombstone"`
CRDTState json.RawMessage `json:"crdt_state"`
}
MetadataRow is a single row in the shadow table.
type MetadataStore ¶
type MetadataStore struct {
// contains filtered or unexported fields
}
MetadataStore reads and writes CRDT metadata in shadow tables. It operates via a generic Executor interface so it works with any Grove driver (pg, sqlite, turso, etc.).
func NewMetadataStore ¶
func NewMetadataStore(exec Executor) *MetadataStore
NewMetadataStore creates a new MetadataStore with the given executor.
func (*MetadataStore) CleanTombstones ¶
CleanTombstones removes tombstones older than the given HLC.
func (*MetadataStore) ReadChangesSince ¶
func (ms *MetadataStore) ReadChangesSince(ctx context.Context, table string, since HLC, limits ...int) ([]ChangeRecord, error)
ReadChangesSince reads change records from the shadow table that happened after the given HLC timestamp. Used by the sync protocol. An optional limit can be provided (first value used); 0 means use DefaultChangesLimit.
func (*MetadataStore) ReadFieldHistory ¶
func (ms *MetadataStore) ReadFieldHistory(ctx context.Context, table, pk, field string, since HLC, limit int) ([]FieldHistoryEntry, error)
ReadFieldHistory reads the change history for a specific field.
func (*MetadataStore) ReadState ¶
ReadState reads the full CRDT state for a record from the shadow table.
func (*MetadataStore) ReadStateAt ¶
ReadStateAt reads the CRDT state for a record as it existed at a specific HLC timestamp. This queries the shadow table for all field states with hlc_ts <= the target time.
func (*MetadataStore) WriteFieldState ¶
func (ms *MetadataStore) WriteFieldState(ctx context.Context, table, pk, field string, fs *FieldState) error
WriteFieldState writes a single field's CRDT state to the shadow table.
func (*MetadataStore) WriteFieldStatesAtomic ¶
func (ms *MetadataStore) WriteFieldStatesAtomic(ctx context.Context, table, pk string, fields map[string]*FieldState) error
WriteFieldStatesAtomic writes multiple field states in a single transaction when the executor supports it. Falls back to individual writes otherwise.
func (*MetadataStore) WriteTombstone ¶
func (ms *MetadataStore) WriteTombstone(ctx context.Context, table, pk string, clock HLC, nodeID string) error
WriteTombstone marks a record as deleted in the shadow table.
type MetadataWriteEvent ¶
type MetadataWriteEvent struct {
Table string `json:"table"`
PK string `json:"pk"`
Field string `json:"field"`
State *FieldState `json:"state"`
NodeID string `json:"node_id"`
}
MetadataWriteEvent describes a pending shadow table write.
type Metrics ¶
type Metrics struct {
// Sync metrics.
PullCount atomic.Int64
PushCount atomic.Int64
PullLatencyNs atomic.Int64 // last pull latency in nanoseconds
PushLatencyNs atomic.Int64 // last push latency in nanoseconds
PullErrors atomic.Int64
PushErrors atomic.Int64
ChangesPulled atomic.Int64
ChangesPushed atomic.Int64
ChangesMerged atomic.Int64
// Conflict metrics.
ConflictsTotal atomic.Int64
ConflictsLWW atomic.Int64
ConflictsSet atomic.Int64
ConflictsList atomic.Int64
// Presence metrics.
ActiveRooms atomic.Int64
ActiveParticipants atomic.Int64
PresenceUpdates atomic.Int64
// Connection metrics.
SSEConnections atomic.Int64
WSConnections atomic.Int64
// Validation metrics.
ValidationErrors atomic.Int64
}
Metrics collects CRDT operational metrics. It uses atomic counters for lock-free concurrent access. Register a MetricsCollector to receive periodic snapshots, or read counters directly.
func (*Metrics) RecordConflict ¶
RecordConflict increments the conflict counter for the given CRDT type.
func (*Metrics) Snapshot ¶
func (m *Metrics) Snapshot() MetricsSnapshot
Snapshot returns a point-in-time copy of all metrics.
type MetricsSnapshot ¶
type MetricsSnapshot struct {
Timestamp time.Time `json:"timestamp"`
PullCount int64 `json:"pull_count"`
PushCount int64 `json:"push_count"`
PullLatencyMs int64 `json:"pull_latency_ms"`
PushLatencyMs int64 `json:"push_latency_ms"`
PullErrors int64 `json:"pull_errors"`
PushErrors int64 `json:"push_errors"`
ChangesPulled int64 `json:"changes_pulled"`
ChangesPushed int64 `json:"changes_pushed"`
ChangesMerged int64 `json:"changes_merged"`
ConflictsTotal int64 `json:"conflicts_total"`
ConflictsLWW int64 `json:"conflicts_lww"`
ConflictsSet int64 `json:"conflicts_set"`
ConflictsList int64 `json:"conflicts_list"`
ActiveRooms int64 `json:"active_rooms"`
ActiveParticipants int64 `json:"active_participants"`
PresenceUpdates int64 `json:"presence_updates"`
SSEConnections int64 `json:"sse_connections"`
WSConnections int64 `json:"ws_connections"`
ValidationErrors int64 `json:"validation_errors"`
}
MetricsSnapshot is a point-in-time copy of all metrics.
type ORSetState ¶
type ORSetState struct {
// Entries maps element (JSON-encoded) → set of tags that added it.
// An element is in the set if it has at least one tag not in Removed.
Entries map[string][]Tag `json:"entries"`
// Removed tracks tags that have been observed and removed.
Removed map[string]bool `json:"removed"`
}
ORSetState holds the state for an Observed-Remove Set with add-wins semantics. Each element is tracked with a unique tag (nodeID + HLC) so that concurrent add and remove of the same element resolves to the element being present (the add wins).
func MergeSet ¶
func MergeSet(local, remote *ORSetState) *ORSetState
MergeSet merges two OR-Set states. The result is the union of all entries with the union of all removed tags. This is commutative, associative, and idempotent.
func SetFromFieldState ¶
func SetFromFieldState(fs *FieldState) *ORSetState
SetFromFieldState reconstructs an ORSetState from a FieldState.
func (*ORSetState) Add ¶
func (s *ORSetState) Add(element any, nodeID string, clock HLC) error
Add inserts an element into the set with the given tag.
func (*ORSetState) Compact ¶
func (s *ORSetState) Compact(before HLC) int
Compact drops observed-removed tags older than the horizon along with their removal markers, pruning entries left tagless. Returns the number of tags dropped.
func (*ORSetState) Contains ¶
func (s *ORSetState) Contains(element any) (bool, error)
Contains returns true if the element is in the effective set.
func (*ORSetState) Elements ¶
func (s *ORSetState) Elements() []json.RawMessage
Elements returns the effective set of elements (those with at least one non-removed tag).
func (*ORSetState) Remove ¶
func (s *ORSetState) Remove(element any) error
Remove removes an element by marking all its current tags as removed.
func (*ORSetState) ToFieldState ¶
func (s *ORSetState) ToFieldState(clock HLC, nodeID string) *FieldState
ToFieldState converts to the generic FieldState representation.
type Option ¶
type Option func(*Plugin)
Option configures the CRDT plugin.
func WithClock ¶
WithClock sets the clock implementation. Defaults to a HybridClock using the node ID.
func WithMaxClockDrift ¶
WithMaxClockDrift sets the maximum tolerable clock drift between nodes. Remote HLC values that exceed this drift from the local clock are clamped. Defaults to 5 seconds.
func WithNodeID ¶
WithNodeID sets the unique identifier for this node. Required.
func WithSyncHook ¶
WithSyncHook adds a sync hook to the plugin. Sync hooks intercept changes during sync operations for validation, transformation, filtering, or auditing. Multiple hooks are called in registration order.
func WithTables ¶
WithTables restricts the CRDT plugin to the specified tables. If not set, the plugin operates on all tables that have crdt: tags.
func WithTombstoneTTL ¶
WithTombstoneTTL sets how long tombstones are retained before being eligible for garbage collection. Defaults to 7 days.
type PNCounterState ¶
type PNCounterState struct {
// Increments maps nodeID → total increments from that node.
Increments map[string]int64 `json:"inc"`
// Decrements maps nodeID → total decrements from that node.
Decrements map[string]int64 `json:"dec"`
}
PNCounterState holds the per-node increment and decrement maps for a PN-Counter (Positive-Negative Counter). The global value is:
sum(all increments) - sum(all decrements)
Each node only ever writes to its own entry; merging takes the max of each node's counters.
func CounterFromFieldState ¶
func CounterFromFieldState(fs *FieldState) *PNCounterState
CounterFromFieldState reconstructs a PNCounterState from a FieldState.
func MergeCounter ¶
func MergeCounter(local, remote *PNCounterState) *PNCounterState
MergeCounter merges two PN-Counter states by taking the max of each node's increments and decrements. This is commutative, associative, and idempotent.
func NewPNCounterState ¶
func NewPNCounterState() *PNCounterState
NewPNCounterState creates an empty PN-Counter state.
func (*PNCounterState) Decrement ¶
func (c *PNCounterState) Decrement(nodeID string, delta int64)
Decrement adds delta to the given node's decrement counter.
func (*PNCounterState) Increment ¶
func (c *PNCounterState) Increment(nodeID string, delta int64)
Increment adds delta to the given node's increment counter.
func (*PNCounterState) ToFieldState ¶
func (c *PNCounterState) ToFieldState(clock HLC, nodeID string) *FieldState
ToFieldState converts to the generic FieldState representation.
func (*PNCounterState) Value ¶
func (c *PNCounterState) Value() int64
Value returns the current counter value (sum of increments minus sum of decrements).
type ParticipantData ¶
type ParticipantData struct {
// Name is the display name of the participant.
Name string `json:"name,omitempty"`
// Color is the assigned collaboration color (hex).
Color string `json:"color,omitempty"`
// Avatar is a URL to the participant's avatar image.
Avatar string `json:"avatar,omitempty"`
// Cursor is the participant's current cursor position.
Cursor *CursorPosition `json:"cursor,omitempty"`
// IsTyping indicates whether the participant is currently typing.
IsTyping bool `json:"is_typing,omitempty"`
// ActiveField is the field currently being edited (for form-style UIs).
ActiveField string `json:"active_field,omitempty"`
// Status is a custom status string (e.g., "idle", "editing", "viewing").
Status string `json:"status,omitempty"`
// Extra holds arbitrary user-defined data.
Extra map[string]any `json:"extra,omitempty"`
}
ParticipantData is the standard presence payload for room participants. Consumers can extend this with custom fields via the Extra map.
type Plugin ¶
type Plugin struct {
// contains filtered or unexported fields
}
Plugin is the CRDT plugin for Grove. It implements the grove plugin.Plugin interface along with WithHooks and WithMigrations capabilities.
Create a plugin with New() and register it with the hook engine:
p := crdt.New(crdt.WithNodeID("node-1"))
db.Hooks().AddHook(p, hook.Scope{Tables: []string{"documents"}})
func (*Plugin) AfterMutation ¶
AfterMutation is called after every INSERT, UPDATE, or DELETE on a CRDT-enabled table. It writes CRDT metadata to the shadow table.
func (*Plugin) BeforeQuery ¶
func (p *Plugin) BeforeQuery(_ context.Context, _ *hook.QueryContext) (*hook.HookResult, error)
BeforeQuery is a no-op by default. In merge-on-read mode it could inject filters, but the default merge-on-write approach doesn't need it.
func (*Plugin) CleanupTombstones ¶
CleanupTombstones removes tombstones older than the configured TTL.
func (*Plugin) EnsureShadowTable ¶
EnsureShadowTable creates the shadow table for the given table if it doesn't exist. This should be called during migration or initialization.
func (*Plugin) MergeEngine ¶
func (p *Plugin) MergeEngine() *MergeEngine
MergeEngine returns the plugin's merge engine.
func (*Plugin) MetadataStore ¶
func (p *Plugin) MetadataStore() *MetadataStore
MetadataStore returns the metadata store (nil until SetExecutor is called).
func (*Plugin) RunTombstoneCleanup ¶
RunTombstoneCleanup starts a background loop that periodically removes tombstones older than the configured TTL for the given tables. The loop runs until the context is cancelled.
func (*Plugin) SetExecutor ¶
SetExecutor sets the database executor for metadata operations. This is called by the hooks when they have access to the database.
func (*Plugin) SyncHooks ¶
func (p *Plugin) SyncHooks() *SyncHookChain
SyncHooks returns the plugin's sync hook chain.
type PluginChain ¶
type PluginChain struct {
// contains filtered or unexported fields
}
PluginChain manages registered CRDT plugins and dispatches events.
func (*PluginChain) DispatchAfterHistoryRead ¶
func (pc *PluginChain) DispatchAfterHistoryRead(ctx context.Context, table, pk string, state *State) (*State, error)
DispatchAfterHistoryRead calls AfterHistoryRead on all TimeTravelInterceptor plugins.
func (*PluginChain) DispatchAfterMerge ¶
func (pc *PluginChain) DispatchAfterMerge(ctx context.Context, ev *MergeEvent)
DispatchAfterMerge calls AfterMerge on all MergeInterceptor plugins.
func (*PluginChain) DispatchAfterMetadataRead ¶
func (pc *PluginChain) DispatchAfterMetadataRead(ctx context.Context, ev *MetadataReadEvent, state *State) (*State, error)
DispatchAfterMetadataRead calls AfterMetadataRead on all MetadataInterceptor plugins.
func (*PluginChain) DispatchAfterMetadataWrite ¶
func (pc *PluginChain) DispatchAfterMetadataWrite(ctx context.Context, ev *MetadataWriteEvent)
DispatchAfterMetadataWrite calls AfterMetadataWrite on all MetadataInterceptor plugins.
func (*PluginChain) DispatchAfterPresenceEvent ¶
func (pc *PluginChain) DispatchAfterPresenceEvent(ctx context.Context, event *PresenceEvent)
DispatchAfterPresenceEvent calls AfterPresenceEvent on all PresenceInterceptor plugins.
func (*PluginChain) DispatchAfterRoomJoin ¶
func (pc *PluginChain) DispatchAfterRoomJoin(ctx context.Context, roomID, nodeID string)
DispatchAfterRoomJoin calls AfterRoomJoin on all RoomInterceptor plugins.
func (*PluginChain) DispatchBeforeHistoryRead ¶
func (pc *PluginChain) DispatchBeforeHistoryRead(ctx context.Context, table, pk string, atHLC HLC) error
DispatchBeforeHistoryRead calls BeforeHistoryRead on all TimeTravelInterceptor plugins.
func (*PluginChain) DispatchBeforeMerge ¶
func (pc *PluginChain) DispatchBeforeMerge(ctx context.Context, ev *MergeEvent) (*FieldState, error)
DispatchBeforeMerge calls BeforeMerge on all MergeInterceptor plugins.
func (*PluginChain) DispatchBeforeMetadataRead ¶
func (pc *PluginChain) DispatchBeforeMetadataRead(ctx context.Context, ev *MetadataReadEvent) error
DispatchBeforeMetadataRead calls BeforeMetadataRead on all MetadataInterceptor plugins.
func (*PluginChain) DispatchBeforeMetadataWrite ¶
func (pc *PluginChain) DispatchBeforeMetadataWrite(ctx context.Context, ev *MetadataWriteEvent) (*FieldState, error)
DispatchBeforeMetadataWrite calls BeforeMetadataWrite on all MetadataInterceptor plugins.
func (*PluginChain) DispatchBeforePresenceUpdate ¶
func (pc *PluginChain) DispatchBeforePresenceUpdate(ctx context.Context, update *PresenceUpdate) (*PresenceUpdate, error)
DispatchBeforePresenceUpdate calls BeforePresenceUpdate on all PresenceInterceptor plugins.
func (*PluginChain) DispatchBeforeRoomJoin ¶
func (pc *PluginChain) DispatchBeforeRoomJoin(ctx context.Context, roomID, nodeID string) error
DispatchBeforeRoomJoin calls BeforeRoomJoin on all RoomInterceptor plugins.
func (*PluginChain) DispatchOnClientConnect ¶
func (pc *PluginChain) DispatchOnClientConnect(ctx context.Context, nodeID, transport string) error
DispatchOnClientConnect calls OnClientConnect on all ConnectionInterceptor plugins.
func (*PluginChain) DispatchOnClientDisconnect ¶
func (pc *PluginChain) DispatchOnClientDisconnect(ctx context.Context, nodeID, transport string)
DispatchOnClientDisconnect calls OnClientDisconnect on all ConnectionInterceptor plugins.
func (*PluginChain) Len ¶
func (pc *PluginChain) Len() int
Len returns the number of registered plugins.
func (*PluginChain) Plugins ¶
func (pc *PluginChain) Plugins() []CRDTPlugin
Plugins returns all registered plugins.
type PresenceEvent ¶
type PresenceEvent struct {
// Type is "join", "update", or "leave".
Type string `json:"type"`
// NodeID identifies the client whose presence changed.
NodeID string `json:"node_id"`
// Topic is the presence scope.
Topic string `json:"topic"`
// Data is the user-defined presence payload (empty for "leave" events).
Data json.RawMessage `json:"data,omitempty"`
}
PresenceEvent is broadcast over SSE to notify clients of presence changes.
type PresenceInterceptor ¶
type PresenceInterceptor interface {
// BeforePresenceUpdate is called before updating presence.
// Return a modified update, nil to reject, or an error to abort.
BeforePresenceUpdate(ctx context.Context, update *PresenceUpdate) (*PresenceUpdate, error)
// AfterPresenceEvent is called after a presence event is emitted.
AfterPresenceEvent(ctx context.Context, event *PresenceEvent) error
}
PresenceInterceptor intercepts presence events for custom logic.
type PresenceManager ¶
type PresenceManager struct {
// contains filtered or unexported fields
}
PresenceManager manages ephemeral presence state for connected clients. State is kept entirely in-memory (never persisted to the database) and cleaned up automatically via TTL expiry. It is safe for concurrent use.
func NewPresenceManager ¶
func NewPresenceManager(ttl time.Duration, onChange func(PresenceEvent), logger log.Logger) *PresenceManager
NewPresenceManager creates a presence manager with the given TTL and change callback. The callback is invoked on join, update, and leave events (including TTL-based expiry). It starts a background goroutine for TTL cleanup; call Close() to stop it.
func (*PresenceManager) Close ¶
func (pm *PresenceManager) Close()
Close stops the background cleanup goroutine.
func (*PresenceManager) Get ¶
func (pm *PresenceManager) Get(topic string) []PresenceState
Get returns all active presence states for a topic.
func (*PresenceManager) GetTopicsForNode ¶
func (pm *PresenceManager) GetTopicsForNode(nodeID string) []string
GetTopicsForNode returns all topics that a node has presence in.
func (*PresenceManager) Remove ¶
func (pm *PresenceManager) Remove(topic, nodeID string) *PresenceEvent
Remove explicitly removes a node's presence from a topic. Returns a "leave" event. No-op if the entry doesn't exist.
func (*PresenceManager) RemoveNode ¶
func (pm *PresenceManager) RemoveNode(nodeID string) []PresenceEvent
RemoveNode removes all presence entries for the given node (e.g., on SSE disconnect). Broadcasts a "leave" event for each removed entry.
func (*PresenceManager) Update ¶
func (pm *PresenceManager) Update(update PresenceUpdate) PresenceEvent
Update upserts a presence entry for the given node and topic. Returns the resulting event ("join" for new entries, "update" for existing).
type PresenceSnapshot ¶
type PresenceSnapshot struct {
// Topic is the presence scope.
Topic string `json:"topic"`
// States is the list of active presence entries for the topic.
States []PresenceState `json:"states"`
}
PresenceSnapshot is the response for GET /sync/presence?topic=...
type PresenceState ¶
type PresenceState struct {
// NodeID identifies the client that owns this presence entry.
NodeID string `json:"node_id"`
// Topic is the presence scope (e.g. "documents:doc-1" or "lobby").
Topic string `json:"topic"`
// Data is the user-defined presence payload (cursor position, typing state, etc.).
Data json.RawMessage `json:"data"`
// UpdatedAt is the time this entry was last updated.
UpdatedAt time.Time `json:"updated_at"`
// ExpiresAt is the time this entry expires if not refreshed.
ExpiresAt time.Time `json:"expires_at"`
}
PresenceState holds a single client's presence data for a topic. Topics are typically "table:pk" for per-document presence, but can be any arbitrary string for rooms or channels.
type PresenceUpdate ¶
type PresenceUpdate struct {
// NodeID identifies the client sending the update.
NodeID string `json:"node_id"`
// Topic is the presence scope.
Topic string `json:"topic"`
// Data is the user-defined presence payload. Set to null to leave.
Data json.RawMessage `json:"data"`
}
PresenceUpdate is the request body for POST /sync/presence.
type PullRequest ¶
type PullRequest struct {
// Tables to pull changes for.
Tables []string `json:"tables"`
// Since is the HLC after which to return changes.
Since HLC `json:"since"`
// NodeID of the requesting node.
NodeID string `json:"node_id"`
// Filter enables selective sync (partial replication).
// When set, only changes matching the filter are returned.
Filter *SyncFilter `json:"filter,omitempty"`
}
PullRequest asks a remote node for changes since a given point.
type PullResponse ¶
type PullResponse struct {
// Changes are the field-level change records.
Changes []ChangeRecord `json:"changes"`
// LatestHLC is the highest HLC in the response.
LatestHLC HLC `json:"latest_hlc"`
}
PullResponse contains changes from the remote node.
type PushRequest ¶
type PushRequest struct {
// Changes to push.
Changes []ChangeRecord `json:"changes"`
// NodeID of the pushing node.
NodeID string `json:"node_id"`
}
PushRequest sends local changes to a remote node.
type PushResponse ¶
type PushResponse struct {
// Merged is the number of changes that were merged.
Merged int `json:"merged"`
// LatestHLC is the remote node's latest HLC after merging.
LatestHLC HLC `json:"latest_hlc"`
}
PushResponse acknowledges a push.
type RGAListState ¶
type RGAListState struct {
// Nodes stores all nodes (including tombstoned) keyed by their ID string.
Nodes map[string]*RGANode `json:"nodes"`
// contains filtered or unexported fields
}
RGAListState holds the full state of an RGA list.
func ListFromFieldState ¶
func ListFromFieldState(fs *FieldState) *RGAListState
ListFromFieldState reconstructs an RGAListState from a FieldState.
func MergeList ¶
func MergeList(local, remote *RGAListState) *RGAListState
MergeList merges two RGA list states. The result contains all nodes from both lists with tombstones preserved. This is commutative, associative, and idempotent.
func NewRGAListState ¶
func NewRGAListState() *RGAListState
NewRGAListState creates an empty RGA list.
func (*RGAListState) Compact ¶
func (l *RGAListState) Compact(before HLC) int
Compact drops tombstoned leaf nodes older than the horizon, cascading until no more can be dropped. Returns the number of nodes removed.
func (*RGAListState) Delete ¶
func (l *RGAListState) Delete(id HLC)
Delete marks the node with the given ID as tombstoned. The order cache stays valid: tombstones remain traversal anchors and are filtered on read.
func (*RGAListState) Elements ¶
func (l *RGAListState) Elements() []json.RawMessage
Elements returns the visible (non-tombstoned) elements in order.
func (*RGAListState) Insert ¶
Insert adds a new element after the given parentID. If parentID is zero, the element is prepended to the list.
func (*RGAListState) Len ¶
func (l *RGAListState) Len() int
Len returns the number of visible (non-tombstoned) elements.
func (*RGAListState) Move ¶
func (l *RGAListState) Move(id, newParentID HLC, nodeID string, clock HLC)
Move moves an element to a new position after the given parentID. Implemented as tombstone + re-insert with new ID.
func (*RGAListState) NodeIDs ¶
func (l *RGAListState) NodeIDs() []HLC
NodeIDs returns the IDs of visible elements in order. Useful for addressing specific positions.
func (*RGAListState) ToFieldState ¶
func (l *RGAListState) ToFieldState(clock HLC, nodeID string) *FieldState
ToFieldState converts to the generic FieldState representation.
type RGANode ¶
type RGANode struct {
// ID uniquely identifies this node in the list.
ID HLC `json:"id"`
// NodeID is the node that created this element.
NodeID string `json:"node_id"`
// ParentID references the node after which this element was inserted.
// Zero HLC means this is inserted at the head of the list.
ParentID HLC `json:"parent_id"`
// Value is the JSON-encoded element value.
Value json.RawMessage `json:"value"`
// Tombstone marks this node as deleted.
Tombstone bool `json:"tombstone,omitempty"`
}
RGANode is a single element in the RGA (Replicated Growable Array). Each node has a unique ID (NodeID + HLC), an optional parent reference for causal ordering, and a tombstone flag for deletions.
type Room ¶
type Room struct {
// ID is the unique room identifier (also used as the presence topic).
ID string `json:"id"`
// Type classifies the room (e.g., "document", "channel", "canvas").
Type string `json:"type,omitempty"`
// Metadata holds arbitrary room-level data (title, permissions, etc.).
Metadata json.RawMessage `json:"metadata,omitempty"`
// MaxParticipants limits how many can join (0 = unlimited).
MaxParticipants int `json:"max_participants,omitempty"`
// CreatedAt is when the room was first created.
CreatedAt time.Time `json:"created_at"`
// CreatedBy is the nodeID that created the room.
CreatedBy string `json:"created_by,omitempty"`
}
Room represents a collaboration space with metadata and participants.
type RoomEvent ¶
type RoomEvent struct {
Type RoomEventType `json:"type"`
RoomID string `json:"room_id"`
NodeID string `json:"node_id,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
RoomEvent is a rich event emitted by the RoomManager.
type RoomEventType ¶
type RoomEventType string
RoomEventType identifies room lifecycle events.
const ( RoomEventCreated RoomEventType = "room_created" RoomEventDestroyed RoomEventType = "room_destroyed" RoomEventParticipantJoin RoomEventType = "participant_join" RoomEventParticipantLeave RoomEventType = "participant_leave" RoomEventMetadataUpdated RoomEventType = "metadata_updated" )
type RoomHook ¶
type RoomHook interface {
// OnRoomCreated is called when a room is created.
OnRoomCreated(ctx context.Context, room *Room) error
// OnRoomDestroyed is called when a room's last participant leaves.
OnRoomDestroyed(ctx context.Context, room *Room) error
// OnParticipantJoin is called when a participant joins a room.
OnParticipantJoin(ctx context.Context, room *Room, nodeID string) error
// OnParticipantLeave is called when a participant leaves a room.
OnParticipantLeave(ctx context.Context, room *Room, nodeID string) error
}
RoomHook is a callback for room lifecycle events.
type RoomInfo ¶
type RoomInfo struct {
Room
// ParticipantCount is the current number of participants.
ParticipantCount int `json:"participant_count"`
// Participants lists current participant states.
Participants []PresenceState `json:"participants"`
}
RoomInfo is the public view of a room including live participant info.
type RoomInterceptor ¶
type RoomInterceptor interface {
// BeforeRoomJoin is called before a participant joins a room.
// Return an error to deny the join (e.g., access control).
BeforeRoomJoin(ctx context.Context, roomID, nodeID string) error
// AfterRoomJoin is called after a participant has joined.
AfterRoomJoin(ctx context.Context, roomID, nodeID string) error
// BeforeRoomLeave is called before a participant leaves.
BeforeRoomLeave(ctx context.Context, roomID, nodeID string) error
// AfterRoomLeave is called after a participant has left.
AfterRoomLeave(ctx context.Context, roomID, nodeID string) error
// OnRoomCreated is called when a new room is created.
OnRoomCreated(ctx context.Context, room *Room) error
// OnRoomDestroyed is called when a room is destroyed (last participant leaves).
OnRoomDestroyed(ctx context.Context, room *Room) error
}
RoomInterceptor intercepts room lifecycle events.
type RoomManager ¶
type RoomManager struct {
// contains filtered or unexported fields
}
RoomManager provides a structured API for managing presence rooms. A room is a named collaboration space (a document, a channel, a canvas) with tracked participants, metadata, and lifecycle events.
Rooms are backed by the PresenceManager for TTL-based presence, but add:
- Room metadata (title, type, max participants, custom data)
- Room lifecycle callbacks (created, destroyed, participant limits)
- Participant count tracking
- Cursor/selection position tracking helpers
- Batch join/leave operations
- Room listing and querying
func NewRoomManager ¶
func NewRoomManager(presence *PresenceManager, logger log.Logger) *RoomManager
NewRoomManager creates a room manager backed by the given presence manager.
func (*RoomManager) AddHook ¶
func (rm *RoomManager) AddHook(hook RoomHook)
AddHook registers a room lifecycle hook.
func (*RoomManager) CreateDocumentRoom ¶
func (rm *RoomManager) CreateDocumentRoom(ctx context.Context, table, pk string, opts ...RoomOption) (*Room, error)
CreateDocumentRoom creates a room for collaborating on a specific document.
func (*RoomManager) CreateRoom ¶
func (rm *RoomManager) CreateRoom(ctx context.Context, id, roomType string, opts ...RoomOption) (*Room, error)
CreateRoom creates or returns an existing room.
func (*RoomManager) GetDocumentParticipants ¶
func (rm *RoomManager) GetDocumentParticipants(table, pk string) []PresenceState
GetDocumentParticipants returns participants for a document room.
func (*RoomManager) GetRoom ¶
func (rm *RoomManager) GetRoom(id string) *Room
GetRoom returns a room by ID, or nil if not found.
func (*RoomManager) GetRoomInfo ¶
func (rm *RoomManager) GetRoomInfo(id string) *RoomInfo
GetRoomInfo returns a room with live participant info.
func (*RoomManager) JoinDocumentRoom ¶
func (rm *RoomManager) JoinDocumentRoom(ctx context.Context, table, pk, nodeID string, data json.RawMessage) error
JoinDocumentRoom joins the room for a specific document.
func (*RoomManager) JoinRoom ¶
func (rm *RoomManager) JoinRoom(ctx context.Context, roomID, nodeID string, data json.RawMessage) error
JoinRoom adds a participant to a room. Creates the room if it doesn't exist.
func (*RoomManager) LeaveAllRooms ¶
func (rm *RoomManager) LeaveAllRooms(ctx context.Context, nodeID string)
LeaveAllRooms removes a participant from all rooms.
func (*RoomManager) LeaveDocumentRoom ¶
func (rm *RoomManager) LeaveDocumentRoom(ctx context.Context, table, pk, nodeID string)
LeaveDocumentRoom leaves the room for a specific document.
func (*RoomManager) LeaveRoom ¶
func (rm *RoomManager) LeaveRoom(ctx context.Context, roomID, nodeID string)
LeaveRoom removes a participant from a room. If the room becomes empty, it is destroyed.
func (*RoomManager) ListRooms ¶
func (rm *RoomManager) ListRooms() []RoomInfo
ListRooms returns all active rooms with participant counts.
func (*RoomManager) ListRoomsByType ¶
func (rm *RoomManager) ListRoomsByType(roomType string) []RoomInfo
ListRoomsByType returns rooms filtered by type.
func (*RoomManager) ParticipantCount ¶
func (rm *RoomManager) ParticipantCount(roomID string) int
ParticipantCount returns the number of participants in a room.
func (*RoomManager) SetRoomMetadata ¶
func (rm *RoomManager) SetRoomMetadata(roomID string, metadata any) error
SetRoomMetadata updates the metadata for a room.
func (*RoomManager) UpdateCursor ¶
func (rm *RoomManager) UpdateCursor(roomID, nodeID string, cursor CursorPosition)
UpdateCursor is a convenience method to update a participant's cursor position.
func (*RoomManager) UpdateTypingStatus ¶
func (rm *RoomManager) UpdateTypingStatus(roomID, nodeID string, isTyping bool)
UpdateTypingStatus is a convenience method to update a participant's typing state.
type RoomOption ¶
type RoomOption func(*Room)
RoomOption configures a Room during creation.
func WithMaxParticipants ¶
func WithMaxParticipants(maxCount int) RoomOption
WithMaxParticipants sets the maximum number of participants.
func WithRoomCreator ¶
func WithRoomCreator(nodeID string) RoomOption
WithRoomCreator sets who created the room.
func WithRoomMetadata ¶
func WithRoomMetadata(metadata any) RoomOption
WithRoomMetadata sets initial room metadata.
type SetOperation ¶
type SetOperation struct {
Op SetOp `json:"op"` // "add" or "remove"
Elements json.RawMessage `json:"elements"` // JSON array of elements
// Tags names the observed add-tags a remove is deleting (exact
// observed-remove semantics). Removes without tags fall back to
// removing every local tag older than the op's HLC.
Tags []Tag `json:"tags,omitempty"`
}
SetOperation represents an add or remove operation on an OR-Set.
type State ¶
type State struct {
// Table is the source table name.
Table string `json:"table"`
// PK is the string-encoded primary key.
PK string `json:"pk"`
// Fields maps field name → field-level CRDT state.
Fields map[string]*FieldState `json:"fields"`
// Tombstone is true if the record has been deleted.
Tombstone bool `json:"tombstone"`
// TombstoneHLC is the clock value of the delete operation (if tombstoned).
TombstoneHLC HLC `json:"tombstone_hlc,omitempty"`
}
State holds the full CRDT state for a single record (all fields).
type StreamingOption ¶
type StreamingOption func(*StreamingTransport)
StreamingOption configures a StreamingTransport.
func WithStreamLogger ¶
func WithStreamLogger(l log.Logger) StreamingOption
WithStreamLogger sets the logger for the streaming transport.
func WithStreamReconnect ¶
func WithStreamReconnect(d time.Duration) StreamingOption
WithStreamReconnect sets the delay before reconnecting after a disconnection from the SSE stream. Defaults to 5 seconds.
func WithStreamTables ¶
func WithStreamTables(tables ...string) StreamingOption
WithStreamTables restricts which tables the SSE stream subscribes to.
type StreamingTransport ¶
type StreamingTransport struct {
*HTTPClient // Embeds for Pull/Push.
// contains filtered or unexported fields
}
StreamingTransport wraps an HTTPClient and adds SSE streaming for real-time change propagation. It satisfies the Transport interface for pull/push operations and additionally supports StreamChanges for SSE-based real-time sync.
Use NewStreamingTransport to create one:
t := crdt.NewStreamingTransport("https://cloud.example.com/sync",
crdt.WithStreamTables("documents"),
crdt.WithStreamReconnect(5 * time.Second),
)
// Use as a Transport for pull/push:
syncer := crdt.NewSyncer(plugin, crdt.WithTransport(t))
// Or stream changes in real-time:
go t.StreamChanges(ctx, since, func(change crdt.ChangeRecord) {
// process each change as it arrives
})
func NewStreamingTransport ¶
func NewStreamingTransport(baseURL string, opts ...StreamingOption) *StreamingTransport
NewStreamingTransport creates a streaming transport that supports both pull/push (via embedded HTTPClient) and SSE streaming for real-time changes.
The baseURL should point to the sync server root (e.g., "https://cloud.example.com/sync"). The SSE endpoint is assumed to be at baseURL + "/stream".
func (*StreamingTransport) StreamChanges ¶
func (t *StreamingTransport) StreamChanges(ctx context.Context, since HLC, handler func(ChangeRecord)) error
StreamChanges connects to the remote SSE endpoint and processes changes in real-time. It blocks until the context is cancelled. On disconnection, it automatically reconnects after the configured delay.
The handler function is called for each ChangeRecord received from the SSE stream. The since parameter specifies the starting HLC; subsequent reconnections use the latest HLC received.
type SyncController ¶
type SyncController struct {
// contains filtered or unexported fields
}
SyncController handles CRDT sync operations. It provides handlers for pull, push, and streaming endpoints that can be registered with any router (Forge, net/http, chi, etc.).
For Forge apps, use grove/extension.WithCRDT() to auto-register routes. For standalone use, call NewHTTPHandler() to get an http.Handler.
func NewSyncController ¶
func NewSyncController(plugin *Plugin, opts ...SyncControllerOption) *SyncController
NewSyncController creates a new sync controller for the given plugin.
func (*SyncController) AddPlugin ¶
func (c *SyncController) AddPlugin(p CRDTPlugin)
AddPlugin registers a CRDT plugin for intercepting operations. Plugins are called in registration order. The plugin only needs to implement the interceptor interfaces it cares about.
func (*SyncController) Close ¶
func (c *SyncController) Close()
Close cleans up the controller's resources (presence manager, etc.).
func (*SyncController) HandleFieldHistory ¶
func (c *SyncController) HandleFieldHistory(ctx context.Context, req *FieldHistoryRequest) (*FieldHistoryResponse, error)
HandleFieldHistory returns the change history of a specific field.
func (*SyncController) HandleGetPresence ¶
func (c *SyncController) HandleGetPresence(_ context.Context, topic string) (*PresenceSnapshot, error)
HandleGetPresence returns a snapshot of all active presence for a topic.
func (*SyncController) HandleHistory ¶
func (c *SyncController) HandleHistory(ctx context.Context, req *HistoryRequest) (*HistoryResponse, error)
HandleHistory returns the state of a record at a specific point in time.
func (*SyncController) HandlePresenceUpdate ¶
func (c *SyncController) HandlePresenceUpdate(ctx context.Context, update *PresenceUpdate) (*PresenceEvent, error)
HandlePresenceUpdate processes a presence update and returns the resulting event. Returns nil if presence is not enabled.
func (*SyncController) HandlePull ¶
func (c *SyncController) HandlePull(ctx context.Context, req *PullRequest) (*PullResponse, error)
HandlePull processes a pull request and returns changes since the given HLC. This is the core logic used by both Forge and HTTP handlers.
func (*SyncController) HandlePush ¶
func (c *SyncController) HandlePush(ctx context.Context, req *PushRequest) (*PushResponse, error)
HandlePush processes a push request, merging remote changes locally. This is the core logic used by both Forge and HTTP handlers.
func (*SyncController) Logger ¶
func (c *SyncController) Logger() log.Logger
Logger returns the controller's logger.
func (*SyncController) Metrics ¶
func (c *SyncController) Metrics() *Metrics
Metrics returns the metrics collector, or nil if metrics are disabled.
func (*SyncController) PluginChain ¶
func (c *SyncController) PluginChain() *PluginChain
PluginChain returns the plugin chain for inspection or testing.
func (*SyncController) Presence ¶
func (c *SyncController) Presence() *PresenceManager
Presence returns the presence manager, or nil if presence is disabled.
func (*SyncController) PresenceChannel ¶
func (c *SyncController) PresenceChannel() <-chan PresenceEvent
PresenceChannel returns the channel for receiving presence events to broadcast over SSE streams. Returns nil if presence is disabled.
func (*SyncController) Rooms ¶
func (c *SyncController) Rooms() *RoomManager
Rooms returns the room manager, or nil if room management is disabled.
func (*SyncController) StreamChangesSince ¶
func (c *SyncController) StreamChangesSince(ctx context.Context, tables []string, since HLC) (<-chan []ChangeRecord, error)
StreamChangesSince returns a channel that yields new changes as they appear. The caller should poll or watch for changes. This is used by SSE handlers.
func (*SyncController) TimeTravelEnabled ¶
func (c *SyncController) TimeTravelEnabled() bool
TimeTravelEnabled returns true if the time-travel feature is enabled.
type SyncControllerOption ¶
type SyncControllerOption func(*SyncController)
SyncControllerOption configures a SyncController.
func WithControllerPlugin ¶
func WithControllerPlugin(plugin CRDTPlugin) SyncControllerOption
WithControllerPlugin registers a CRDT plugin on the sync controller. Plugins intercept merge operations, metadata reads/writes, presence, room events, time-travel queries, and client connections.
Example:
type AuditPlugin struct { crdt.BaseCRDTPlugin }
func (p *AuditPlugin) Name() string { return "audit" }
func (p *AuditPlugin) AfterMerge(ctx context.Context, ev *crdt.MergeEvent) error {
log.Printf("merge: %s/%s field=%s winner=%s", ev.Table, ev.PK, ev.Field, ev.WinnerNodeID)
return nil
}
crdt.WithControllerPlugin(&AuditPlugin{})
func WithControllerSyncHook ¶
func WithControllerSyncHook(hook SyncHook) SyncControllerOption
WithControllerSyncHook adds a sync hook to the controller. These hooks are called in addition to any plugin-level hooks.
func WithMetrics ¶
func WithMetrics() SyncControllerOption
WithMetrics enables metrics collection on the sync controller.
func WithPresenceBufferSize ¶
func WithPresenceBufferSize(size int) SyncControllerOption
WithPresenceBufferSize sets the buffer size for the presence event broadcast channel. When the buffer is full, new events are dropped. Defaults to 256.
func WithPresenceEnabled ¶
func WithPresenceEnabled(enabled bool) SyncControllerOption
WithPresenceEnabled enables the presence subsystem on the controller. When enabled, the controller creates an in-memory PresenceManager for ephemeral awareness data (typing indicators, cursors, user info). Disabled by default — zero overhead when not used.
func WithPresenceTTL ¶
func WithPresenceTTL(d time.Duration) SyncControllerOption
WithPresenceTTL sets the TTL for presence entries. After this duration without a heartbeat, presence entries are automatically removed and a "leave" event is broadcast. Defaults to 30 seconds.
func WithRoomManager ¶
func WithRoomManager(enabled bool) SyncControllerOption
WithRoomManager enables the room management subsystem on the controller. Requires presence to be enabled. Provides structured room lifecycle, participant tracking, cursor position tracking, and room listing.
func WithStreamKeepAlive ¶
func WithStreamKeepAlive(d time.Duration) SyncControllerOption
WithStreamKeepAlive sets the interval for SSE keep-alive comments. Defaults to 15 seconds.
func WithStreamPollInterval ¶
func WithStreamPollInterval(d time.Duration) SyncControllerOption
WithStreamPollInterval sets how frequently the SSE stream handler checks for new changes. Defaults to 1 second.
func WithTimeTravelEnabled ¶
func WithTimeTravelEnabled(enabled bool) SyncControllerOption
WithTimeTravelEnabled enables the opt-in time-travel feature. When enabled, the sync server exposes /history and /field-history endpoints.
func WithTimeTravelMaxDepth ¶
func WithTimeTravelMaxDepth(depth int) SyncControllerOption
WithTimeTravelMaxDepth sets the maximum number of history entries returned.
func WithValidation ¶
func WithValidation(cfg *ValidationConfig) SyncControllerOption
WithValidation enables input validation with the given config. Use DefaultValidationConfig() for sensible defaults.
type SyncFilter ¶
type SyncFilter struct {
// PKFilter restricts sync to records matching these primary keys.
PKFilter []string `json:"pk_filter,omitempty"`
// FieldFilter restricts sync to only these fields.
FieldFilter []string `json:"field_filter,omitempty"`
}
SyncFilter defines selective sync criteria for partial replication. When set on a PullRequest, only records matching the filter are returned.
type SyncHook ¶
type SyncHook interface {
// BeforeInboundChange is called before a remote change is merged locally.
// Return a modified change to transform it, nil to skip it, or an error to abort.
BeforeInboundChange(ctx context.Context, change *ChangeRecord) (*ChangeRecord, error)
// AfterInboundChange is called after a remote change has been merged locally.
// This is useful for audit logging or triggering side effects.
AfterInboundChange(ctx context.Context, change *ChangeRecord) error
// BeforeOutboundChange is called before a local change is sent to a remote peer.
// Return a modified change to transform it, nil to skip it, or an error to abort.
BeforeOutboundChange(ctx context.Context, change *ChangeRecord) (*ChangeRecord, error)
// BeforeOutboundRead is called before changes are returned in a pull response.
// Receives the full slice; return a filtered or modified slice.
BeforeOutboundRead(ctx context.Context, changes []ChangeRecord) ([]ChangeRecord, error)
}
SyncHook intercepts changes during sync operations. Implement this interface to validate, transform, filter, or audit data flowing between nodes during sync.
Use BaseSyncHook for a no-op default that you can selectively override.
type SyncHookChain ¶
type SyncHookChain struct {
// contains filtered or unexported fields
}
SyncHookChain composes multiple SyncHooks into a sequential chain. Each hook is called in order. For Before* methods, the output of one hook becomes the input of the next. A nil return skips the change. An error return aborts the chain immediately.
func NewSyncHookChain ¶
func NewSyncHookChain(hooks ...SyncHook) *SyncHookChain
NewSyncHookChain creates a chain with the given hooks.
func (*SyncHookChain) Add ¶
func (c *SyncHookChain) Add(hook SyncHook)
Add appends a hook to the chain.
func (*SyncHookChain) AfterInboundChange ¶
func (c *SyncHookChain) AfterInboundChange(ctx context.Context, change *ChangeRecord) error
AfterInboundChange calls each hook in order. If any returns an error, subsequent hooks are still called (best-effort notification).
func (*SyncHookChain) BeforeInboundChange ¶
func (c *SyncHookChain) BeforeInboundChange(ctx context.Context, change *ChangeRecord) (*ChangeRecord, error)
BeforeInboundChange calls each hook in order. If any hook returns nil, the change is skipped. If any returns an error, the chain aborts.
func (*SyncHookChain) BeforeOutboundChange ¶
func (c *SyncHookChain) BeforeOutboundChange(ctx context.Context, change *ChangeRecord) (*ChangeRecord, error)
BeforeOutboundChange calls each hook in order. If any returns nil, the change is skipped. If any returns an error, the chain aborts.
func (*SyncHookChain) BeforeOutboundRead ¶
func (c *SyncHookChain) BeforeOutboundRead(ctx context.Context, changes []ChangeRecord) ([]ChangeRecord, error)
BeforeOutboundRead calls each hook in order. Each hook receives the output of the previous hook. An error aborts the chain.
func (*SyncHookChain) Len ¶
func (c *SyncHookChain) Len() int
Len returns the number of hooks in the chain.
type SyncReport ¶
type SyncReport struct {
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Merged int `json:"merged"`
Conflicts int `json:"conflicts"`
}
SyncReport summarizes the result of a sync operation.
func (*SyncReport) String ¶
func (r *SyncReport) String() string
type Syncer ¶
type Syncer struct {
// contains filtered or unexported fields
}
Syncer orchestrates the push-pull sync protocol between nodes. It supports single-peer (edge-to-cloud), multi-peer (hub-and-spoke), and P2P topologies via the Transport interface.
syncer := crdt.NewSyncer(db, plugin,
crdt.WithTransport(crdt.HTTPTransport("https://cloud.example.com/sync")),
crdt.WithSyncInterval(30 * time.Second),
)
go syncer.Run(ctx)
func NewSyncer ¶
func NewSyncer(plugin *Plugin, opts ...SyncerOption) *Syncer
NewSyncer creates a new Syncer for the given plugin.
func (*Syncer) PushChange ¶
func (s *Syncer) PushChange(ctx context.Context, table, pk, field string, crdtType CRDTType, value json.RawMessage, clock HLC) error
PushChange sends a single change event to all peers. This is useful for CDC-driven sync where changes are pushed in real-time. If sync hooks are configured, BeforeOutboundChange is called before pushing.
func (*Syncer) StreamSync ¶
StreamSync connects to all peers that support SSE streaming and processes changes in real-time. This runs alongside the periodic poll-based Sync for lower latency on supported transports. Falls back gracefully if a transport does not support streaming.
Blocks until the context is cancelled. Start it in a goroutine:
go syncer.StreamSync(ctx)
type SyncerOption ¶
type SyncerOption func(*Syncer)
SyncerOption configures a Syncer.
func WithPeers ¶
func WithPeers(peers ...Transport) SyncerOption
WithPeers adds multiple peer transports for hub-and-spoke or P2P sync.
func WithRetry ¶
func WithRetry(attempts int, baseDelay, maxDelay time.Duration) SyncerOption
WithRetry configures retry behavior for sync operations. Defaults: 3 attempts, 1s base delay, 30s max delay.
func WithSyncInterval ¶
func WithSyncInterval(d time.Duration) SyncerOption
WithSyncInterval sets the interval for background sync. Defaults to 30 seconds.
func WithSyncTables ¶
func WithSyncTables(tables ...string) SyncerOption
WithSyncTables restricts which tables are synced.
func WithTransport ¶
func WithTransport(t Transport) SyncerOption
WithTransport sets the transport for sync communication.
type TextDelta ¶
type TextDelta struct {
Insert string `json:"insert"`
Attributes map[string]json.RawMessage `json:"attributes,omitempty"`
}
TextDelta is one Quill-style segment of the visible text.
type TextFragment ¶
type TextFragment struct {
Origin HLC `json:"origin"`
Start int `json:"start"`
Content string `json:"content"`
Length int `json:"length"`
Parent TextRef `json:"parent,omitempty"`
Tombstone bool `json:"tombstone,omitempty"`
Attrs map[string]AttrState `json:"attrs,omitempty"`
}
TextFragment is a stored piece of an origin's span.
type TextOp ¶
type TextOp struct {
Op TextOpType `json:"op"`
Ref TextRef `json:"ref,omitempty"`
Origin HLC `json:"origin,omitempty"`
Content string `json:"content,omitempty"`
Spans []TextSpan `json:"spans,omitempty"`
Attrs map[string]json.RawMessage `json:"attrs,omitempty"`
}
TextOp is a text operation for the sync transport. Inserts carry the content, the ref they anchor at and the Origin span they belong to (creator-decided: the op's own HLC for a new span, or the extended span's origin). Deletes and formats carry the exact address Spans they cover.
type TextOpType ¶
type TextOpType string
TextOpType identifies a text operation.
const ( TextOpInsert TextOpType = "insert" TextOpDelete TextOpType = "delete" TextOpFormat TextOpType = "format" )
type TextRef ¶
TextRef addresses one character: rune Offset within the insertion span identified by Origin. The zero TextRef is the document head.
type TextSpan ¶
type TextSpan struct {
Origin HLC `json:"origin"`
Start int `json:"start"`
Length int `json:"length"`
}
TextSpan names a contiguous address range within one origin.
type TextState ¶
type TextState struct {
// Frags maps origin key → fragments sorted by Start (disjoint ranges).
Frags map[string][]*TextFragment `json:"frags"`
// contains filtered or unexported fields
}
TextState holds the full text CRDT state.
func MergeText ¶
MergeText merges two text states: per-origin union with both sides normalized to the finest common fragment partition, then per-fragment tombstone-OR and per-attribute LWW. Commutative, associative, idempotent. Inputs are never mutated.
func TextFromFieldState ¶
func TextFromFieldState(fs *FieldState) *TextState
TextFromFieldState reconstructs a TextState from a FieldState.
func (*TextState) Apply ¶
Apply folds one TextOp into the state. Inserts place content at creator-chosen addresses and are idempotent (duplicate delivery is ignored). Delete/format ops touch the exact spans they name and assume CAUSAL DELIVERY per origin — they must arrive after the insert whose addresses they reference (the same assumption Yjs makes; fabriq's seq-ordered log and grove's HLC-ordered pull both satisfy it). Arbitrary-order convergence across replicas is provided by MergeText.
func (*TextState) Compact ¶
Compact skeletonizes tombstoned fragments whose origin is older than the horizon: content is freed and adjacent skeletons coalesce, but addresses are preserved so every anchor stays resolvable. Returns the number of fragments freed or coalesced away.
func (*TextState) Delete ¶
Delete tombstones length visible characters starting at ref's character and returns the op (with the exact spans it resolved) to broadcast.
func (*TextState) Delta ¶
Delta returns the visible text as attribute-run segments, merging adjacent segments with identical attributes.
func (*TextState) Format ¶
func (t *TextState) Format(ref TextRef, length int, attrs map[string]json.RawMessage, nodeID string, clock HLC) (*TextOp, error)
Format applies attribute writes to length visible characters starting at ref's character. A JSON null value clears the attribute. Returns the op.
func (*TextState) IndexOf ¶
IndexOf returns the current visible index of the character at ref. A tombstoned character collapses to the index it would occupy (the position of the next visible character) — cursor semantics.
func (*TextState) Insert ¶
Insert inserts s after the character at ref (or at the document head for the zero ref) and returns the op to broadcast. Sequential inserts by the same node at its own span's tail extend that span (run coalescing).
func (*TextState) SetString ¶
SetString reconciles the text toward the given whole string using a common prefix/suffix diff — the ORM write path for crdt:"text" fields, where the caller only has the new full value. Returns the ops emitted.
func (*TextState) ToFieldState ¶
func (t *TextState) ToFieldState(clock HLC, nodeID string) *FieldState
ToFieldState converts to the generic FieldState representation.
type TimeTravelConfig ¶
type TimeTravelConfig struct {
// Enabled controls whether the time-travel endpoints are registered.
// Defaults to false (disabled).
Enabled bool
// MaxHistoryDepth limits how many historical versions to return.
// 0 means unlimited. Defaults to 100.
MaxHistoryDepth int
}
TimeTravelConfig controls the opt-in time-travel feature. When enabled, the sync server exposes a /history endpoint for querying historical state at any point in time via HLC.
type TimeTravelInterceptor ¶
type TimeTravelInterceptor interface {
// BeforeHistoryRead is called before reading historical state.
// Return an error to deny the read (e.g., access control on history).
BeforeHistoryRead(ctx context.Context, table, pk string, atHLC HLC) error
// AfterHistoryRead is called after reading historical state.
// Return a modified state for redaction or transformation.
AfterHistoryRead(ctx context.Context, table, pk string, state *State) (*State, error)
}
TimeTravelInterceptor intercepts time-travel queries.
type Transport ¶
type Transport interface {
// Pull requests changes from a remote node since the given HLC.
Pull(ctx context.Context, req *PullRequest) (*PullResponse, error)
// Push sends local changes to a remote node.
Push(ctx context.Context, req *PushRequest) (*PushResponse, error)
}
Transport is the interface for sync communication between nodes. Implement this interface to support custom transport layers (WebSocket, NATS, Kafka, gRPC, etc.).
type TxExecutor ¶
TxExecutor extends Executor with transaction support. When the underlying executor supports transactions, MetadataStore uses them to wrap multi-field writes atomically.
type ValidationConfig ¶
type ValidationConfig struct {
// MaxChangeValueSize is the maximum byte size for a single field value (default: 1MB).
MaxChangeValueSize int
// MaxChangesPerPush is the maximum number of changes in a single push (default: 10000).
MaxChangesPerPush int
// MaxHLCDrift is the maximum allowed HLC timestamp drift from server time (default: 1 hour).
MaxHLCDrift time.Duration
// MaxRoomMetadataSize is the maximum byte size for room metadata (default: 100KB).
MaxRoomMetadataSize int
// MaxParticipantDataSize is the maximum byte size for participant presence data (default: 10KB).
MaxParticipantDataSize int
}
ValidationConfig controls input validation for sync operations.
func DefaultValidationConfig ¶
func DefaultValidationConfig() *ValidationConfig
DefaultValidationConfig returns sensible defaults.
func (*ValidationConfig) ValidateChangeRecord ¶
func (vc *ValidationConfig) ValidateChangeRecord(change *ChangeRecord) error
ValidateChangeRecord validates a single change record.
func (*ValidationConfig) ValidatePresenceData ¶
func (vc *ValidationConfig) ValidatePresenceData(data []byte) error
ValidatePresenceData validates presence payload size.
func (*ValidationConfig) ValidatePushRequest ¶
func (vc *ValidationConfig) ValidatePushRequest(req *PushRequest) error
ValidatePushRequest validates a push request.
func (*ValidationConfig) ValidateRoomMetadata ¶
func (vc *ValidationConfig) ValidateRoomMetadata(data []byte) error
ValidateRoomMetadata validates room metadata payload size.
type WSMessageType ¶
type WSMessageType string
WSMessageType identifies the type of WebSocket message.
const ( WSPullRequest WSMessageType = "pull_request" WSPullResponse WSMessageType = "pull_response" WSPushRequest WSMessageType = "push_request" WSPushResponse WSMessageType = "push_response" WSChange WSMessageType = "change" WSChanges WSMessageType = "changes" WSPresenceUpdate WSMessageType = "presence_update" WSPresenceEvent WSMessageType = "presence_event" WSPresenceGet WSMessageType = "presence_get" WSPresenceSnap WSMessageType = "presence_snapshot" WSSubscribe WSMessageType = "subscribe" WSUnsubscribe WSMessageType = "unsubscribe" WSError WSMessageType = "error" WSPing WSMessageType = "ping" WSPong WSMessageType = "pong" )
type WebSocketConn ¶
type WebSocketConn interface {
// ReadMessage reads the next message from the connection.
ReadMessage() ([]byte, error)
// WriteMessage sends a message over the connection.
WriteMessage(data []byte) error
// Close closes the connection.
Close() error
}
WebSocketConn is the interface that any WebSocket implementation must satisfy. This allows plugging in gorilla/websocket, nhooyr.io/websocket, or any other library.
type WebSocketDialer ¶
type WebSocketDialer func(ctx context.Context) (WebSocketConn, error)
WebSocketDialer is a function that establishes a new WebSocket connection. Used by StartWithReconnect to re-establish connections after disconnection.
type WebSocketHandler ¶
type WebSocketHandler struct {
// contains filtered or unexported fields
}
WebSocketHandler processes WebSocket messages on the server side. It wraps a SyncController and handles pull/push/subscribe requests received over WebSocket.
func NewWebSocketHandler ¶
func NewWebSocketHandler(ctrl *SyncController, conn WebSocketConn, logger log.Logger) *WebSocketHandler
NewWebSocketHandler creates a new server-side WebSocket handler.
type WebSocketMessage ¶
type WebSocketMessage struct {
Type WSMessageType `json:"type"`
Payload json.RawMessage `json:"payload"`
RequestID string `json:"request_id,omitempty"`
}
WebSocketMessage is the JSON framing format for multiplexed WebSocket messages. When using the binary protobuf format, use the proto WebSocketFrame instead.
type WebSocketOption ¶
type WebSocketOption func(*WebSocketTransport)
WebSocketOption configures a WebSocketTransport.
func WithWSPingInterval ¶
func WithWSPingInterval(d time.Duration) WebSocketOption
WithWSPingInterval sets the ping/keepalive interval.
func WithWSReconnectDelay ¶
func WithWSReconnectDelay(d time.Duration) WebSocketOption
WithWSReconnectDelay sets the reconnect delay.
func WithWSTables ¶
func WithWSTables(tables ...string) WebSocketOption
WithWSTables restricts which tables to subscribe to.
type WebSocketTransport ¶
type WebSocketTransport struct {
// contains filtered or unexported fields
}
WebSocketTransport implements the Transport interface over WebSocket connections. It provides bidirectional multiplexed communication for pull, push, presence, and real-time change streaming over a single connection.
func NewWebSocketTransport ¶
func NewWebSocketTransport(conn WebSocketConn, opts ...WebSocketOption) *WebSocketTransport
NewWebSocketTransport creates a new WebSocket-based transport.
func (*WebSocketTransport) Close ¶
func (t *WebSocketTransport) Close() error
Close closes the WebSocket connection.
func (*WebSocketTransport) OnChange ¶
func (t *WebSocketTransport) OnChange(handler func(ChangeRecord))
OnChange registers a handler for incoming change events.
func (*WebSocketTransport) OnPresence ¶
func (t *WebSocketTransport) OnPresence(handler func(PresenceEvent))
OnPresence registers a handler for incoming presence events.
func (*WebSocketTransport) Pull ¶
func (t *WebSocketTransport) Pull(ctx context.Context, req *PullRequest) (*PullResponse, error)
Pull requests changes from the remote node via WebSocket.
func (*WebSocketTransport) Push ¶
func (t *WebSocketTransport) Push(ctx context.Context, req *PushRequest) (*PushResponse, error)
Push sends local changes to the remote node via WebSocket.
func (*WebSocketTransport) Start ¶
func (t *WebSocketTransport) Start(ctx context.Context) error
Start begins the read loop for processing incoming messages. Call this in a goroutine after creating the transport.
func (*WebSocketTransport) StartWithReconnect ¶
func (t *WebSocketTransport) StartWithReconnect(ctx context.Context, dial WebSocketDialer) error
StartWithReconnect begins the read loop and automatically reconnects on disconnection using the provided dialer. It blocks until the context is cancelled. Between reconnection attempts, it waits for reconnectDelay.
Source Files
¶
- apply.go
- clock.go
- compact.go
- counter.go
- crdt.go
- doc.go
- document.go
- hooks.go
- inspect.go
- list.go
- merge.go
- metadata.go
- metrics.go
- migrations.go
- options.go
- plugin.go
- plugin_hooks.go
- presence.go
- presence_types.go
- register.go
- room.go
- server.go
- set.go
- sync.go
- sync_hooks.go
- text.go
- timetravel.go
- transport.go
- transport_ws.go
- validation.go