// Package oltp is quarry's forge OLTP database (0001 §8): the only
// authoritative non-DAG state. v1 holds sync users — per-repo login
// credentials for push gating. Small by construction; the litestream
// replica is the recovery story when deployment warrants it.
package oltp
import (
"database/sql"
"fmt"
"strings"
"github.com/ncruces/go-sqlite3/driver"
)
const schema = `
CREATE TABLE IF NOT EXISTS sync_user (
repo TEXT NOT NULL,
user TEXT NOT NULL,
secret TEXT NOT NULL, -- RFC §6.3 shared secret (40 hex); plaintext never stored
PRIMARY KEY (repo, user)
);`
type DB struct {
db *sql.DB
}
// allMigrations is every widening statement, in the order they are
// applied. Named rather than assembled at the call site so a test can
// check the same list the open runs — a guard reading a second copy
// would go stale the moment someone adds a group.
func allMigrations() []string {
var out []string
out = append(out, repoMigrations...)
out = append(out, mirrorMigrations...)
out = append(out, accountMigrations...)
out = append(out, sessionMigrations...)
out = append(out, webhookMigrations...)
out = append(out, gateMigrations...)
out = append(out, ciMigrations...)
out = append(out, extCIMigrations...)
out = append(out, pushDeviceMigrations...)
out = append(out, exceptionMigrations...)
return out
}
// allSchemas is every CREATE block, for the same reason.
func allSchemas() []string {
return []string{
repoSchema, accountSchema, sessionSchema, ciSchema, mirrorSchema,
webhookSchema, subsSchema, voteSchema, pollSchema, moderationSchema,
praiseSchema, protectSchema, checksSchema, autoSchema, gateSchema, categorySchema, localTicketSchema,
readSchema, capsSchema, mentionSchema, chatReadSchema, chatRefSchema, chatReplySchema,
attnSchema, pushDeviceSchema, chatBridgeSchema, exceptionSchema,
}
}
func Open(path string) (*DB, error) {
db, err := driver.Open("file:" + path)
if err != nil {
return nil, err
}
if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: pragmas: %w", err)
}
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: schema: %w", err)
}
if _, err := db.Exec(accountSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: account schema: %w", err)
}
if _, err := db.Exec(webhookSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: webhook schema: %w", err)
}
if _, err := db.Exec(praiseSchema); err != nil {
return nil, fmt.Errorf("oltp: praise schema: %w", err)
}
if _, err := db.Exec(inviteSchema); err != nil {
return nil, err
}
if _, err := db.Exec(sessionSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: session schema: %w", err)
}
if _, err := db.Exec(ciSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: ci schema: %w", err)
}
if _, err := db.Exec(mirrorSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: mirror schema: %w", err)
}
if _, err := db.Exec(repoSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: repo schema: %w", err)
}
if _, err := db.Exec(extCISchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: external ci schema: %w", err)
}
if _, err := db.Exec(checksSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: checks schema: %w", err)
}
if _, err := db.Exec(autoSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: auto-accept schema: %w", err)
}
if _, err := db.Exec(gateSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: gate schema: %w", err)
}
if _, err := db.Exec(localTicketSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: local ticket schema: %w", err)
}
if _, err := db.Exec(protectSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: protect schema: %w", err)
}
if _, err := db.Exec(voteSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: vote schema: %w", err)
}
if _, err := db.Exec(categorySchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: category schema: %w", err)
}
if _, err := db.Exec(pollSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: poll schema: %w", err)
}
if _, err := db.Exec(moderationSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: moderation schema: %w", err)
}
if _, err := db.Exec(savedViewSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: saved view schema: %w", err)
}
if _, err := db.Exec(subsSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: subs schema: %w", err)
}
if _, err := db.Exec(readSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: read schema: %w", err)
}
if _, err := db.Exec(capsSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: caps schema: %w", err)
}
if _, err := db.Exec(mentionSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: mention schema: %w", err)
}
if _, err := db.Exec(chatRefSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: chat ref schema: %w", err)
}
if _, err := db.Exec(chatReplySchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: chat reply schema: %w", err)
}
if _, err := db.Exec(chatReadSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: chat read schema: %w", err)
}
if _, err := db.Exec(exceptionSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: exception schema: %w", err)
}
if _, err := db.Exec(attnSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: attention schema: %w", err)
}
if _, err := db.Exec(pushDeviceSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: push device schema: %w", err)
}
if _, err := db.Exec(chatBridgeSchema); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: chat bridge schema: %w", err)
}
for _, alter := range allMigrations() {
if _, err := db.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
db.Close()
return nil, fmt.Errorf("oltp: migrate: %w", err)
}
}
out := &DB{db: db}
// Legacy role rows become cap strings on every open — idempotent,
// and explicit caps are never clobbered (RFC 0004 §5).
if err := out.MigrateRolesToCaps(); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: roles→caps: %w", err)
}
if err := out.seedFleetDefault(); err != nil {
db.Close()
return nil, fmt.Errorf("oltp: fleet default: %w", err)
}
return out, nil
}
func (d *DB) Close() error { return d.db.Close() }
// SetSyncUser stores (replacing) one repo user's shared secret.
func (d *DB) SetSyncUser(repo, user, secret string) error {
_, err := d.db.Exec(`INSERT OR REPLACE INTO sync_user (repo, user, secret)
VALUES (?, ?, ?)`, repo, user, secret)
return err
}
// Secret returns the stored shared secret for one repo user.
func (d *DB) Secret(repo, user string) (string, bool) {
var s string
err := d.db.QueryRow(`SELECT secret FROM sync_user WHERE repo = ? AND user = ?`,
repo, user).Scan(&s)
return s, err == nil
}
// Gated reports whether a repo has any sync users — the v1 policy
// switch: no users means anonymous push stays allowed (local use),
// one or more users means pushes must authenticate.
func (d *DB) Gated(repo string) bool {
var n int
if err := d.db.QueryRow(`SELECT count(*) FROM sync_user WHERE repo = ?`, repo).Scan(&n); err != nil {
return false
}
return n > 0
}