mirror of
https://github.com/hanzoai/collab.git
synced 2026-10-30 18:44:20 +00:00
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]>
205 lines
6.3 KiB
Go
205 lines
6.3 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/sqlite"
|
|
|
|
"github.com/hanzoai/collab/pkg/frame"
|
|
)
|
|
|
|
// pageSize is the page size a new database is created with; maxFile is
|
|
// converted to max_page_count in these units.
|
|
const pageSize = 4096
|
|
|
|
// SQLite keeps one row per document in doc, carrying the document's size, and
|
|
// one row per frame in frame. It uses a single connection, so appends never
|
|
// contend for the write lock: callers queue on the pool, each holding only the
|
|
// frame it is persisting.
|
|
//
|
|
// Two bounds hold. Append refuses a frame that would take its document past
|
|
// maxDoc bytes. max_page_count holds the database file to maxFile bytes, which
|
|
// SQLite enforces on every write with SQLITE_FULL; the rollback journal holds
|
|
// only the pages one transaction changes, so the files on disk never exceed
|
|
// twice maxFile.
|
|
type SQLite struct {
|
|
db *sql.DB
|
|
maxDoc int64
|
|
}
|
|
|
|
// NewSQLite opens the SQLite database at path, capping each document at maxDoc
|
|
// bytes of frames and the database file at maxFile bytes, and moves a 0.1.x
|
|
// database's documents into the current tables (see migrate). The pragmas are
|
|
// applied here to every connection on either driver backend, so path is a file
|
|
// path, never a "file:" URI carrying its own.
|
|
func NewSQLite(path string, maxDoc, maxFile int64) (*SQLite, error) {
|
|
if strings.HasPrefix(path, "file:") || strings.ContainsRune(path, '?') {
|
|
return nil, fmt.Errorf("%q is a URI: give a file path; the pragmas are set here", path)
|
|
}
|
|
db, err := sqlite.OpenPragma("file:"+path, []sqlite.Pragma{
|
|
{Name: "busy_timeout", Value: "5000"},
|
|
{Name: "page_size", Value: strconv.Itoa(pageSize)},
|
|
{Name: "journal_mode", Value: "DELETE"},
|
|
{Name: "max_page_count", Value: strconv.FormatInt(maxFile/pageSize, 10)},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
if _, err := db.ExecContext(context.Background(), `
|
|
CREATE TABLE IF NOT EXISTS doc (
|
|
id TEXT PRIMARY KEY,
|
|
size INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS frame (
|
|
seq INTEGER PRIMARY KEY,
|
|
doc TEXT NOT NULL,
|
|
data BLOB NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS frame_doc ON frame(doc, seq)`); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("create tables: %w", err)
|
|
}
|
|
if err := migrate(context.Background(), db); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrate 0.1 docs: %w", err)
|
|
}
|
|
return &SQLite{db: db, maxDoc: maxDoc}, nil
|
|
}
|
|
|
|
// migrate moves the documents of a 0.1.x database into doc and frame, then
|
|
// drops its table. 0.1.x kept one row per document, docs(doc_id, state), where
|
|
// state is every binary message the document received, concatenated.
|
|
//
|
|
// A state that splits into whole y-websocket messages keeps its content
|
|
// messages, in order, as frames: 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. The migrated frames follow any the document already has, its size
|
|
// counts them, and maxDoc does not refuse them.
|
|
//
|
|
// It is one transaction, so a failure (SQLITE_FULL when the frames and their
|
|
// index outgrow maxFile) leaves the 0.1.x table as it was and NewSQLite fails.
|
|
// Each document's row is deleted before its frames are written, so the frames
|
|
// reuse the pages the row freed.
|
|
func migrate(ctx context.Context, db *sql.DB) error {
|
|
var n int
|
|
if err := db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'docs'`).Scan(&n); err != nil || n == 0 {
|
|
return err
|
|
}
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
rows, err := tx.QueryContext(ctx, `SELECT doc_id FROM docs ORDER BY rowid`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := errors.Join(rows.Err(), rows.Close()); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, id := range ids {
|
|
var state []byte
|
|
if err := tx.QueryRowContext(ctx, `SELECT state FROM docs WHERE doc_id = ?`, id).Scan(&state); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM docs WHERE doc_id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
frames := [][]byte{state}
|
|
if msgs, ok := frame.Split(state); ok {
|
|
frames = slices.DeleteFunc(msgs, func(m []byte) bool { return !frame.Content(m) })
|
|
}
|
|
size := 0
|
|
for _, f := range frames {
|
|
if len(f) == 0 {
|
|
continue
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO frame(doc, data) VALUES(?, ?)`, id, f); err != nil {
|
|
return err
|
|
}
|
|
size += len(f)
|
|
}
|
|
if size > 0 {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO doc(id, size) VALUES(?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET size = size + excluded.size`,
|
|
id, size); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DROP TABLE docs`); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// Append inserts frame after docID's existing frames and adds its length to
|
|
// the document's size, in one transaction. It returns ErrFull, and writes
|
|
// nothing, when the frame would take the document past maxDoc.
|
|
func (s *SQLite) Append(ctx context.Context, docID string, frame []byte) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var size int64
|
|
err = tx.QueryRowContext(ctx, `SELECT size FROM doc WHERE id = ?`, docID).Scan(&size)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if size+int64(len(frame)) > s.maxDoc {
|
|
return ErrFull
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO frame(doc, data) VALUES(?, ?)`, docID, frame); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO doc(id, size) VALUES(?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET size = size + excluded.size`,
|
|
docID, len(frame)); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// Load returns docID's frames in append order, or nil if it has none.
|
|
func (s *SQLite) Load(ctx context.Context, docID string) ([][]byte, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT data FROM frame WHERE doc = ? ORDER BY seq`, docID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var frames [][]byte
|
|
for rows.Next() {
|
|
var f []byte
|
|
if err := rows.Scan(&f); err != nil {
|
|
return nil, err
|
|
}
|
|
frames = append(frames, f)
|
|
}
|
|
return frames, rows.Err()
|
|
}
|
|
|
|
func (s *SQLite) Close() error { return s.db.Close() }
|