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

90 lines
2.1 KiB
Go

// Package frame reads the y-websocket message framing (y-protocols sync,
// awareness and auth, lib0 variable-length encoding).
//
// A message is a varUint message type followed by its body:
//
// 0 sync: varUint sync type (0 step1, 1 step2, 2 update), varUint8Array
// 1 awareness: varUint8Array
// 2 auth: varUint 0 (permission denied), varString reason
// 3 query awareness: no body
//
// varUint8Array and varString are a varUint length and that many bytes.
package frame
// Content reports whether msg is a sync message that carries document content:
// messageSync (0) followed by syncStep2 (1) or syncUpdate (2). Sync step 1 (a
// state vector) and awareness (presence) carry none.
func Content(msg []byte) bool {
return len(msg) >= 2 && msg[0] == 0 && (msg[1] == 1 || msg[1] == 2)
}
// Split cuts b into the whole messages it concatenates, in order. ok is false,
// and msgs nil, when b is not such a sequence: an unknown type, or a length
// running past the end.
func Split(b []byte) (msgs [][]byte, ok bool) {
for d := (decoder{b: b}); len(d.b) > 0; {
start := d.b
d.message()
if d.bad {
return nil, false
}
msgs = append(msgs, start[:len(start)-len(d.b)])
}
return msgs, true
}
type decoder struct {
b []byte
bad bool
}
// message consumes one message.
func (d *decoder) message() {
switch d.uint() {
case 0:
if d.uint() > 2 {
d.bad = true
}
d.bytes()
case 1:
d.bytes()
case 2:
if d.uint() != 0 {
d.bad = true
}
d.bytes()
case 3:
default:
d.bad = true
}
}
// uint consumes a lib0 varUint: 7 bits a byte, least significant first, the
// high bit set on every byte but the last. lib0 writes at most 53 bits.
func (d *decoder) uint() uint64 {
var v uint64
for shift := uint(0); !d.bad; shift += 7 {
if len(d.b) == 0 || shift > 49 {
d.bad = true
break
}
c := d.b[0]
d.b = d.b[1:]
v |= uint64(c&0x7f) << shift
if c < 0x80 {
return v
}
}
return 0
}
// bytes consumes a varUint length and that many bytes.
func (d *decoder) bytes() {
n := d.uint()
if d.bad || n > uint64(len(d.b)) {
d.bad = true
return
}
d.b = d.b[n:]
}