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
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>
283 lines
8.0 KiB
Go
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
|
|
}
|