PocketBase as a standalone database server
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

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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-26 16:31:08 +01:00
1 parent 0994156705
commit a47e15cc1c
69 files changed
+8209 -374

No files matched your search

+66 -3
View File
@@ -6,6 +6,7 @@ import (
"crypto/subtle"
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
@@ -101,7 +102,7 @@ type Database struct {
Driver string `yaml:"driver" env:"WARP_DB_DRIVER"`
// ConnectionString is driver specific: a DSN for postgres, a file URI
// for sqlite, a directory for pocketbase.
// for sqlite, the server's URL for pocketbase.
ConnectionString string `yaml:"connection_string" env:"WARP_DB_CONNECTION_STRING"`
MaxOpenConns int `yaml:"max_open_conns" env:"WARP_DB_MAX_OPEN_CONNS"`
@@ -112,14 +113,28 @@ type Database struct {
// schema changes behind an explicit `warp migrate`.
AutoMigrate bool `yaml:"auto_migrate" env:"WARP_DB_AUTO_MIGRATE"`
// PocketBase configures what the pocketbase driver adds over sqlite.
// PocketBase configures the pocketbase driver: how it signs in to the
// server, and where that server keeps media.
PocketBase PocketBase `yaml:"pocketbase"`
}
// PocketBase configures the pocketbase storage driver.
type PocketBase struct {
// SuperuserEmail and SuperuserPassword are the PocketBase superuser
// Warp signs in as. Warp's collections are superuser-only, so nothing
// less can read or write them.
SuperuserEmail string `yaml:"superuser_email" env:"WARP_PB_SUPERUSER_EMAIL"`
SuperuserPassword string `yaml:"superuser_password" env:"WARP_PB_SUPERUSER_PASSWORD"`
// SuperuserFile names a file of WARP_PB_SUPERUSER_EMAIL=... and
// WARP_PB_SUPERUSER_PASSWORD=... lines, read for whichever of the two
// is not set otherwise. The all-in-one images keep the superuser they
// make up there, so that every warp command in the container finds it.
SuperuserFile string `yaml:"superuser_file" env:"WARP_PB_SUPERUSER_FILE"`
// S3 puts media content in an S3-compatible bucket through
// PocketBase's filesystem, instead of under the data directory.
// PocketBase's filesystem, instead of under its pb_data. Warp writes it
// into PocketBase's settings on every start.
S3 PocketBaseS3 `yaml:"s3"`
}
@@ -143,6 +158,9 @@ func (d Database) Storage() storage.Config {
MaxOpenConns: d.MaxOpenConns,
MaxIdleConns: d.MaxIdleConns,
ConnMaxLifetime: d.ConnMaxLifetime,
SuperuserEmail: d.PocketBase.SuperuserEmail,
SuperuserPassword: d.PocketBase.SuperuserPassword,
S3: storage.S3Config{
Enabled: s3.Enabled,
Bucket: s3.Bucket,
@@ -709,6 +727,14 @@ func (c *Config) Validate() error {
bad("tracing.endpoint: must be an http or https URL")
}
}
if c.Database.Driver == "pocketbase" {
if u, err := url.Parse(c.Database.ConnectionString); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
bad("database.connection_string: must be the PocketBase server's http or https URL for the pocketbase driver")
}
if c.Database.PocketBase.SuperuserEmail == "" || c.Database.PocketBase.SuperuserPassword == "" {
bad("database.pocketbase: superuser_email and superuser_password are required")
}
}
if c.Database.Driver == "pocketbase" && c.Database.PocketBase.S3.Enabled {
s3 := c.Database.PocketBase.S3
if s3.Bucket == "" || s3.Region == "" || s3.Endpoint == "" || s3.AccessKey == "" || s3.Secret == "" {
@@ -889,8 +915,45 @@ func Load(path string) (*Config, error) {
if err := ApplyEnv(cfg, os.Getenv); err != nil {
return nil, err
}
if err := cfg.Database.PocketBase.readSuperuserFile(); err != nil {
return nil, err
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration:\n%w", err)
}
return cfg, nil
}
// readSuperuserFile fills in the superuser from SuperuserFile, for whichever
// of the email and password is still empty. A file that does not exist yet
// is not an error: the all-in-one entrypoint writes it on first start, and
// a command run before that finds nothing either way.
func (p *PocketBase) readSuperuserFile() error {
if p.SuperuserFile == "" || (p.SuperuserEmail != "" && p.SuperuserPassword != "") {
return nil
}
data, err := os.ReadFile(p.SuperuserFile)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read database.pocketbase.superuser_file: %w", err)
}
for _, line := range strings.Split(string(data), "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch key {
case "WARP_PB_SUPERUSER_EMAIL":
if p.SuperuserEmail == "" {
p.SuperuserEmail = value
}
case "WARP_PB_SUPERUSER_PASSWORD":
if p.SuperuserPassword == "" {
p.SuperuserPassword = value
}
}
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestSuperuserFileFillsInWhatIsUnset(t *testing.T) {
path := filepath.Join(t.TempDir(), "superuser.env")
if err := os.WriteFile(path, []byte("WARP_PB_SUPERUSER_EMAIL=warp@warp.local\nWARP_PB_SUPERUSER_PASSWORD=from-the-file\n"), 0o600); err != nil {
t.Fatal(err)
}
p := PocketBase{SuperuserFile: path}
if err := p.readSuperuserFile(); err != nil {
t.Fatalf("readSuperuserFile: %v", err)
}
if p.SuperuserEmail != "warp@warp.local" || p.SuperuserPassword != "from-the-file" {
t.Errorf("read %q / %q", p.SuperuserEmail, p.SuperuserPassword)
}
// What the environment set wins.
p = PocketBase{SuperuserFile: path, SuperuserPassword: "from-the-environment"}
if err := p.readSuperuserFile(); err != nil {
t.Fatalf("readSuperuserFile: %v", err)
}
if p.SuperuserEmail != "warp@warp.local" || p.SuperuserPassword != "from-the-environment" {
t.Errorf("read %q / %q", p.SuperuserEmail, p.SuperuserPassword)
}
// A file not written yet is no error.
p = PocketBase{SuperuserFile: filepath.Join(t.TempDir(), "missing.env")}
if err := p.readSuperuserFile(); err != nil || p.SuperuserEmail != "" {
t.Errorf("missing file: %q, %v", p.SuperuserEmail, err)
}
}