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

244 lines
7.7 KiB
Go

package store
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// Messages as y-websocket writes them, captured from yjs 13.6 and y-protocols
// 1.0 (the same as pkg/frame's tests): update inserts "hi" into Y.Text "t".
var (
update = []byte{0, 2, 16, 1, 1, 192, 217, 245, 190, 7, 0, 4, 1, 1, 116, 2, 104, 105, 0}
step1 = []byte{0, 0, 1, 0}
step2 = []byte{0, 1, 2, 0, 0}
awareness = []byte{1, 20, 1, 192, 217, 245, 190, 7, 1, 12, 123, 34, 117, 115, 101, 114, 34, 58, 34, 97, 34, 125}
)
type doc01 struct {
id string
msgs [][]byte
}
// seed01 writes a database as collab 0.1.x left it: its one table, created
// with 0.1.x's statement, and each document's state the concatenation of its
// messages in order, written with 0.1.x's upsert (0.1.x's Append concatenated
// one message at a time; the row it leaves is the same).
func seed01(t *testing.T, path string, docs ...doc01) {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS docs (
doc_id TEXT PRIMARY KEY,
state BLOB NOT NULL
)`); err != nil {
t.Fatal(err)
}
for _, d := range docs {
var existing []byte
err := db.QueryRowContext(ctx, `SELECT state FROM docs WHERE doc_id = ?`, d.id).Scan(&existing)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `
INSERT INTO docs(doc_id, state) VALUES(?, ?)
ON CONFLICT(doc_id) DO UPDATE SET state = excluded.state`,
d.id, bytes.Join(append([][]byte{existing}, d.msgs...), nil)); err != nil {
t.Fatal(err)
}
}
}
// hasTable reports whether s's database has a table named name.
func hasTable(t *testing.T, s *SQLite, name string) bool {
t.Helper()
var n int
if err := s.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n); err != nil {
t.Fatal(err)
}
return n == 1
}
// docSize returns the size doc records for id, 0 when it has no row.
func docSize(t *testing.T, s *SQLite, id string) int64 {
t.Helper()
var size int64
err := s.db.QueryRow(`SELECT size FROM doc WHERE id = ?`, id).Scan(&size)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatal(err)
}
return size
}
// A 0.1.x database opens with its documents: each keeps its content messages
// as frames, in order, and the 0.1.x table is gone. A state that is not
// y-websocket messages is kept whole. Reopening migrates nothing twice, and the
// migrated document takes appends after its frames.
func TestSQLiteMigrates01(t *testing.T) {
path := filepath.Join(t.TempDir(), "collab.db")
relay := []byte("update-from-A-hanzo:blue:relay1790152991638")
seed01(t, path,
doc01{"hanzo:w:note", [][]byte{step1, awareness, update, step2, awareness}},
doc01{"hanzo:w:raw", [][]byte{relay}},
doc01{"hanzo:w:presence", [][]byte{step1, awareness}},
)
want := map[string][][]byte{
"hanzo:w:note": {update, step2},
"hanzo:w:raw": {relay},
"hanzo:w:presence": nil,
}
for range 2 {
s, err := NewSQLite(path, 1<<20, 64<<20)
if err != nil {
t.Fatal(err)
}
if hasTable(t, s, "docs") {
t.Fatal("0.1.x table docs is still there")
}
for id, w := range want {
got, err := s.Load(context.Background(), id)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(got) != fmt.Sprint(w) {
t.Fatalf("%s: frames %v, want %v", id, got, w)
}
var size int64
for _, f := range w {
size += int64(len(f))
}
if got := docSize(t, s, id); got != size {
t.Fatalf("%s: size %d, want %d", id, got, size)
}
}
s.Close()
}
s := open(t, path, 1<<20, 64<<20)
if err := s.Append(context.Background(), "hanzo:w:note", update); err != nil {
t.Fatal(err)
}
if got := frames(t, s, "hanzo:w:note"); len(got) != 3 || got[2] != string(update) {
t.Fatalf("frames after append: %q", got)
}
}
// A document that already has frames (0.1.x run again over a current
// database) keeps them, and its 0.1.x messages follow.
func TestSQLiteMigrationFollowsFrames(t *testing.T) {
path := filepath.Join(t.TempDir(), "collab.db")
s, err := NewSQLite(path, 1<<20, 64<<20)
if err != nil {
t.Fatal(err)
}
if err := s.Append(context.Background(), "hanzo:w:note", step2); err != nil {
t.Fatal(err)
}
s.Close()
seed01(t, path, doc01{"hanzo:w:note", [][]byte{step1, update}})
s = open(t, path, 1<<20, 64<<20)
if got, want := fmt.Sprint(frames(t, s, "hanzo:w:note")), fmt.Sprint([]string{string(step2), string(update)}); got != want {
t.Fatalf("frames %q, want %q", got, want)
}
if got := docSize(t, s, "hanzo:w:note"); got != int64(len(step2)+len(update)) {
t.Fatalf("size %d, want %d", got, len(step2)+len(update))
}
}
// The migration fits in the file cap when one 0.1.x document takes more than
// half of it: its frames reuse the pages its row freed.
func TestSQLiteMigrationReusesPages(t *testing.T) {
const maxFile = 1 << 20
path := filepath.Join(t.TempDir(), "collab.db")
big := append([]byte{0, 2, 0xe0, 0xd4, 0x03}, make([]byte, 60000)...) // one 60000-byte update
msgs := slices.Repeat([][]byte{big}, 10)
seed01(t, path, doc01{"hanzo:w:note", msgs})
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Size() < maxFile/2 || fi.Size() > maxFile {
t.Fatalf("seeded %d bytes, want between half the cap and the cap", fi.Size())
}
s := open(t, path, 1<<30, maxFile)
got, err := s.Load(context.Background(), "hanzo:w:note")
if err != nil || !slices.EqualFunc(got, msgs, bytes.Equal) {
t.Fatalf("%d frames, want the %d updates (%v)", len(got), len(msgs), err)
}
if fi, err := os.Stat(path); err != nil || fi.Size() > maxFile {
t.Fatalf("file after the migration: %v bytes, cap %d (%v)", fi.Size(), maxFile, err)
}
}
// A migration that cannot fit fails NewSQLite and leaves the 0.1.x table
// byte for byte; a larger cap then migrates it.
func TestSQLiteMigrationFailureKeepsDocs(t *testing.T) {
path := filepath.Join(t.TempDir(), "collab.db")
msgs := make([][]byte, 20000) // many small frames: their rows and index outgrow the state
for i := range msgs {
msgs[i] = update
}
seed01(t, path, doc01{"hanzo:w:note", msgs})
state := bytes.Repeat(update, len(msgs))
if _, err := NewSQLite(path, 1<<30, 512<<10); err == nil || !strings.Contains(err.Error(), "migrate") {
t.Fatalf("NewSQLite = %v, want a migrate error", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
var got []byte
if err := db.QueryRow(`SELECT state FROM docs WHERE doc_id = 'hanzo:w:note'`).Scan(&got); err != nil || !bytes.Equal(got, state) {
t.Fatalf("0.1.x state after a failed migration: %d bytes, want %d (%v)", len(got), len(state), err)
}
var n int
if err := db.QueryRow(`SELECT count(*) FROM frame`).Scan(&n); err != nil || n != 0 {
t.Fatalf("%d frames written by a failed migration (%v)", n, err)
}
db.Close()
s := open(t, path, 1<<30, 8<<20)
if got := frames(t, s, "hanzo:w:note"); len(got) != len(msgs) {
t.Fatalf("%d frames after the retry, want %d", len(got), len(msgs))
}
}
// A "file:" URI, such as the DSN collab 0.1.1 is configured with, is refused
// by name before anything is opened: the store sets its own pragmas on a file
// path.
func TestSQLiteRefusesURI(t *testing.T) {
dir := t.TempDir()
for _, p := range []string{
"file:" + dir + "/collab.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(DELETE)&_pragma=max_page_count(16384)&_txlock=immediate",
"file:" + dir + "/collab.db",
dir + "/collab.db?cache=shared",
} {
s, err := NewSQLite(p, 1<<20, 64<<20)
if err == nil {
s.Close()
}
if err == nil || !strings.Contains(err.Error(), "URI") {
t.Fatalf("NewSQLite(%q) = %v, want it refused as a URI", p, err)
}
}
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
t.Fatalf("refused URIs left files: %v", entries)
}
}