Files
Warp/server/internal/storage/pocketbase/batch.go
T
tajniak81andClaude Opus 5.5 a47e15cc1c
CI / Test (ubuntu-latest) (push) Waiting to run
CI / Test (windows-latest) (push) Waiting to run
CI / Storage tests on PostgreSQL (push) Waiting to run
CI / Complement (federation off) (push) Waiting to run
CI / golangci-lint (push) Waiting to run
CI / Smoke test the binary (push) Waiting to run
CI / Admin panel (push) Waiting to run
CI / Web client (push) Waiting to run
CI / Desktop client (ubuntu-24.04) (push) Waiting to run
CI / Desktop client (windows-latest) (push) Waiting to run
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

283 lines
8.0 KiB
Go

package pocketbase
import (
"context"
"database/sql"
"fmt"
"strings"
"sync"
)
// base is what every store shares: the client, the read-only SQL pool and
// the write lock.
//
// Writes are serialised by the lock, in this process, the way the sqlite
// driver serialises them onto one connection: PocketBase is SQLite and takes
// one writer at a time anyway, and holding the lock across a store call's
// reads and its batch is what makes "read, decide, write" safe without a
// transaction that spans requests. It does mean one Warp server per
// PocketBase.
type base struct {
c *client
db *sql.DB
lock *sync.RWMutex
}
// rec is one row's columns, by name.
type rec map[string]any
// maxBatch is how many operations go into one batch request. PocketBase is
// told to allow this many (see configure); a store call that writes more is
// split, and each part is atomic on its own.
const maxBatch = 1000
// tx collects the writes of one store call.
type tx struct {
ctx context.Context
b *base
// seqs are the counters this call has read or advanced, by table and
// name, and seqOrder the order they were first touched in.
seqs map[string]int64
seqOrder []string
ops []batchRequest
deleted map[string]bool
}
// write runs fn with the write lock held and sends what it collected.
//
// The counters it advanced are written first, in the first batch, so that a
// call split over several batches that fails part-way leaves a gap in a
// stream — which every reader already copes with — and never a position
// handed out twice.
func (b *base) write(ctx context.Context, fn func(*tx) error) error {
b.lock.Lock()
defer b.lock.Unlock()
t := &tx{ctx: withWriteLock(ctx), b: b, seqs: map[string]int64{}, deleted: map[string]bool{}}
if err := fn(t); err != nil {
return err
}
return t.commit()
}
// read runs fn with the read lock held, so that its queries see one state:
// no write lands between them. The SQL drivers get that from a
// transaction; here it is the lock, since every write takes it.
func (b *base) read(ctx context.Context, fn func(ctx context.Context) error) error {
b.lock.RLock()
defer b.lock.RUnlock()
return fn(withWriteLock(ctx))
}
func (t *tx) commit() error {
ops := make([]batchRequest, 0, len(t.seqOrder)+len(t.ops))
for _, key := range t.seqOrder {
table, name, _ := strings.Cut(key, "\x00")
ops = append(ops, putRequest(table, rec{"name": name, "value": t.seqs[key]}))
}
ops = append(ops, t.ops...)
for len(ops) > 0 {
n := min(len(ops), maxBatch)
if err := t.b.c.batch(t.ctx, ops[:n]); err != nil {
return err
}
ops = ops[n:]
}
return nil
}
// putRequest is an upsert of one row: created if its key is new, otherwise
// updated in the columns given and left alone in the rest.
func putRequest(collection string, row rec) batchRequest {
tb, ok := tables[collection]
if !ok {
panic("pocketbase: no collection " + collection)
}
key := make([]any, len(tb.key))
for i, col := range tb.key {
v, ok := row[col]
if !ok {
panic(fmt.Sprintf("pocketbase: a %s row without its key column %s", collection, col))
}
key[i] = v
}
return patchRequest(collection, rowID(collection, key...), row)
}
// patchRequest is an upsert of a row whose id is already known.
func patchRequest(collection, id string, row rec) batchRequest {
body := make(map[string]any, len(row)+1)
for col, v := range row {
body[col] = jsonValue(v)
}
body["id"] = id
return batchRequest{Method: "PUT", URL: "/api/collections/" + collection + "/records", Body: body}
}
// jsonValue turns a column value into what the record API takes.
func jsonValue(v any) any {
switch v := v.(type) {
case []byte:
return string(v)
case int:
return int64(v)
case int32:
return int64(v)
default:
return v
}
}
// put upserts a row; see putRequest.
func (t *tx) put(collection string, row rec) {
req := putRequest(collection, row)
delete(t.deleted, collection+"/"+req.Body["id"].(string))
t.ops = append(t.ops, req)
}
// patch upserts columns of the row with this id.
func (t *tx) patch(collection, id string, row rec) {
delete(t.deleted, collection+"/"+id)
t.ops = append(t.ops, patchRequest(collection, id, row))
}
// del deletes a row that exists. Deleting one that does not fails the whole
// batch, so callers only delete what they have just read.
func (t *tx) del(collection, id string) bool {
key := collection + "/" + id
if t.deleted[key] {
return false
}
t.deleted[key] = true
t.ops = append(t.ops, batchRequest{Method: "DELETE", URL: "/api/collections/" + collection + "/records/" + id})
return true
}
// delKey deletes the row with this primary key, if it exists.
func (t *tx) delKey(collection string, key ...any) (bool, error) {
id := rowID(collection, key...)
found, err := t.exists(collection, `id = ?`, id)
if err != nil || !found {
return false, err
}
return t.del(collection, id), nil
}
// ids reads the ids of the rows matching a condition.
func (t *tx) ids(collection, where string, args ...any) ([]string, error) {
rows, err := t.b.db.QueryContext(t.ctx, `SELECT id FROM `+collection+` WHERE `+where, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// delWhere deletes the rows matching a condition and reports how many there
// were.
func (t *tx) delWhere(collection, where string, args ...any) (int, error) {
ids, err := t.ids(collection, where, args...)
if err != nil {
return 0, fmt.Errorf("find the %s rows to delete: %w", collection, err)
}
n := 0
for _, id := range ids {
if t.del(collection, id) {
n++
}
}
return n, nil
}
// setWhere updates columns of the rows matching a condition and reports how
// many there were.
func (t *tx) setWhere(collection string, row rec, where string, args ...any) (int, error) {
ids, err := t.ids(collection, where, args...)
if err != nil {
return 0, fmt.Errorf("find the %s rows to update: %w", collection, err)
}
n := 0
for _, id := range ids {
if t.deleted[collection+"/"+id] {
continue
}
t.patch(collection, id, row)
n++
}
return n, nil
}
// exists reports whether a row matches a condition.
func (t *tx) exists(collection, where string, args ...any) (bool, error) {
var one int
err := t.b.db.QueryRowContext(t.ctx, `SELECT 1 FROM `+collection+` WHERE `+where+` LIMIT 1`, args...).Scan(&one)
switch {
case err == sql.ErrNoRows:
return false, nil
case err != nil:
return false, err
}
return true, nil
}
// queryRow reads one row inside the call.
func (t *tx) queryRow(query string, args ...any) *sql.Row {
return t.b.db.QueryRowContext(t.ctx, query, args...)
}
// query reads rows inside the call.
func (t *tx) query(query string, args ...any) (*sql.Rows, error) {
return t.b.db.QueryContext(t.ctx, query, args...)
}
// next hands out the next value of a counter.
func (t *tx) next(table, name string) (int64, error) {
return t.advance(table, name, 1)
}
// advance moves a counter by n and returns the last value of the block, as
// the SQL drivers' advanceSequence does. n may be negative.
func (t *tx) advance(table, name string, n int64) (int64, error) {
cur, err := t.current(table, name)
if err != nil {
return 0, err
}
cur += n
t.seqs[table+"\x00"+name] = cur
return cur, nil
}
// current reads a counter as this call has left it.
func (t *tx) current(table, name string) (int64, error) {
key := table + "\x00" + name
if v, ok := t.seqs[key]; ok {
return v, nil
}
var v int64
if err := t.queryRow(`SELECT value FROM `+table+` WHERE name = ?`, name).Scan(&v); err != nil {
return 0, fmt.Errorf("read the %s counter: %w", name, err)
}
t.seqs[key] = v
t.seqOrder = append(t.seqOrder, key)
return v, nil
}
// currentSequence reads a counter outside any write.
func (b *base) currentSequence(ctx context.Context, table, name string) (int64, error) {
var v int64
if err := b.db.QueryRowContext(ctx, `SELECT value FROM `+table+` WHERE name = ?`, name).Scan(&v); err != nil {
return 0, fmt.Errorf("read the %s counter: %w", name, err)
}
return v, nil
}