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()
}
// releasesRange keeps a releases/ query on the primary key: every name
// that starts releases/ sorts at or after the first bound and before
// the second, since '0' follows '/'. The instr test then wants a folder
// and a file under it: releases/<folder>/<file>.
const releasesRange = `name >= 'releases/' AND name < 'releases0' AND instr(substr(name, 10), '/') > 1`
// UVHasReleases is whether any live name sits in a folder under
// releases/, by existence: the repository bar asks on every page.
func (d *DB) UVHasReleases(repo string) (bool, error) {
var n int
err := d.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM unversioned
WHERE repo = ? AND deleted = 0 AND `+releasesRange+`)`, repo).Scan(&n)
return n != 0, err
}
// UVReleaseFiles is every live name in a folder under releases/, by
// name: the releases page and the Files page's block read only these.
func (d *DB) UVReleaseFiles(repo string) ([]uv.File, error) {
rows, err := d.db.Query(`SELECT name, mtime, hash, size, deleted, who, at
FROM unversioned WHERE repo = ? AND deleted = 0 AND `+releasesRange+` 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()
}
// UVFolder is one release folder: its name and its newest file's mtime.
type UVFolder struct {
Name string
Mtime int64
}
// UVReleaseFolders is one row per folder under releases/ holding a live
// file, with the newest mtime in it, by name: what the Files page's
// block needs, without reading every file.
func (d *DB) UVReleaseFolders(repo string) ([]UVFolder, error) {
rows, err := d.db.Query(`SELECT substr(name, 10, instr(substr(name, 10), '/') - 1) AS folder, MAX(mtime)
FROM unversioned WHERE repo = ? AND deleted = 0 AND `+releasesRange+`
GROUP BY folder ORDER BY folder`, repo)
if err != nil {
return nil, err
}
defer rows.Close()
out := []UVFolder{}
for rows.Next() {
var f UVFolder
if err := rows.Scan(&f.Name, &f.Mtime); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}