Files
Warp/server/internal/storage/pocketbase/relations.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

370 lines
11 KiB
Go

package pocketbase
import (
"context"
"fmt"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The relation and search half of the room store.
//
// Relations are the SQL store's queries. Search has no full-text engine to
// lean on — PocketBase does not expose FTS5 — so roomserver_event_search
// holds each event's searchable text as its words, lower-cased and padded
// with spaces, and a search matches every word of the term as the start of
// a word: what the SQLite driver's prefix query matches, short of folding
// diacritics. Every match ranks the same, so a ranked search comes back
// newest first.
// searchText is how an event's text is stored for searching: its words,
// lower-cased, each with a space before it and the whole with one after.
func searchText(text string) string {
words := storage.SearchWords(text)
if len(words) == 0 {
return ""
}
return " " + strings.Join(words, " ") + " "
}
// indexEvent adds an event that has just joined a timeline to the search
// index, if it has anything to find it by.
func (w *roomWrite) indexEvent(e storage.StoredEvent, ordering int64) {
body := searchText(e.SearchText)
if e.SearchKey == "" || body == "" {
return
}
w.put("roomserver_event_search", rec{
"stream_ordering": ordering,
"room_id": e.RoomID,
"event_id": e.EventID,
"search_key": e.SearchKey,
"sender": e.Sender,
"event_type": e.Type,
"body": body,
})
}
// Relations implements storage.RoomStore.
func (s *Rooms) Relations(ctx context.Context, req storage.RelationsRequest) ([]storage.StoredEvent, error) {
if req.Limit <= 0 || len(req.ParentIDs) == 0 {
return nil, nil
}
conditions := []string{
`e.room_id = ?`,
`e.relates_to_id IN ` + inList,
`NOT e.outlier`,
}
args := []any{req.RoomID, jsonList(req.ParentIDs)}
if req.RelType != "" {
conditions = append(conditions, `e.rel_type = ?`)
args = append(args, req.RelType)
} else {
conditions = append(conditions, `e.rel_type <> ''`)
}
if req.EventType != "" {
conditions = append(conditions, `e.event_type = ?`)
args = append(args, req.EventType)
}
if req.Low != 0 {
conditions = append(conditions, `e.stream_ordering > ?`)
args = append(args, req.Low)
}
if req.High != 0 {
conditions = append(conditions, `e.stream_ordering <= ?`)
args = append(args, req.High)
}
order := `ASC`
if req.Direction == storage.DirectionBackward {
order = `DESC`
}
args = append(args, req.Limit)
rows, err := s.db.QueryContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_events e
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY e.stream_ordering `+order+`
LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("read the relations of %v in %q: %w", req.ParentIDs, req.RoomID, err)
}
events, err := scanEvents(rows)
if err != nil {
return nil, fmt.Errorf("read the relations of %v in %q: %w", req.ParentIDs, req.RoomID, err)
}
return events, nil
}
// ChildEventIDs implements storage.RoomStore.
func (s *Rooms) ChildEventIDs(ctx context.Context, roomID string, parentIDs []string, relType, eventType string) ([]string, error) {
if len(parentIDs) == 0 {
return nil, nil
}
query := `SELECT event_id FROM roomserver_events
WHERE room_id = ? AND relates_to_id IN ` + inList + `
AND rel_type <> '' AND NOT outlier`
args := []any{roomID, jsonList(parentIDs)}
if relType != "" {
query += ` AND rel_type = ?`
args = append(args, relType)
}
if eventType != "" {
query += ` AND event_type = ?`
args = append(args, eventType)
}
query += ` ORDER BY stream_ordering`
ids, err := s.queryStrings(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read the children of %v in %q: %w", parentIDs, roomID, err)
}
return ids, nil
}
// RelationAggregates implements storage.RoomStore.
func (s *Rooms) RelationAggregates(ctx context.Context, roomID string, parentIDs []string, high int64) ([]storage.RelationAggregate, error) {
if len(parentIDs) == 0 {
return nil, nil
}
inner := `SELECT relates_to_id, rel_type, sender, COUNT(*) AS n, MAX(stream_ordering) AS latest
FROM roomserver_events
WHERE room_id = ? AND relates_to_id IN ` + inList + `
AND rel_type IN (?, ?, ?) AND NOT outlier`
args := []any{roomID, jsonList(parentIDs), storage.RelThread, storage.RelReplace, storage.RelReference}
if high != 0 {
inner += ` AND stream_ordering <= ?`
args = append(args, high)
}
inner += ` GROUP BY relates_to_id, rel_type, sender`
rows, err := s.db.QueryContext(ctx, `SELECT g.relates_to_id, g.rel_type, g.sender, g.n, g.latest, e.event_id
FROM (`+inner+`) g
JOIN roomserver_events e ON e.stream_ordering = g.latest`, args...)
if err != nil {
return nil, fmt.Errorf("summarise the relations of %d events in %q: %w", len(parentIDs), roomID, err)
}
defer func() { _ = rows.Close() }()
var out []storage.RelationAggregate
for rows.Next() {
var a storage.RelationAggregate
if err := rows.Scan(&a.ParentID, &a.RelType, &a.Sender, &a.Count, &a.LatestOrdering, &a.LatestEventID); err != nil {
return nil, fmt.Errorf("summarise the relations in %q: %w", roomID, err)
}
out = append(out, a)
}
return out, rows.Err()
}
// RelationEventIDs implements storage.RoomStore.
func (s *Rooms) RelationEventIDs(ctx context.Context, roomID string, parentIDs []string, relType string, high int64, limit int) (map[string][]string, error) {
if len(parentIDs) == 0 || limit <= 0 {
return nil, nil
}
query := `SELECT relates_to_id, event_id FROM roomserver_events
WHERE room_id = ? AND relates_to_id IN ` + inList + `
AND rel_type = ? AND NOT outlier`
args := []any{roomID, jsonList(parentIDs), relType}
if high != 0 {
query += ` AND stream_ordering <= ?`
args = append(args, high)
}
query += ` ORDER BY stream_ordering LIMIT ?`
args = append(args, limit*len(parentIDs))
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read the %s relations in %q: %w", relType, roomID, err)
}
defer func() { _ = rows.Close() }()
out := map[string][]string{}
for rows.Next() {
var parent, id string
if err := rows.Scan(&parent, &id); err != nil {
return nil, fmt.Errorf("read the %s relations in %q: %w", relType, roomID, err)
}
if len(out[parent]) < limit {
out[parent] = append(out[parent], id)
}
}
return out, rows.Err()
}
// Threads implements storage.RoomStore.
func (s *Rooms) Threads(ctx context.Context, req storage.ThreadsRequest) ([]storage.Thread, error) {
if req.Limit <= 0 {
return nil, nil
}
inner := `SELECT relates_to_id AS root, MAX(stream_ordering) AS latest
FROM roomserver_events
WHERE room_id = ? AND rel_type = ? AND NOT outlier`
args := []any{req.RoomID, storage.RelThread}
if req.High != 0 {
inner += ` AND stream_ordering <= ?`
args = append(args, req.High)
}
inner += ` GROUP BY relates_to_id`
conditions := []string{`r.room_id = ?`, `NOT r.outlier`}
args = append(args, req.RoomID)
if req.Before != 0 {
conditions = append(conditions, `g.latest < ?`)
args = append(args, req.Before)
}
if req.ParticipatedBy != "" {
conditions = append(conditions, `(r.sender = ? OR EXISTS (
SELECT 1 FROM roomserver_events p
WHERE p.room_id = r.room_id AND p.relates_to_id = g.root AND p.rel_type = ? AND p.sender = ?))`)
args = append(args, req.ParticipatedBy, storage.RelThread, req.ParticipatedBy)
}
args = append(args, req.Limit)
rows, err := s.db.QueryContext(ctx, `SELECT g.root, g.latest
FROM (`+inner+`) g
JOIN roomserver_events r ON r.event_id = g.root
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY g.latest DESC
LIMIT ?`, args...)
if err != nil {
return nil, fmt.Errorf("read the threads of %q: %w", req.RoomID, err)
}
defer func() { _ = rows.Close() }()
var out []storage.Thread
for rows.Next() {
var t storage.Thread
if err := rows.Scan(&t.RootID, &t.LatestOrdering); err != nil {
return nil, fmt.Errorf("read the threads of %q: %w", req.RoomID, err)
}
out = append(out, t)
}
return out, rows.Err()
}
// searchKeys are the fields the index holds.
var searchKeys = []string{"content.body", "content.name", "content.topic"}
// Search implements storage.RoomStore.
func (s *Rooms) Search(ctx context.Context, req storage.SearchRequest) ([]storage.SearchResult, int, error) {
if req.Limit <= 0 || len(req.Rooms) == 0 {
return nil, 0, nil
}
words := storage.SearchWords(req.Term)
if len(words) == 0 {
return nil, 0, nil
}
var (
conditions []string
args []any
)
for _, w := range words {
conditions = append(conditions, `body LIKE ? ESCAPE '\'`)
args = append(args, `% `+escapeLike(w)+`%`)
}
// One bounded clause per room: what a user may search of a room is a
// window of its stream, and the windows differ from room to room.
rooms := make([]string, 0, len(req.Rooms))
for _, r := range req.Rooms {
clause := `room_id = ?`
args = append(args, r.RoomID)
if r.Low != 0 {
clause += ` AND stream_ordering > ?`
args = append(args, r.Low)
}
if r.High != 0 {
clause += ` AND stream_ordering <= ?`
args = append(args, r.High)
}
rooms = append(rooms, `(`+clause+`)`)
}
conditions = append(conditions, `(`+strings.Join(rooms, ` OR `)+`)`)
keys := req.Keys
if len(keys) == 0 {
keys = searchKeys
}
conditions = append(conditions, `search_key IN `+inList)
args = append(args, jsonList(keys))
if clause, filterArgs := filterConditionsOn(`event_type`, `sender`, req.Filter); clause != "" {
conditions = append(conditions, clause)
args = append(args, filterArgs...)
}
where := strings.Join(conditions, " AND ")
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM roomserver_event_search WHERE `+where, args...).
Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count search results: %w", err)
}
pageWhere := where
pageArgs := append([]any{}, args...)
if req.Order == storage.SearchByRecent && req.Before != 0 {
pageWhere += ` AND stream_ordering < ?`
pageArgs = append(pageArgs, req.Before)
}
offset := req.Offset
if req.Order == storage.SearchByRecent {
offset = 0
}
pageArgs = append(pageArgs, req.Limit, offset)
positions, err := s.queryInts(ctx, `SELECT stream_ordering FROM roomserver_event_search
WHERE `+pageWhere+`
ORDER BY stream_ordering DESC
LIMIT ? OFFSET ?`, pageArgs...)
if err != nil {
return nil, 0, fmt.Errorf("search: %w", err)
}
if len(positions) == 0 {
return nil, total, nil
}
eventRows, err := s.db.QueryContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_events e WHERE e.stream_ordering IN `+inList, jsonInts(positions))
if err != nil {
return nil, 0, fmt.Errorf("read search results: %w", err)
}
events, err := scanEvents(eventRows)
if err != nil {
return nil, 0, fmt.Errorf("read search results: %w", err)
}
byPos := make(map[int64]storage.StoredEvent, len(events))
for _, e := range events {
byPos[e.StreamOrdering] = e
}
out := make([]storage.SearchResult, 0, len(positions))
for _, pos := range positions {
if e, ok := byPos[pos]; ok {
out = append(out, storage.SearchResult{Event: e, Rank: 1})
}
}
return out, total, nil
}
// queryInts runs a query returning one integer column.
func (b *base) queryInts(ctx context.Context, query string, args ...any) ([]int64, error) {
rows, err := b.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var out []int64
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}