Phase 0: bootstrap the Warp homeserver
Scaffolds the server module and everything Phase 0 of PLAN.md calls for: a binary that boots, serves its discovery endpoints and nothing else yet. - config: one YAML file plus per-field WARP_* environment overrides, unknown fields rejected, validation that reports every problem at once - logging and metrics: slog, a Prometheus registry, per-route HTTP instrumentation labelled by pattern rather than path - httputil: the Matrix error envelope, error-returning handlers, CORS, request logging, panic recovery, and a router over http.ServeMux - endpoints: /_matrix/client/versions, /.well-known/matrix/client, /.well-known/matrix/server (gated), /healthz, /metrics, and M_UNRECOGNIZED for everything else - storage: driver registry, shared database/sql base, checksummed migration runner, sqlite and postgres drivers, pocketbase stub - settings: server_settings table behind a cached service, so the federation and registration switches change without a restart - federation: the gate that makes federation a runtime mode - cmd/warp: serve, migrate, generate-keys, version - Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests, race on Linux, storage tests against PostgreSQL, lint, boot smoke test), Dockerfile, example config and docs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
b4fb60a8ab
50 files changed
+5672
No files matched your search
@@ -0,0 +1,14 @@
|
||||
// Package drivers links every storage driver into the binary. Importing it
|
||||
// for its side effects is what makes storage.Open able to find them:
|
||||
//
|
||||
// import _ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
|
||||
//
|
||||
// It exists so that commands and tests have one import to remember, and so
|
||||
// that the storage package itself never imports a database engine.
|
||||
package drivers
|
||||
|
||||
import (
|
||||
_ "github.com/dariuszjaniak/warp/server/internal/storage/pocketbase"
|
||||
_ "github.com/dariuszjaniak/warp/server/internal/storage/postgres"
|
||||
_ "github.com/dariuszjaniak/warp/server/internal/storage/sqlite"
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package migrations runs embedded SQL migrations against any
|
||||
// database/sql driver.
|
||||
//
|
||||
// Migrations live next to their driver as files named NNNN_description.sql
|
||||
// and are applied in version order inside a transaction each, recorded in a
|
||||
// schema_migrations table. A migration that has already run and whose file
|
||||
// has since changed is an error, not a silent no-op.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Migration is one SQL file.
|
||||
type Migration struct {
|
||||
Version int
|
||||
Name string
|
||||
SQL string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
// Applied is a row of schema_migrations.
|
||||
type Applied struct {
|
||||
Version int
|
||||
Name string
|
||||
Checksum string
|
||||
AppliedAt time.Time
|
||||
}
|
||||
|
||||
// Dialect carries the handful of statements that differ per database. Each
|
||||
// driver package supplies one; the runner itself stays dialect-agnostic.
|
||||
type Dialect struct {
|
||||
// Name is used in log lines and errors, e.g. "sqlite".
|
||||
Name string
|
||||
|
||||
// CreateTable creates schema_migrations if it does not exist, with
|
||||
// columns (version, name, checksum, applied_at).
|
||||
CreateTable string
|
||||
|
||||
// SelectApplied reads every row, ordered by version.
|
||||
SelectApplied string
|
||||
|
||||
// InsertApplied records one migration. Its placeholders bind
|
||||
// (version, name, checksum, applied_at) in that order.
|
||||
InsertApplied string
|
||||
}
|
||||
|
||||
// Load reads every NNNN_*.sql file in dir of fsys.
|
||||
func Load(fsys fs.FS, dir string) ([]Migration, error) {
|
||||
entries, err := fs.ReadDir(fsys, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
var out []Migration
|
||||
seen := map[int]string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
version, name, err := parseFilename(e.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prev, dup := seen[version]; dup {
|
||||
return nil, fmt.Errorf("duplicate migration version %d: %s and %s", version, prev, e.Name())
|
||||
}
|
||||
seen[version] = e.Name()
|
||||
|
||||
body, err := fs.ReadFile(fsys, path.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %s: %w", e.Name(), err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
out = append(out, Migration{
|
||||
Version: version,
|
||||
Name: name,
|
||||
SQL: string(body),
|
||||
Checksum: hex.EncodeToString(sum[:]),
|
||||
})
|
||||
}
|
||||
slices.SortFunc(out, func(a, b Migration) int { return a.Version - b.Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseFilename splits "0001_server_settings.sql" into 1 and
|
||||
// "server_settings".
|
||||
func parseFilename(filename string) (int, string, error) {
|
||||
base := strings.TrimSuffix(filename, ".sql")
|
||||
prefix, name, ok := strings.Cut(base, "_")
|
||||
if !ok {
|
||||
return 0, "", fmt.Errorf("migration %s: expected NNNN_description.sql", filename)
|
||||
}
|
||||
version, err := strconv.Atoi(prefix)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("migration %s: %q is not a version number", filename, prefix)
|
||||
}
|
||||
if version <= 0 {
|
||||
return 0, "", fmt.Errorf("migration %s: version must be positive", filename)
|
||||
}
|
||||
return version, name, nil
|
||||
}
|
||||
|
||||
// Status lists the migrations already applied.
|
||||
func Status(ctx context.Context, db *sql.DB, d Dialect) ([]Applied, error) {
|
||||
if _, err := db.ExecContext(ctx, d.CreateTable); err != nil {
|
||||
return nil, fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, d.SelectApplied)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Applied
|
||||
for rows.Next() {
|
||||
var a Applied
|
||||
if err := rows.Scan(&a.Version, &a.Name, &a.Checksum, &a.AppliedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan schema_migrations: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Run applies every migration that has not run yet and returns how many it
|
||||
// applied. It is safe to call on every boot.
|
||||
func Run(ctx context.Context, db *sql.DB, d Dialect, ms []Migration, log *slog.Logger) (int, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
applied, err := Status(ctx, db, d)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
known := make(map[int]Applied, len(applied))
|
||||
for _, a := range applied {
|
||||
known[a.Version] = a
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, m := range ms {
|
||||
if prev, ok := known[m.Version]; ok {
|
||||
if prev.Checksum != m.Checksum {
|
||||
return count, fmt.Errorf(
|
||||
"migration %04d_%s has changed since it was applied (recorded %s, file %s); "+
|
||||
"write a new migration instead of editing an applied one",
|
||||
m.Version, m.Name, short(prev.Checksum), short(m.Checksum))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := apply(ctx, db, d, m); err != nil {
|
||||
return count, err
|
||||
}
|
||||
log.Info("applied migration", "version", m.Version, "name", m.Name)
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func apply(ctx context.Context, db *sql.DB, d Dialect, m Migration) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %04d_%s: %w", m.Version, m.Name, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
|
||||
return fmt.Errorf("migration %04d_%s: %w", m.Version, m.Name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, d.InsertApplied, m.Version, m.Name, m.Checksum, time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("record migration %04d_%s: %w", m.Version, m.Name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %04d_%s: %w", m.Version, m.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pending reports the migrations in ms that have not been applied yet.
|
||||
func Pending(ctx context.Context, db *sql.DB, d Dialect, ms []Migration) ([]Migration, error) {
|
||||
applied, err := Status(ctx, db, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
done := make(map[int]struct{}, len(applied))
|
||||
for _, a := range applied {
|
||||
done[a.Version] = struct{}{}
|
||||
}
|
||||
var out []Migration
|
||||
for _, m := range ms {
|
||||
if _, ok := done[m.Version]; !ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrDirtySchema is returned when the recorded schema is ahead of the
|
||||
// binary: an older warp must not run against a newer database.
|
||||
var ErrDirtySchema = errors.New("database schema is newer than this binary")
|
||||
|
||||
// CheckNotAhead fails when the database records a migration this binary does
|
||||
// not know about.
|
||||
func CheckNotAhead(applied []Applied, ms []Migration) error {
|
||||
highest := 0
|
||||
for _, m := range ms {
|
||||
highest = max(highest, m.Version)
|
||||
}
|
||||
for _, a := range applied {
|
||||
if a.Version > highest {
|
||||
return fmt.Errorf("%w: database is at %04d_%s, this binary knows up to %04d",
|
||||
ErrDirtySchema, a.Version, a.Name, highest)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func short(checksum string) string {
|
||||
if len(checksum) > 8 {
|
||||
return checksum[:8]
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
|
||||
)
|
||||
|
||||
var dialect = migrations.Dialect{
|
||||
Name: "sqlite",
|
||||
CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY, name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL, applied_at TIMESTAMP NOT NULL)`,
|
||||
SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
|
||||
InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`,
|
||||
}
|
||||
|
||||
func openDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "test.db"))
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func fsWith(files map[string]string) fstest.MapFS {
|
||||
out := fstest.MapFS{}
|
||||
for name, body := range files {
|
||||
out["migrations/"+name] = &fstest.MapFile{Data: []byte(body)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLoadOrdersByVersionAndParsesNames(t *testing.T) {
|
||||
ms, err := migrations.Load(fsWith(map[string]string{
|
||||
"0002_devices.sql": "CREATE TABLE devices (id TEXT);",
|
||||
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT);",
|
||||
"notes.txt": "ignored",
|
||||
}), "migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(ms) != 2 {
|
||||
t.Fatalf("loaded %d migrations, want 2", len(ms))
|
||||
}
|
||||
if ms[0].Version != 1 || ms[0].Name != "server_settings" {
|
||||
t.Errorf("first migration = %d/%s", ms[0].Version, ms[0].Name)
|
||||
}
|
||||
if ms[1].Version != 2 || ms[1].Name != "devices" {
|
||||
t.Errorf("second migration = %d/%s", ms[1].Version, ms[1].Name)
|
||||
}
|
||||
if ms[0].Checksum == "" || ms[0].Checksum == ms[1].Checksum {
|
||||
t.Error("each migration needs its own checksum")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsBadFilenames(t *testing.T) {
|
||||
for _, name := range []string{"nope.sql", "abc_x.sql", "0000_zero.sql"} {
|
||||
if _, err := migrations.Load(fsWith(map[string]string{name: "SELECT 1;"}), "migrations"); err == nil {
|
||||
t.Errorf("Load(%s) = nil error, want a complaint", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIsIdempotent(t *testing.T) {
|
||||
db := openDB(t)
|
||||
fsys := fsWith(map[string]string{
|
||||
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);",
|
||||
})
|
||||
ms, err := migrations.Load(fsys, "migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := migrations.Run(t.Context(), db, dialect, ms, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("applied %d migrations, want 1", n)
|
||||
}
|
||||
|
||||
n, err = migrations.Run(t.Context(), db, dialect, ms, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("second Run: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("second Run applied %d migrations, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDetectsEditedMigration(t *testing.T) {
|
||||
db := openDB(t)
|
||||
before, err := migrations.Load(fsWith(map[string]string{
|
||||
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY);",
|
||||
}), "migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := migrations.Run(t.Context(), db, dialect, before, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after, err := migrations.Load(fsWith(map[string]string{
|
||||
"0001_server_settings.sql": "CREATE TABLE server_settings (k TEXT PRIMARY KEY, v TEXT);",
|
||||
}), "migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = migrations.Run(t.Context(), db, dialect, after, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an edited migration")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "has changed since it was applied") {
|
||||
t.Errorf("unhelpful error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRollsBackFailedMigration(t *testing.T) {
|
||||
db := openDB(t)
|
||||
ms, err := migrations.Load(fsWith(map[string]string{
|
||||
"0001_ok.sql": "CREATE TABLE a (k TEXT);",
|
||||
"0002_broken.sql": "CREATE TABLE b (k TEXT); THIS IS NOT SQL;",
|
||||
}), "migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := migrations.Run(t.Context(), db, dialect, ms, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected the broken migration to fail")
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("applied %d migrations before failing, want 1", n)
|
||||
}
|
||||
|
||||
// The failed migration must leave nothing behind.
|
||||
var count int
|
||||
if err := db.QueryRowContext(t.Context(),
|
||||
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'b'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Error("table from the failed migration was not rolled back")
|
||||
}
|
||||
|
||||
pending, err := migrations.Pending(t.Context(), db, dialect, ms)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Version != 2 {
|
||||
t.Errorf("pending = %v, want just migration 2", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNotAhead(t *testing.T) {
|
||||
applied := []migrations.Applied{{Version: 7, Name: "future"}}
|
||||
ms := []migrations.Migration{{Version: 1, Name: "server_settings"}}
|
||||
|
||||
if err := migrations.CheckNotAhead(applied, ms); err == nil {
|
||||
t.Fatal("expected a dirty-schema error")
|
||||
}
|
||||
if err := migrations.CheckNotAhead(nil, ms); err != nil {
|
||||
t.Errorf("fresh database must be fine, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package pocketbase is the placeholder for the PocketBase storage driver.
|
||||
//
|
||||
// The plan (PLAN.md section 4) is an appliance deployment: one binary that
|
||||
// also brings admin auth, a filesystem abstraction for media (local or S3)
|
||||
// and realtime for the admin panel. The event DAG is a poor fit for
|
||||
// PocketBase's record model, so the roomserver and syncapi tables will use
|
||||
// its underlying dbx connection directly rather than its collection API.
|
||||
//
|
||||
// Until that work starts (Phase 10) the driver is compiled in, registered
|
||||
// and fails loudly, so that `database.driver: pocketbase` is a clear error
|
||||
// instead of an unknown-driver one, and so the interface stays honest:
|
||||
// every method the other drivers implement exists here too.
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
func init() {
|
||||
storage.Register(storage.DriverPocketBase, Open)
|
||||
}
|
||||
|
||||
// Database is the not-yet-implemented PocketBase driver.
|
||||
type Database struct{}
|
||||
|
||||
// Open always fails with storage.ErrNotImplemented.
|
||||
func Open(context.Context, storage.Config, *slog.Logger) (storage.Database, error) {
|
||||
return nil, fmt.Errorf(
|
||||
"pocketbase driver is not implemented yet (planned for Phase 10); use %q or %q: %w",
|
||||
storage.DriverSQLite, storage.DriverPostgres, storage.ErrNotImplemented)
|
||||
}
|
||||
|
||||
// Driver implements storage.Database.
|
||||
func (*Database) Driver() string { return storage.DriverPocketBase }
|
||||
|
||||
// Ping implements storage.Database.
|
||||
func (*Database) Ping(context.Context) error { return storage.ErrNotImplemented }
|
||||
|
||||
// Migrate implements storage.Database.
|
||||
func (*Database) Migrate(context.Context) error { return storage.ErrNotImplemented }
|
||||
|
||||
// PendingMigrations implements storage.Database.
|
||||
func (*Database) PendingMigrations(context.Context) ([]string, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
// Settings implements storage.Database.
|
||||
func (*Database) Settings() storage.SettingsStore { return notImplementedSettings{} }
|
||||
|
||||
// Close implements storage.Database.
|
||||
func (*Database) Close() error { return nil }
|
||||
|
||||
// notImplementedSettings satisfies storage.SettingsStore by refusing every
|
||||
// call, so a partially wired-up PocketBase deployment cannot silently lose
|
||||
// an admin's federation switch.
|
||||
type notImplementedSettings struct{}
|
||||
|
||||
func (notImplementedSettings) Get(context.Context, string) (string, bool, error) {
|
||||
return "", false, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSettings) GetAll(context.Context) (map[string]string, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSettings) Set(context.Context, string, string) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSettings) Delete(context.Context, string) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
// Compile-time proof that the stub keeps up with the interface.
|
||||
var _ storage.Database = (*Database)(nil)
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Runtime-mutable server settings (federation and registration switches).
|
||||
-- Values are stored as text; the settings service parses and caches them.
|
||||
CREATE TABLE IF NOT EXISTS server_settings (
|
||||
setting_key TEXT NOT NULL PRIMARY KEY,
|
||||
setting_value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package postgres implements the storage interfaces on PostgreSQL 15+ via
|
||||
// pgx's database/sql driver. This is the production backend and the one
|
||||
// Complement runs against.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" database/sql driver
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage/sqlstore"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func init() {
|
||||
storage.Register(storage.DriverPostgres, Open)
|
||||
}
|
||||
|
||||
var dialect = migrations.Dialect{
|
||||
Name: storage.DriverPostgres,
|
||||
CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL
|
||||
)`,
|
||||
SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
|
||||
InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, $4)`,
|
||||
}
|
||||
|
||||
var settingsQueries = sqlstore.SettingsQueries{
|
||||
Get: `SELECT setting_value FROM server_settings WHERE setting_key = $1`,
|
||||
GetAll: `SELECT setting_key, setting_value FROM server_settings`,
|
||||
Upsert: `INSERT INTO server_settings (setting_key, setting_value, updated_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at`,
|
||||
Delete: `DELETE FROM server_settings WHERE setting_key = $1`,
|
||||
}
|
||||
|
||||
// Database is the PostgreSQL implementation of storage.Database.
|
||||
type Database struct {
|
||||
*sqlstore.Store
|
||||
}
|
||||
|
||||
// Open connects to the DSN in cfg.ConnectionString, e.g.
|
||||
// "postgres://warp:secret@localhost/warp?sslmode=disable".
|
||||
func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) {
|
||||
if cfg.ConnectionString == "" {
|
||||
return nil, fmt.Errorf("postgres: connection_string must not be empty")
|
||||
}
|
||||
db, err := sql.Open("pgx", cfg.ConnectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("postgres: connect: %w", err)
|
||||
}
|
||||
|
||||
store, err := sqlstore.New(sqlstore.Options{
|
||||
DriverName: storage.DriverPostgres,
|
||||
DB: db,
|
||||
Dialect: dialect,
|
||||
MigrationsFS: migrationFiles,
|
||||
MigrationsDir: "migrations",
|
||||
Settings: settingsQueries,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Database{Store: store}, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Runtime-mutable server settings (federation and registration switches).
|
||||
-- Values are stored as text; the settings service parses and caches them.
|
||||
CREATE TABLE IF NOT EXISTS server_settings (
|
||||
setting_key TEXT NOT NULL PRIMARY KEY,
|
||||
setting_value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package sqlite implements the storage interfaces on modernc.org/sqlite,
|
||||
// a pure-Go SQLite with no cgo. It is the development and single-user
|
||||
// driver; PostgreSQL is the production one.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite" // registers the "sqlite" database/sql driver
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage/sqlstore"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func init() {
|
||||
storage.Register(storage.DriverSQLite, Open)
|
||||
}
|
||||
|
||||
// dialect is the migration bookkeeping for SQLite.
|
||||
var dialect = migrations.Dialect{
|
||||
Name: storage.DriverSQLite,
|
||||
CreateTable: `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMP NOT NULL
|
||||
)`,
|
||||
SelectApplied: `SELECT version, name, checksum, applied_at FROM schema_migrations ORDER BY version`,
|
||||
InsertApplied: `INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)`,
|
||||
}
|
||||
|
||||
var settingsQueries = sqlstore.SettingsQueries{
|
||||
Get: `SELECT setting_value FROM server_settings WHERE setting_key = ?`,
|
||||
GetAll: `SELECT setting_key, setting_value FROM server_settings`,
|
||||
Upsert: `INSERT INTO server_settings (setting_key, setting_value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT (setting_key) DO UPDATE SET setting_value = excluded.setting_value, updated_at = excluded.updated_at`,
|
||||
Delete: `DELETE FROM server_settings WHERE setting_key = ?`,
|
||||
}
|
||||
|
||||
// Database is the SQLite implementation of storage.Database.
|
||||
type Database struct {
|
||||
*sqlstore.Store
|
||||
}
|
||||
|
||||
// Open connects to the SQLite file named by cfg.ConnectionString.
|
||||
//
|
||||
// The connection string is a modernc.org/sqlite DSN: "file:warp.db" with
|
||||
// optional "_pragma=..." parameters. The parent directory is created if it
|
||||
// does not exist, because a missing directory is the single most common
|
||||
// first-run failure.
|
||||
//
|
||||
// Writes are serialised onto one connection: SQLite allows a single writer,
|
||||
// and letting database/sql open several write connections turns that into
|
||||
// SQLITE_BUSY under concurrency rather than a queue.
|
||||
func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) {
|
||||
dsn := cfg.ConnectionString
|
||||
if dsn == "" {
|
||||
return nil, fmt.Errorf("sqlite: connection_string must not be empty")
|
||||
}
|
||||
if err := ensureParentDir(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("sqlite: connect: %w", err)
|
||||
}
|
||||
|
||||
store, err := sqlstore.New(sqlstore.Options{
|
||||
DriverName: storage.DriverSQLite,
|
||||
DB: db,
|
||||
Dialect: dialect,
|
||||
MigrationsFS: migrationFiles,
|
||||
MigrationsDir: "migrations",
|
||||
Settings: settingsQueries,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Database{Store: store}, nil
|
||||
}
|
||||
|
||||
// ensureParentDir creates the directory holding the database file. In-memory
|
||||
// databases (":memory:" or "mode=memory") have no directory.
|
||||
func ensureParentDir(dsn string) error {
|
||||
path := filePathFromDSN(dsn)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "" || dir == "." {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("sqlite: create database directory %s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filePathFromDSN extracts the filesystem path from a SQLite DSN, or "" for
|
||||
// an in-memory database.
|
||||
func filePathFromDSN(dsn string) string {
|
||||
path := strings.TrimPrefix(dsn, "file:")
|
||||
if i := strings.IndexAny(path, "?"); i >= 0 {
|
||||
path = path[:i]
|
||||
}
|
||||
if unescaped, err := url.PathUnescape(path); err == nil {
|
||||
path = unescaped
|
||||
}
|
||||
if path == "" || path == ":memory:" || strings.Contains(dsn, "mode=memory") {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SettingsQueries are the four statements the settings store needs. Each
|
||||
// driver writes them in its own placeholder style and upsert syntax.
|
||||
type SettingsQueries struct {
|
||||
// Get binds (key) and selects setting_value.
|
||||
Get string
|
||||
|
||||
// GetAll selects (setting_key, setting_value).
|
||||
GetAll string
|
||||
|
||||
// Upsert binds (key, value, updated_at) and overwrites on conflict.
|
||||
Upsert string
|
||||
|
||||
// Delete binds (key).
|
||||
Delete string
|
||||
}
|
||||
|
||||
// Settings is the server_settings key/value store. It is the persistence
|
||||
// behind the runtime federation and registration switches; the in-memory
|
||||
// cache lives one layer up, in the settings service.
|
||||
type Settings struct {
|
||||
db *sql.DB
|
||||
q SettingsQueries
|
||||
}
|
||||
|
||||
// NewSettings builds a settings store.
|
||||
func NewSettings(db *sql.DB, q SettingsQueries) *Settings {
|
||||
return &Settings{db: db, q: q}
|
||||
}
|
||||
|
||||
// Get returns the stored value for key; the boolean reports presence.
|
||||
func (s *Settings) Get(ctx context.Context, key string) (string, bool, error) {
|
||||
var value string
|
||||
err := s.db.QueryRowContext(ctx, s.q.Get, key).Scan(&value)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return "", false, nil
|
||||
case err != nil:
|
||||
return "", false, fmt.Errorf("get setting %q: %w", key, err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// GetAll returns every stored setting.
|
||||
func (s *Settings) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q.GetAll)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list settings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, fmt.Errorf("scan setting: %w", err)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list settings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Set writes key, overwriting any previous value.
|
||||
func (s *Settings) Set(ctx context.Context, key, value string) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.q.Upsert, key, value, time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("set setting %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes key. Removing an absent key is not an error.
|
||||
func (s *Settings) Delete(ctx context.Context, key string) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.q.Delete, key); err != nil {
|
||||
return fmt.Errorf("delete setting %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package sqlstore holds the parts of a storage driver that are identical
|
||||
// for every database/sql backend: the migration plumbing and the
|
||||
// server_settings table. The sqlite and postgres drivers differ only in
|
||||
// their DSN handling, placeholder style and upsert syntax, all of which
|
||||
// they pass in here.
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage/migrations"
|
||||
)
|
||||
|
||||
// Options describe one database/sql backend.
|
||||
type Options struct {
|
||||
// DriverName is the Warp driver name, e.g. "sqlite".
|
||||
DriverName string
|
||||
|
||||
// DB is the already-opened pool.
|
||||
DB *sql.DB
|
||||
|
||||
// Dialect supplies the migration bookkeeping statements.
|
||||
Dialect migrations.Dialect
|
||||
|
||||
// MigrationsFS and MigrationsDir locate the embedded .sql files.
|
||||
MigrationsFS fs.FS
|
||||
MigrationsDir string
|
||||
|
||||
// Settings holds the statements for the server_settings table.
|
||||
Settings SettingsQueries
|
||||
|
||||
// Logger is used for migration progress.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Store is the shared implementation of storage.Database. Drivers embed it
|
||||
// and add their own component stores as those are built.
|
||||
type Store struct {
|
||||
opts Options
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
settings *Settings
|
||||
}
|
||||
|
||||
// New builds a Store. It does not touch the database.
|
||||
func New(opts Options) (*Store, error) {
|
||||
if opts.DB == nil {
|
||||
return nil, errors.New("sqlstore: DB must not be nil")
|
||||
}
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = slog.Default()
|
||||
}
|
||||
return &Store{
|
||||
opts: opts,
|
||||
db: opts.DB,
|
||||
log: opts.Logger,
|
||||
settings: NewSettings(opts.DB, opts.Settings),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DB exposes the pool for driver-specific code and tests.
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Driver implements storage.Database.
|
||||
func (s *Store) Driver() string { return s.opts.DriverName }
|
||||
|
||||
// Ping implements storage.Database.
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
// Close implements storage.Database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// Settings implements storage.Database.
|
||||
func (s *Store) Settings() storage.SettingsStore { return s.settings }
|
||||
|
||||
// Migrate implements storage.Database.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
ms, err := s.migrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applied, err := migrations.Status(ctx, s.db, s.opts.Dialect)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrations.CheckNotAhead(applied, ms); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := migrations.Run(ctx, s.db, s.opts.Dialect, ms, s.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
s.log.Debug("database schema is up to date", "migrations", len(ms))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingMigrations implements storage.Database.
|
||||
func (s *Store) PendingMigrations(ctx context.Context) ([]string, error) {
|
||||
ms, err := s.migrations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pending, err := migrations.Pending(ctx, s.db, s.opts.Dialect, ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(pending))
|
||||
for _, m := range pending {
|
||||
out = append(out, fmt.Sprintf("%04d_%s", m.Version, m.Name))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) migrations() ([]migrations.Migration, error) {
|
||||
return migrations.Load(s.opts.MigrationsFS, s.opts.MigrationsDir)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package storage defines the interfaces every Warp storage driver
|
||||
// implements, plus the registry that turns a config into a Database.
|
||||
//
|
||||
// Drivers live in sub-packages (sqlite, postgres, pocketbase) and register
|
||||
// themselves from an init function, the database/sql way. Nothing in this
|
||||
// package imports a driver, so components can depend on the interfaces
|
||||
// without pulling in every database engine.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Driver names. These are the values accepted by database.driver in the
|
||||
// config file.
|
||||
const (
|
||||
DriverSQLite = "sqlite"
|
||||
DriverPostgres = "postgres"
|
||||
DriverPocketBase = "pocketbase"
|
||||
)
|
||||
|
||||
// ErrNotImplemented is returned by a driver that is compiled in but not
|
||||
// finished — currently PocketBase.
|
||||
var ErrNotImplemented = errors.New("not implemented by this storage driver")
|
||||
|
||||
// ErrNoSuchSetting is returned by SettingsStore.Get for an unknown key.
|
||||
var ErrNoSuchSetting = errors.New("no such setting")
|
||||
|
||||
// Config is the subset of the file configuration a driver needs.
|
||||
type Config struct {
|
||||
Driver string
|
||||
ConnectionString string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
// Database is the handle every component gets. Component-specific stores
|
||||
// (userapi, roomserver, syncapi...) hang off it as they are implemented.
|
||||
type Database interface {
|
||||
// Driver reports which driver this is, for logs and admin output.
|
||||
Driver() string
|
||||
|
||||
// Ping checks the connection is alive; used by /healthz.
|
||||
Ping(ctx context.Context) error
|
||||
|
||||
// Migrate applies pending migrations.
|
||||
Migrate(ctx context.Context) error
|
||||
|
||||
// PendingMigrations lists migrations that Migrate would apply.
|
||||
PendingMigrations(ctx context.Context) ([]string, error)
|
||||
|
||||
// Settings is the server_settings key/value store backing the runtime
|
||||
// settings service (federation and registration switches).
|
||||
Settings() SettingsStore
|
||||
|
||||
// Close releases the connection pool.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SettingsStore persists the handful of settings that are mutable at
|
||||
// runtime through the admin API.
|
||||
type SettingsStore interface {
|
||||
// Get returns the raw value for key. The boolean reports presence.
|
||||
Get(ctx context.Context, key string) (string, bool, error)
|
||||
|
||||
// GetAll returns every setting.
|
||||
GetAll(ctx context.Context) (map[string]string, error)
|
||||
|
||||
// Set writes key, overwriting any previous value.
|
||||
Set(ctx context.Context, key, value string) error
|
||||
|
||||
// Delete removes key, reverting it to its configured default. Deleting
|
||||
// a key that is not there is not an error.
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// OpenFunc opens a database for a driver.
|
||||
type OpenFunc func(ctx context.Context, cfg Config, log *slog.Logger) (Database, error)
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = map[string]OpenFunc{}
|
||||
)
|
||||
|
||||
// Register makes a driver available to Open. It panics on a duplicate
|
||||
// registration, which can only be a programming error.
|
||||
func Register(name string, open OpenFunc) {
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, dup := registry[name]; dup {
|
||||
panic("storage: driver " + name + " registered twice")
|
||||
}
|
||||
registry[name] = open
|
||||
}
|
||||
|
||||
// Drivers lists the registered driver names, sorted.
|
||||
func Drivers() []string {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
return slices.Sorted(maps.Keys(registry))
|
||||
}
|
||||
|
||||
// Open connects using the driver named in cfg.
|
||||
func Open(ctx context.Context, cfg Config, log *slog.Logger) (Database, error) {
|
||||
registryMu.RLock()
|
||||
open, ok := registry[cfg.Driver]
|
||||
registryMu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("storage: unknown driver %q (compiled in: %v)", cfg.Driver, Drivers())
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
db, err := open(ctx, cfg, log.With("driver", cfg.Driver))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: open %s: %w", cfg.Driver, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
|
||||
)
|
||||
|
||||
// openTestDB opens each driver that can run without external services. The
|
||||
// postgres driver joins in when WARP_TEST_POSTGRES_DSN is set, which is how
|
||||
// CI runs the same tests against both engines.
|
||||
func openTestDB(t *testing.T, driver string) storage.Database {
|
||||
t.Helper()
|
||||
|
||||
cfg := storage.Config{Driver: driver, MaxOpenConns: 4, MaxIdleConns: 2}
|
||||
switch driver {
|
||||
case storage.DriverSQLite:
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
|
||||
cfg.ConnectionString = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
|
||||
case storage.DriverPostgres:
|
||||
dsn := os.Getenv("WARP_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set WARP_TEST_POSTGRES_DSN to test the postgres driver")
|
||||
}
|
||||
cfg.ConnectionString = dsn
|
||||
default:
|
||||
t.Fatalf("no test setup for driver %q", driver)
|
||||
}
|
||||
|
||||
db, err := storage.Open(t.Context(), cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open(%s): %v", driver, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
// testDrivers are run by every storage test.
|
||||
var testDrivers = []string{storage.DriverSQLite, storage.DriverPostgres}
|
||||
|
||||
func TestDriversRegistered(t *testing.T) {
|
||||
got := storage.Drivers()
|
||||
for _, want := range []string{storage.DriverSQLite, storage.DriverPostgres, storage.DriverPocketBase} {
|
||||
if !slices.Contains(got, want) {
|
||||
t.Errorf("driver %q is not registered (have %v)", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenUnknownDriver(t *testing.T) {
|
||||
_, err := storage.Open(t.Context(), storage.Config{Driver: "mysql"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unknown driver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPocketBaseIsNotImplemented(t *testing.T) {
|
||||
_, err := storage.Open(t.Context(), storage.Config{
|
||||
Driver: storage.DriverPocketBase,
|
||||
ConnectionString: "./pb_data",
|
||||
}, nil)
|
||||
if !errors.Is(err, storage.ErrNotImplemented) {
|
||||
t.Fatalf("expected ErrNotImplemented, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAndSettings(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
db := openTestDB(t, driver)
|
||||
|
||||
pending, err := db.PendingMigrations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingMigrations: %v", err)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
t.Skip("database already migrated by a previous run")
|
||||
}
|
||||
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
// Migrating twice must be a no-op, not an error: it runs on
|
||||
// every boot.
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
t.Fatalf("second Migrate: %v", err)
|
||||
}
|
||||
pending, err = db.PendingMigrations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("PendingMigrations: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Errorf("after Migrate, still pending: %v", pending)
|
||||
}
|
||||
|
||||
settings := db.Settings()
|
||||
if _, found, err := settings.Get(ctx, "federation.enabled"); err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
} else if found {
|
||||
t.Fatal("expected no stored value on a fresh database")
|
||||
}
|
||||
|
||||
if err := settings.Set(ctx, "federation.enabled", "true"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if err := settings.Set(ctx, "federation.enabled", "false"); err != nil {
|
||||
t.Fatalf("Set (overwrite): %v", err)
|
||||
}
|
||||
value, found, err := settings.Get(ctx, "federation.enabled")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Get after Set: value=%q found=%v err=%v", value, found, err)
|
||||
}
|
||||
if value != "false" {
|
||||
t.Errorf("value = %q, want %q (the upsert must overwrite)", value, "false")
|
||||
}
|
||||
|
||||
all, err := settings.GetAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAll: %v", err)
|
||||
}
|
||||
if all["federation.enabled"] != "false" {
|
||||
t.Errorf("GetAll = %v", all)
|
||||
}
|
||||
|
||||
if err := settings.Delete(ctx, "federation.enabled"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if err := settings.Delete(ctx, "federation.enabled"); err != nil {
|
||||
t.Errorf("deleting an absent key must not fail: %v", err)
|
||||
}
|
||||
if _, found, _ := settings.Get(ctx, "federation.enabled"); found {
|
||||
t.Error("value survived Delete")
|
||||
}
|
||||
|
||||
if err := db.Ping(ctx); err != nil {
|
||||
t.Errorf("Ping: %v", err)
|
||||
}
|
||||
if db.Driver() != driver {
|
||||
t.Errorf("Driver() = %q, want %q", db.Driver(), driver)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user