Files
Warp/server/internal/storage/pocketbase/pocketbase.go
T
tajniak81andClaude Opus 5.5 a47e15cc1c
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
PocketBase as a standalone database server
The pocketbase driver no longer embeds PocketBase: Warp keeps its data as
PocketBase collections on a server of its own and reaches it only through
the REST API, the way the postgres driver reaches PostgreSQL.
database.connection_string is the server's URL, and Warp signs in as the
superuser in WARP_PB_SUPERUSER_EMAIL / WARP_PB_SUPERUSER_PASSWORD (or a
WARP_PB_SUPERUSER_FILE of the two).

Every SQL table is a superuser-only collection of the same name and
columns, created by warp migrate. Reads go through PocketBase's SQL
endpoint with the SQL drivers' queries, via a read-only database/sql
driver that inlines arguments, moves long ones into warp_params and pages
past 1000 rows. Writes are record operations in batch requests under one
write lock in the Warp process. Media is a protected file field in
warp_media, local or in the S3 bucket Warp writes into PocketBase's
settings. Search matches word prefixes with LIKE. The storage tests run
against an in-process PocketBase and pass as on SQLite.

Docker: the PocketBase stacks get a pocketbase service running
tajniak81/pocketbase-docker-root (WARP_PB_IMAGE), with a pb-data volume
and its admin UI on loopback; the AIO images run that PocketBase binary
as a process beside the homeserver, making up a superuser when none is
set. The .env examples, READMEs, docs and warp.yaml.example follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:31:08 +01:00

339 lines
10 KiB
Go

// Package pocketbase is the PocketBase storage driver: Warp's data in a
// standalone PocketBase server, as collections, reached only through
// PocketBase's REST API — the way the postgres driver reaches PostgreSQL
// over its wire protocol.
//
// Each of the SQL drivers' tables is a collection of the same name and
// columns (see schema.go), so a PocketBase admin sees Warp's rooms, events
// and accounts as records. The stores read them through the superuser SQL
// endpoint with the same queries the SQL drivers use (sqldriver.go), and
// write them as record operations in batch requests (batch.go), which
// PocketBase runs as one transaction each. Media content goes into a file
// field, which is PocketBase's own storage: its pb_data/storage, or an S3
// bucket.
package pocketbase
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
func init() {
storage.Register(storage.DriverPocketBase, Open)
}
// Database is the PocketBase implementation of storage.Database.
type Database struct {
*base
log *slog.Logger
s3 storage.S3Config
settings *Settings
users *Users
rooms *Rooms
sync *Sync
fed *Federation
media *Media
keys *Keys
push *Push
admin *Admin
apps *AppServices
delayed *DelayedEvents
openID *OpenIDTokens
blobs *blobStore
}
// Open connects to the PocketBase server at cfg.ConnectionString and signs
// in as the configured superuser.
//
// It configures the server for Warp before anything else — batch requests
// on, and the S3 bucket when one is configured — so a server that cannot
// be reached or refuses the account fails here, at startup.
func Open(ctx context.Context, cfg storage.Config, log *slog.Logger) (storage.Database, error) {
c, err := newClient(cfg.ConnectionString, cfg.SuperuserEmail, cfg.SuperuserPassword)
if err != nil {
return nil, err
}
lock := &sync.RWMutex{}
db := sql.OpenDB(&sqlConnector{c: c, lock: lock})
// The connections hold nothing, so the pool only bounds how many reads
// are in flight at once.
maxConns := cfg.MaxOpenConns
if maxConns <= 0 {
maxConns = 16
}
db.SetMaxOpenConns(maxConns)
b := &base{c: c, db: db, lock: lock}
d := &Database{
base: b,
log: log,
s3: cfg.S3,
settings: &Settings{base: b},
users: &Users{base: b},
rooms: &Rooms{base: b},
sync: &Sync{base: b},
fed: &Federation{base: b},
media: &Media{base: b},
keys: &Keys{base: b},
push: &Push{base: b},
admin: &Admin{base: b},
apps: &AppServices{base: b},
delayed: &DelayedEvents{base: b},
openID: &OpenIDTokens{base: b},
blobs: &blobStore{base: b},
}
if err := d.configure(ctx); err != nil {
_ = db.Close()
return nil, err
}
return d, nil
}
// configure applies the PocketBase settings Warp depends on.
//
// The S3 settings are written from Warp's configuration on every start
// rather than left to the PocketBase admin UI: Warp's config is the
// authority, and a second place for the bucket to be set would be a second
// place for it to disagree. A deployment without S3 in Warp's config leaves
// PocketBase's own setting as it is.
func (d *Database) configure(ctx context.Context) error {
settings := map[string]any{
"batch": map[string]any{
"enabled": true,
"maxRequests": maxBatch,
"timeout": 300,
"maxBodySize": 0,
},
}
if d.s3.Enabled {
settings["s3"] = map[string]any{
"enabled": true,
"bucket": d.s3.Bucket,
"region": d.s3.Region,
"endpoint": d.s3.Endpoint,
"accessKey": d.s3.AccessKey,
"secret": d.s3.Secret,
"forcePathStyle": d.s3.ForcePathStyle,
}
}
if err := d.c.patchSettings(ctx, settings); err != nil {
return fmt.Errorf("pocketbase: configure the server: %w", err)
}
return nil
}
// DB exposes the read-only SQL pool, for tests and diagnostics: queries run
// on the PocketBase server through its SQL endpoint, and anything else is
// refused.
func (d *Database) DB() *sql.DB { return d.db }
// Driver implements storage.Database.
func (d *Database) Driver() string { return storage.DriverPocketBase }
// Ping implements storage.Database.
func (d *Database) Ping(ctx context.Context) error { return d.db.PingContext(ctx) }
// Close implements storage.Database.
func (d *Database) Close() error { return d.db.Close() }
// Settings implements storage.Database.
func (d *Database) Settings() storage.SettingsStore { return d.settings }
// Users implements storage.Database.
func (d *Database) Users() storage.UserStore { return d.users }
// Rooms implements storage.Database.
func (d *Database) Rooms() storage.RoomStore { return d.rooms }
// Sync implements storage.Database.
func (d *Database) Sync() storage.SyncStore { return d.sync }
// Federation implements storage.Database.
func (d *Database) Federation() storage.FederationStore { return d.fed }
// Media implements storage.Database.
func (d *Database) Media() storage.MediaStore { return d.media }
// Keys implements storage.Database.
func (d *Database) Keys() storage.KeyStore { return d.keys }
// Push implements storage.Database.
func (d *Database) Push() storage.PushStore { return d.push }
// Admin implements storage.Database.
func (d *Database) Admin() storage.AdminStore { return d.admin }
// AppServices implements storage.Database.
func (d *Database) AppServices() storage.AppServiceStore { return d.apps }
// DelayedEvents implements storage.Database.
func (d *Database) DelayedEvents() storage.DelayedEventStore { return d.delayed }
// OpenIDTokens implements storage.Database.
func (d *Database) OpenIDTokens() storage.OpenIDTokenStore { return d.openID }
// Blobs implements storage.BlobProvider: media content goes into
// PocketBase's storage.
func (d *Database) Blobs() storage.BlobStore { return d.blobs }
// schemaMetaKey is the warp_meta row holding the schema version applied.
const schemaMetaKey = "schema"
// Migrate implements storage.Database.
//
// There are no numbered migrations: the schema is declared in schema.go
// and imported as it is, which PocketBase applies as a diff — a new
// collection is created, a new field added, an unchanged one left alone.
// The version stamped afterwards is a hash of the declaration, so any
// change to it is a pending migration.
func (d *Database) Migrate(ctx context.Context) error {
pending, err := d.PendingMigrations(ctx)
if err != nil {
return err
}
if len(pending) == 0 {
d.log.Debug("database schema is up to date", "version", schemaVersion)
return nil
}
collections := make([]map[string]any, len(schema))
for i := range schema {
collections[i] = schema[i].collectionJSON()
}
if err := d.c.importCollections(ctx, collections); err != nil {
return fmt.Errorf("pocketbase: import the schema: %w", err)
}
err = d.write(ctx, func(t *tx) error {
for table, names := range sequenceRows {
for _, name := range names {
found, err := t.exists(table, `name = ?`, name)
if err != nil {
return err
}
if !found {
t.put(table, rec{"name": name, "value": int64(0)})
}
}
}
// Arguments a crashed query left behind.
if _, err := t.delWhere(paramsCollection, `1 = 1`); err != nil {
return err
}
t.put("warp_meta", rec{"meta_key": schemaMetaKey, "meta_value": schemaVersion})
return nil
})
if err != nil {
return fmt.Errorf("pocketbase: initialise the schema: %w", err)
}
d.log.Info("applied the database schema", "version", schemaVersion)
return nil
}
// PendingMigrations implements storage.Database.
func (d *Database) PendingMigrations(ctx context.Context) ([]string, error) {
pending := []string{"pocketbase_schema_" + schemaVersion}
var n int
if err := d.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'warp_meta'`).Scan(&n); err != nil {
return nil, fmt.Errorf("pocketbase: read the schema version: %w", err)
}
if n == 0 {
return pending, nil
}
var version string
err := d.db.QueryRowContext(ctx,
`SELECT meta_value FROM warp_meta WHERE meta_key = ?`, schemaMetaKey).Scan(&version)
switch {
case errors.Is(err, sql.ErrNoRows):
return pending, nil
case err != nil:
return nil, fmt.Errorf("pocketbase: read the schema version: %w", err)
case version != schemaVersion:
return pending, nil
}
return nil, nil
}
// Settings is the server_settings store.
type Settings struct{ *base }
// Get implements storage.SettingsStore.
func (s *Settings) Get(ctx context.Context, key string) (string, bool, error) {
var value string
err := s.db.QueryRowContext(ctx,
`SELECT setting_value FROM server_settings WHERE setting_key = ?`, 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 implements storage.SettingsStore.
func (s *Settings) GetAll(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT setting_key, setting_value FROM server_settings`)
if err != nil {
return nil, fmt.Errorf("list settings: %w", err)
}
defer func() { _ = 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 implements storage.SettingsStore.
func (s *Settings) Set(ctx context.Context, key, value string) error {
err := s.write(ctx, func(t *tx) error {
t.put("server_settings", rec{
"setting_key": key,
"setting_value": value,
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
})
return nil
})
if err != nil {
return fmt.Errorf("set setting %q: %w", key, err)
}
return nil
}
// Delete implements storage.SettingsStore.
func (s *Settings) Delete(ctx context.Context, key string) error {
err := s.write(ctx, func(t *tx) error {
_, err := t.delKey("server_settings", key)
return err
})
if err != nil {
return fmt.Errorf("delete setting %q: %w", key, err)
}
return nil
}
var (
_ storage.Database = (*Database)(nil)
_ storage.BlobProvider = (*Database)(nil)
_ storage.SettingsStore = (*Settings)(nil)
)