Files
zeekayandHanzo Dev 72d038cb61 A 0.1.x database opens with its documents, and a DSN path is refused by name
0.1.x kept each document as one row of docs(doc_id, state), every message it
received concatenated. The frame store never read that table, so the first
start of a later image on a 0.1.x volume served every document empty.

NewSQLite now moves docs into doc and frame, in one transaction, and drops
it. A state that splits into whole y-websocket messages keeps its content
messages (sync step 2 and update) as frames, in order: what Append stores for
the same messages. A state that does not split is kept whole as one frame,
which is what 0.1.x replayed. Migrated frames follow any the document already
has. Each row is deleted before its frames are written, so they reuse its
pages; if they still outgrow COLLAB_STORE_BYTES, the transaction rolls back,
docs is left as it was, and startup fails with "migrate 0.1 docs".

pkg/frame holds the y-websocket framing: Content, which the room already used
to decide what to store, and Split, which the migration uses.

COLLAB_SQLITE_PATH is a file path; the store sets its own pragmas. 0.1.1 took
a driver DSN there, and a DSN given to this store became "file:file:..." and
failed as "unable to open database file: out of memory (14)". A "file:" URI
or a path with a query is now refused before anything opens, naming it.

Tests seed a database with 0.1.x's own DDL and upsert: its Yjs update and
step 2 come back as frames, a non-Yjs state comes back whole, a document with
frames keeps them first, a migration larger than half the cap fits, one that
cannot fit leaves docs byte for byte, and reopening migrates nothing twice.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 16:22:09 -07:00

145 lines
4.0 KiB
Go

// Package room is the in-memory registry of doc-keyed broadcast hubs.
//
// We use a dumb relay (no server-side Y.js merging). Justification:
// y-websocket already converges client state via the sync + awareness
// protocols; the server only needs to (a) fan out binary messages and
// (b) persist the ones that carry document content so a fresh peer can
// replay the state. Merging CRDT updates server-side would buy us nothing
// for single-region relay and pulls in a non-trivial dep (peerdb-io/y-go).
// Revisit only when we need cross-region merge.
package room
import (
"context"
"errors"
"sync"
"github.com/hanzoai/collab/pkg/frame"
"github.com/hanzoai/collab/pkg/metrics"
"github.com/hanzoai/collab/pkg/store"
)
// Peer is a single connected WS participant.
type Peer struct {
ID string
Send chan []byte
}
// Room fans out binary messages between Peers of the same docID and
// persists the ones that carry document content to the backing Store.
type Room struct {
DocID string
store store.Store
mu sync.Mutex
peers map[*Peer]struct{}
}
// Registry maps docID → Room. One Room per doc, reference-counted on
// peer add/remove. When the last peer leaves we GC the room.
type Registry struct {
store store.Store
mu sync.Mutex
rooms map[string]*Room
}
// NewRegistry builds a Registry over the given Store.
func NewRegistry(s store.Store) *Registry {
return &Registry{store: s, rooms: map[string]*Room{}}
}
// Join attaches peer to docID's Room (creating it if needed) and
// returns the persisted frames to replay to the new peer, in order.
//
// The state is loaded before the registry is touched, so a failed Load
// leaves no Room behind. The Room is then found or created and the peer
// added under one r.mu hold, the same lock Leave takes to remove an empty
// Room, so a joiner can never enter a Room that Leave has just removed.
func (r *Registry) Join(ctx context.Context, docID string, peer *Peer) (*Room, [][]byte, error) {
frames, err := r.store.Load(ctx, docID)
if err != nil {
return nil, nil, err
}
r.mu.Lock()
defer r.mu.Unlock()
rm, ok := r.rooms[docID]
if !ok {
rm = &Room{DocID: docID, store: r.store, peers: map[*Peer]struct{}{}}
r.rooms[docID] = rm
metrics.Rooms.Inc()
}
rm.mu.Lock()
rm.peers[peer] = struct{}{}
rm.mu.Unlock()
metrics.Peers.Inc()
return rm, frames, nil
}
// Leave detaches peer; when the last peer leaves the Room is removed.
func (r *Registry) Leave(docID string, peer *Peer) {
r.mu.Lock()
rm, ok := r.rooms[docID]
if !ok {
r.mu.Unlock()
return
}
rm.mu.Lock()
delete(rm.peers, peer)
empty := len(rm.peers) == 0
rm.mu.Unlock()
if empty {
delete(r.rooms, docID)
metrics.Rooms.Dec()
}
r.mu.Unlock()
metrics.Peers.Dec()
}
// Broadcast relays msg to all peers in the room EXCEPT the sender, then
// persists it if it carries document content. The append runs in the
// caller's goroutine, so a connection reads its next frame only after this
// one is stored: each connection has at most one append in flight.
func (rm *Room) Broadcast(ctx context.Context, sender *Peer, msg []byte) {
rm.mu.Lock()
targets := make([]*Peer, 0, len(rm.peers))
for p := range rm.peers {
if p == sender {
continue
}
targets = append(targets, p)
}
rm.mu.Unlock()
for _, p := range targets {
select {
case p.Send <- msg:
default:
// Drop messages for slow peers rather than block the relay.
metrics.Errors.WithLabelValues("peer_slow").Inc()
}
}
// Sync step 1 and awareness (sent on every cursor move) are relayed but
// not stored: replaying them to a later peer replays nothing of the
// document.
if !frame.Content(msg) {
return
}
switch err := rm.store.Append(ctx, rm.DocID, msg); {
case err == nil:
case errors.Is(err, store.ErrFull):
metrics.Errors.WithLabelValues("store_full").Inc()
default:
metrics.Errors.WithLabelValues("store_append").Inc()
}
}
// PeerCount returns the current number of peers in this room.
func (rm *Room) PeerCount() int {
rm.mu.Lock()
defer rm.mu.Unlock()
return len(rm.peers)
}