package oltp
import (
"database/sql"
"errors"
"time"
"craftdesign.group/quarry/internal/uv"
)
// unversionedSchema is RFC 0020's catalog of a repository's
// unversioned files: one row per name, the newest version or the
// tombstone that removed it.
//
// The bucket is the record and this is a copy of it, kept so a listing
// and the sync front's uv-hash never list the bucket on a request path.
// unversioned_source says which bucket the copy was built from: a
// repository with no source row, or one whose bound bucket is not the
// one named, is rebuilt from a listing before it is read (RFC 0001
// ยง11's disposability, passed the way RFC 0016 passes it).
const unversionedSchema = `
CREATE TABLE IF NOT EXISTS unversioned (
repo TEXT NOT NULL,
name TEXT NOT NULL,
mtime INTEGER NOT NULL, -- fossil's, seconds since 1970
hash TEXT NOT NULL DEFAULT '', -- '' for a tombstone
size INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
who TEXT NOT NULL DEFAULT '', -- uploader, sync login, or mirror:<host>
at TEXT NOT NULL, -- when quarry recorded it
PRIMARY KEY (repo, name)
);
CREATE TABLE IF NOT EXISTS unversioned_source (
repo TEXT PRIMARY KEY,
source TEXT NOT NULL, -- endpoint and bucket the catalog was built from
built_at TEXT NOT NULL
);`
// UVFiles is a repository's catalog, tombstones included, by name.
func (d *DB) UVFiles(repo string) ([]uv.File, error) {
rows, err := d.db.Query(`SELECT name, mtime, hash, size, deleted, who, at
FROM unversioned WHERE repo = ? ORDER BY name`, repo)
if err != nil {
return nil, err
}
defer rows.Close()
out := []uv.File{}
for rows.Next() {
var f uv.File
var deleted int
if err := rows.Scan(&f.Name, &f.Mtime, &f.Hash, &f.Size, &deleted, &f.Who, &f.At); err != nil {
return nil, err
}
f.Deleted = deleted != 0
out = append(out, f)
}
return out, rows.Err()
}
// UVFile is one name's row.
func (d *DB) UVFile(repo, name string) (uv.File, bool, error) {
return uvFile(d.db, repo, name)
}
type queryRower interface {
QueryRow(query string, args ...any) *sql.Row
}
func uvFile(q queryRower, repo, name string) (uv.File, bool, error) {
var f uv.File
var deleted int
err := q.QueryRow(`SELECT name, mtime, hash, size, deleted, who, at
FROM unversioned WHERE repo = ? AND name = ?`, repo, name).
Scan(&f.Name, &f.Mtime, &f.Hash, &f.Size, &deleted, &f.Who, &f.At)
if errors.Is(err, sql.ErrNoRows) {
return uv.File{}, false, nil
}
f.Deleted = deleted != 0
return f, err == nil, err
}
// PutUVFile records a version of one name if fossil's rule says it
// replaces what the catalog holds (uv.Replaces), and reports whether it
// did. The read and the write share one transaction, so two writers of
// one name cannot both win.
func (d *DB) PutUVFile(repo string, f uv.File) (bool, error) {
tx, err := d.w.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
have, exists, err := uvFile(tx, repo, f.Name)
if err != nil {
return false, err
}
if !uv.Replaces(f, have, exists) {
return false, nil
}
if err := insertUVFile(tx, repo, f); err != nil {
return false, err
}
return true, tx.Commit()
}
func insertUVFile(tx *sql.Tx, repo string, f uv.File) error {
if f.Deleted {
f.Hash, f.Size = "", 0
}
if f.At == "" {
f.At = time.Now().UTC().Format(time.RFC3339)
}
_, err := tx.Exec(`INSERT OR REPLACE INTO unversioned (repo, name, mtime, hash, size, deleted, who, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, repo, f.Name, f.Mtime, f.Hash, f.Size, boolInt(f.Deleted), f.Who, f.At)
return err
}
// UVSource is the bucket a repository's catalog was built from; false
// when it was never built, which is what makes the next read rebuild it.
func (d *DB) UVSource(repo string) (string, bool, error) {
var src string
err := d.db.QueryRow(`SELECT source FROM unversioned_source WHERE repo = ?`, repo).Scan(&src)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
return src, err == nil, err
}
// ReplaceUVCatalog swaps a repository's whole catalog for one read
// from source, in one transaction, so a reader sees the old catalog or
// the new one and never half of each.
func (d *DB) ReplaceUVCatalog(repo, source string, files []uv.File) error {
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM unversioned WHERE repo = ?`, repo); err != nil {
return err
}
for _, f := range files {
if err := insertUVFile(tx, repo, f); err != nil {
return err
}
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO unversioned_source (repo, source, built_at) VALUES (?, ?, ?)`,
repo, source, time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
return tx.Commit()
}
// DropUVCatalog forgets a repository's catalog and where it came from.
// Nothing in the bucket changes; the next read rebuilds from it.
func (d *DB) DropUVCatalog(repo string) error {
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM unversioned WHERE repo = ?`, repo); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM unversioned_source WHERE repo = ?`, repo); err != nil {
return err
}
return tx.Commit()
}