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

1150 lines
37 KiB
Go

package pocketbase
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"slices"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Rooms is the PocketBase implementation of storage.RoomStore.
//
// The reads are the SQL store's, with the event's nullable state_key read
// through has_state_key. A write is the SQL store's transaction turned into
// one batch, with the one difference that a batch cannot read what it has
// not yet written: whatever the SQL store would read back inside its
// transaction — the member count after a state change, a snapshot or an
// event written earlier in the same call — is tracked here, in the call.
type Rooms struct{ *base }
const (
defaultRoomPageSize = 100
maxRoomPageSize = 1000
)
// memberEventType is the one event type the store has to recognise.
const memberEventType = "m.room.member"
// CreateRoom implements storage.RoomStore.
func (s *Rooms) CreateRoom(ctx context.Context, room storage.NewRoom) error {
err := s.write(ctx, func(t *tx) error {
taken, err := t.exists("roomserver_rooms", `room_id = ?`, room.ID)
if err != nil {
return err
}
if taken {
return storage.ErrRoomExists
}
t.put("roomserver_rooms", rec{
"room_id": room.ID,
"room_version": room.Version,
"creator": room.Creator,
"is_published": room.Published,
"created_at_ms": room.CreatedAtMS,
"name": "",
"topic": "",
"avatar_url": "",
"canonical_alias": "",
"join_rule": "",
"history_visibility": "",
"guest_access": "",
"room_type": "",
"joined_members": int64(0),
})
return nil
})
if err != nil {
return fmt.Errorf("create room %q: %w", room.ID, err)
}
return nil
}
// Room implements storage.RoomStore.
func (s *Rooms) Room(ctx context.Context, roomID string) (storage.Room, error) {
var room storage.Room
err := s.db.QueryRowContext(ctx, `SELECT room_id, room_version, creator, is_published, created_at_ms
FROM roomserver_rooms WHERE room_id = ?`, roomID).Scan(
&room.ID, &room.Version, &room.Creator, &room.Published, &room.CreatedAtMS)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.Room{}, fmt.Errorf("room %q: %w", roomID, storage.ErrNoSuchRoom)
case err != nil:
return storage.Room{}, fmt.Errorf("read room %q: %w", roomID, err)
}
return room, nil
}
// roomWrite is one write call's view of the room tables: what it has
// written so far, which a later step of the same call has to see.
type roomWrite struct {
*tx
// held are the events the call knows about, read or written: whether
// each is an outlier, and whether it has been redacted.
held map[string]heldEvent
// snapshots are the state snapshots by room and hash.
snapshots map[string]int64
}
type heldEvent struct {
outlier bool
redacted bool
}
func newRoomWrite(t *tx) *roomWrite {
return &roomWrite{tx: t, held: map[string]heldEvent{}, snapshots: map[string]int64{}}
}
// loadHeld reads which of these events are already stored, in one query.
func (w *roomWrite) loadHeld(eventIDs []string) error {
var unknown []string
for _, id := range eventIDs {
if _, ok := w.held[id]; !ok {
unknown = append(unknown, id)
}
}
if len(unknown) == 0 {
return nil
}
rows, err := w.query(`SELECT event_id, outlier, redacted_by FROM roomserver_events
WHERE event_id IN `+inList, jsonList(unknown))
if err != nil {
return fmt.Errorf("look up events: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id string
outlier bool
redactedBy string
)
if err := rows.Scan(&id, &outlier, &redactedBy); err != nil {
return fmt.Errorf("look up events: %w", err)
}
w.held[id] = heldEvent{outlier: outlier, redacted: redactedBy != ""}
}
return rows.Err()
}
// Write implements storage.RoomStore.
func (s *Rooms) Write(ctx context.Context, rw storage.RoomWrite) (int64, error) {
var ordering int64
err := s.write(ctx, func(t *tx) error {
w := newRoomWrite(t)
var err error
if ordering, err = t.next(roomSequences, seqStreamOrdering); err != nil {
return err
}
var snapshotID int64
if !rw.Outlier {
if snapshotID, err = w.stateSnapshot(rw.Event.RoomID, rw.StateBefore); err != nil {
return err
}
}
if err := w.loadHeld([]string{rw.Event.EventID}); err != nil {
return err
}
w.insertEvent(rw.Event, ordering, snapshotID, rw.Outlier)
if rw.Event.Type == memberEventType && rw.Event.StateKey != nil {
if err := w.replaceInviteState(rw.Event.RoomID, *rw.Event.StateKey, rw.InviteState); err != nil {
return err
}
}
if rw.Redacts != "" {
if err := w.applyRedaction(rw.Event.RoomID, rw.Redacts, rw.Event.EventID, rw.RedactedJSON); err != nil {
return err
}
}
if err := w.applyStateDelta(rw); err != nil {
return err
}
if err := w.replaceExtremities(rw.Event.RoomID, rw.Extremities); err != nil {
return err
}
if rw.Profile != nil {
p := rw.Profile
if _, err := t.setWhere("roomserver_rooms", rec{
"name": p.Name, "topic": p.Topic, "avatar_url": p.AvatarURL, "canonical_alias": p.CanonicalAlias,
"join_rule": p.JoinRule, "history_visibility": p.HistoryVisibility,
"guest_access": p.GuestAccess, "room_type": p.RoomType,
}, `room_id = ?`, rw.Event.RoomID); err != nil {
return fmt.Errorf("update room profile: %w", err)
}
}
// The member count is recomputed whenever current state moved,
// because that is the only thing that can change it.
if len(rw.StateDelta) > 0 || rw.StateDeltaIsWholeState {
if err := w.recountMembers(rw); err != nil {
return err
}
}
if rw.Transaction != nil {
ref := *rw.Transaction
found, err := t.exists("roomserver_transactions", `localpart = ? AND device_id = ? AND txn_id = ?`,
ref.Localpart, ref.DeviceID, ref.TxnID)
if err != nil {
return fmt.Errorf("record transaction %q: %w", ref.TxnID, err)
}
if !found {
t.put("roomserver_transactions", rec{
"localpart": ref.Localpart, "device_id": ref.DeviceID, "txn_id": ref.TxnID,
"event_id": rw.Event.EventID, "room_id": rw.Event.RoomID, "sent_at_ms": rw.Event.OriginServerTS,
})
}
}
return nil
})
if err != nil {
return 0, fmt.Errorf("write event %s: %w", rw.Event.EventID, err)
}
return ordering, nil
}
// stateSnapshot stores a state set, re-using the row of an identical one.
// An empty state set is snapshot 0 and stores nothing.
func (w *roomWrite) stateSnapshot(roomID string, entries []storage.StateEntry) (int64, error) {
if len(entries) == 0 {
return 0, nil
}
hash := stateHash(entries)
if id, ok := w.snapshots[roomID+"\x00"+hash]; ok {
return id, nil
}
var id int64
err := w.queryRow(`SELECT snapshot_id FROM roomserver_state_snapshots
WHERE room_id = ? AND state_hash = ?`, roomID, hash).Scan(&id)
switch {
case err == nil:
w.snapshots[roomID+"\x00"+hash] = id
return id, nil
case !errors.Is(err, sql.ErrNoRows):
return 0, fmt.Errorf("look up state snapshot: %w", err)
}
if id, err = w.next(roomSequences, seqStateSnapshot); err != nil {
return 0, err
}
w.put("roomserver_state_snapshots", rec{"snapshot_id": id, "room_id": roomID, "state_hash": hash})
for _, e := range entries {
w.put("roomserver_state_entries", rec{
"snapshot_id": id, "event_type": e.Type, "state_key": e.StateKey, "event_id": e.EventID,
})
}
w.snapshots[roomID+"\x00"+hash] = id
return id, nil
}
// stateHash identifies a state set by its contents, as the SQL store does.
func stateHash(entries []storage.StateEntry) string {
sorted := slices.Clone(entries)
slices.SortFunc(sorted, func(a, b storage.StateEntry) int {
if c := strings.Compare(a.Type, b.Type); c != 0 {
return c
}
return strings.Compare(a.StateKey, b.StateKey)
})
h := sha256.New()
for _, e := range sorted {
for _, field := range [...]string{e.Type, e.StateKey, e.EventID} {
_, _ = fmt.Fprintf(h, "%d:%s", len(field), field)
}
}
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// eventRow is the roomserver_events row of an event.
func eventRow(e storage.StoredEvent, ordering, snapshotID int64, outlier bool) rec {
stateKey, hasStateKey := "", false
if e.StateKey != nil {
stateKey, hasStateKey = *e.StateKey, true
}
return rec{
"event_id": e.EventID,
"room_id": e.RoomID,
"stream_ordering": ordering,
"event_type": e.Type,
"state_key": stateKey,
"has_state_key": hasStateKey,
"sender": e.Sender,
"depth": e.Depth,
"origin_server_ts": e.OriginServerTS,
"event_json": string(e.JSON),
"state_before_id": snapshotID,
"redacted_by": "",
"outlier": outlier,
"relates_to_id": e.RelatesTo,
"rel_type": e.RelType,
}
}
// insertEvent writes the event row; loadHeld must have been called for it.
//
// An event already held is not written twice, except that an outlier
// arriving as an ordinary event is promoted in place: it gets the new stream
// position and state snapshot, and joins the timeline, with its stored JSON
// left as it was.
func (w *roomWrite) insertEvent(e storage.StoredEvent, ordering, snapshotID int64, outlier bool) {
if held, ok := w.held[e.EventID]; ok {
if held.outlier && !outlier {
w.promote(e, ordering, snapshotID, held.redacted)
}
return
}
w.put("roomserver_events", eventRow(e, ordering, snapshotID, outlier))
w.held[e.EventID] = heldEvent{outlier: outlier}
if !outlier {
w.indexEvent(e, ordering)
}
}
// promote moves an outlier into the timeline. It joins the search index
// too, unless the copy held was redacted, whose text is gone.
func (w *roomWrite) promote(e storage.StoredEvent, ordering, snapshotID int64, redacted bool) {
w.patch("roomserver_events", rowID("roomserver_events", e.EventID), rec{
"stream_ordering": ordering,
"state_before_id": snapshotID,
"outlier": false,
})
w.held[e.EventID] = heldEvent{outlier: false, redacted: redacted}
if !redacted {
w.indexEvent(e, ordering)
}
}
// replaceInviteState clears a user's stored invite state and, for an invite
// that came with some, stores the new one.
func (w *roomWrite) replaceInviteState(roomID, userID string, state []byte) error {
if len(state) > 0 {
w.put("roomserver_invite_state", rec{"room_id": roomID, "user_id": userID, "state_json": string(state)})
return nil
}
if _, err := w.delKey("roomserver_invite_state", roomID, userID); err != nil {
return fmt.Errorf("clear invite state: %w", err)
}
return nil
}
// InviteState implements storage.RoomStore.
func (s *Rooms) InviteState(ctx context.Context, roomID, userID string) ([]byte, error) {
var state string
err := s.db.QueryRowContext(ctx, `SELECT state_json FROM roomserver_invite_state
WHERE room_id = ? AND user_id = ?`, roomID, userID).Scan(&state)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, fmt.Errorf("read the invite state of %q in %q: %w", userID, roomID, err)
}
return []byte(state), nil
}
// WriteOutliers implements storage.RoomStore.
//
// Which of the events are already held is asked once for the whole batch,
// and the stream positions are taken as one block.
func (s *Rooms) WriteOutliers(ctx context.Context, roomID string, events []storage.StoredEvent) error {
if len(events) == 0 {
return nil
}
err := s.write(ctx, func(t *tx) error {
w := newRoomWrite(t)
top, err := t.advance(roomSequences, seqStreamOrdering, int64(len(events)))
if err != nil {
return err
}
ids := make([]string, len(events))
for i, e := range events {
ids[i] = e.EventID
}
if err := w.loadHeld(ids); err != nil {
return err
}
first := top - int64(len(events)) + 1
for i, e := range events {
if e.RoomID == "" {
e.RoomID = roomID
}
w.insertEvent(e, first+int64(i), 0, true)
}
return nil
})
if err != nil {
return fmt.Errorf("write %d outliers to %q: %w", len(events), roomID, err)
}
return nil
}
// WriteBackfill implements storage.RoomStore.
//
// The positions come from their own counter, advanced downwards by the
// number of events actually written, the batch taking the block oldest
// first.
func (s *Rooms) WriteBackfill(ctx context.Context, roomID string, events []storage.BackfillEvent) error {
if len(events) == 0 {
return nil
}
err := s.write(ctx, func(t *tx) error {
w := newRoomWrite(t)
ids := make([]string, len(events))
for i, b := range events {
ids[i] = b.Event.EventID
}
if err := w.loadHeld(ids); err != nil {
return err
}
// Which of the batch is new, which is an outlier to promote, and
// which is already in the timeline and left alone.
var write []storage.BackfillEvent
seen := map[string]bool{}
for _, b := range events {
if seen[b.Event.EventID] {
continue
}
seen[b.Event.EventID] = true
if held, ok := w.held[b.Event.EventID]; !ok || held.outlier {
write = append(write, b)
}
}
if len(write) == 0 {
return nil
}
bottom, err := t.advance(roomSequences, seqBackfillOrdering, -int64(len(write)))
if err != nil {
return err
}
for i, b := range write {
e := b.Event
if e.RoomID == "" {
e.RoomID = roomID
}
ordering := bottom + int64(i)
snapshotID, err := w.stateSnapshot(e.RoomID, b.StateBefore)
if err != nil {
return err
}
if held, ok := w.held[e.EventID]; ok {
w.promote(e, ordering, snapshotID, held.redacted)
continue
}
w.insertEvent(e, ordering, snapshotID, false)
}
return nil
})
if err != nil {
return fmt.Errorf("backfill %d events into %q: %w", len(events), roomID, err)
}
return nil
}
// applyRedaction replaces a stored event with its redacted form. A second
// redaction of the same event is a no-op.
func (w *roomWrite) applyRedaction(roomID, targetID, redactionID string, redacted []byte) error {
n, err := w.setWhere("roomserver_events", rec{
"event_json": string(redacted),
"redacted_by": redactionID,
"relates_to_id": "",
"rel_type": "",
}, `room_id = ? AND event_id = ? AND redacted_by = ''`, roomID, targetID)
if err != nil {
return fmt.Errorf("apply redaction to %s: %w", targetID, err)
}
if n > 0 {
if held, ok := w.held[targetID]; ok {
held.redacted = true
w.held[targetID] = held
}
}
// What a redacted event said is gone, and so is its way of being found
// by what it said.
if _, err := w.delWhere("roomserver_event_search", `room_id = ? AND event_id = ?`, roomID, targetID); err != nil {
return fmt.Errorf("remove event %s from the search index: %w", targetID, err)
}
return nil
}
// applyStateDelta moves the room's current state. A whole-state write
// clears the room's rows first, because tuples the resolution dropped have
// to go.
func (w *roomWrite) applyStateDelta(rw storage.RoomWrite) error {
if len(rw.StateDelta) == 0 && !rw.StateDeltaIsWholeState {
return nil
}
if rw.StateDeltaIsWholeState {
if _, err := w.delWhere("roomserver_current_state", `room_id = ?`, rw.Event.RoomID); err != nil {
return fmt.Errorf("clear current state: %w", err)
}
}
for _, e := range rw.StateDelta {
w.put("roomserver_current_state", rec{
"room_id": rw.Event.RoomID, "event_type": e.Type, "state_key": e.StateKey,
"event_id": e.EventID, "membership": e.Membership,
})
}
return nil
}
// recountMembers sets the room's joined member count from its current state
// as this write leaves it: the stored joins, with the delta applied.
func (w *roomWrite) recountMembers(rw storage.RoomWrite) error {
joined := map[string]bool{}
if !rw.StateDeltaIsWholeState {
rows, err := w.query(`SELECT state_key FROM roomserver_current_state
WHERE room_id = ? AND event_type = 'm.room.member' AND membership = 'join'`, rw.Event.RoomID)
if err != nil {
return fmt.Errorf("recount joined members: %w", err)
}
for rows.Next() {
var user string
if err := rows.Scan(&user); err != nil {
_ = rows.Close()
return fmt.Errorf("recount joined members: %w", err)
}
joined[user] = true
}
_ = rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("recount joined members: %w", err)
}
}
for _, e := range rw.StateDelta {
if e.Type != memberEventType {
continue
}
joined[e.StateKey] = e.Membership == "join"
}
n := 0
for _, j := range joined {
if j {
n++
}
}
if _, err := w.setWhere("roomserver_rooms", rec{"joined_members": int64(n)}, `room_id = ?`, rw.Event.RoomID); err != nil {
return fmt.Errorf("recount joined members: %w", err)
}
return nil
}
func (w *roomWrite) replaceExtremities(roomID string, extremities []string) error {
if len(extremities) == 0 {
return nil
}
if _, err := w.delWhere("roomserver_extremities", `room_id = ?`, roomID); err != nil {
return fmt.Errorf("clear forward extremities: %w", err)
}
for _, id := range extremities {
w.put("roomserver_extremities", rec{"room_id": roomID, "event_id": id})
}
return nil
}
// eventColumns is the select list every event read shares, in the order
// scanEvent expects. state_key reads as NULL for a message event, as the SQL
// drivers store it.
const eventColumns = `
e.room_id, e.event_id, e.event_type, CASE WHEN e.has_state_key THEN e.state_key END, e.sender,
e.depth, e.origin_server_ts, e.event_json, e.stream_ordering, e.redacted_by, e.outlier,
e.relates_to_id, e.rel_type`
// scanEvent reads one row of eventColumns.
func scanEvent(scan func(...any) error) (storage.StoredEvent, error) {
var (
e storage.StoredEvent
stateKey sql.NullString
body string
)
err := scan(&e.RoomID, &e.EventID, &e.Type, &stateKey, &e.Sender,
&e.Depth, &e.OriginServerTS, &body, &e.StreamOrdering, &e.RedactedBy, &e.Outlier,
&e.RelatesTo, &e.RelType)
if err != nil {
return storage.StoredEvent{}, err
}
if stateKey.Valid {
e.StateKey = &stateKey.String
}
e.JSON = []byte(body)
return e, nil
}
// scanEvents drains a result set of eventColumns rows.
func scanEvents(rows *sql.Rows) ([]storage.StoredEvent, error) {
defer func() { _ = rows.Close() }()
var out []storage.StoredEvent
for rows.Next() {
e, err := scanEvent(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// Event implements storage.RoomStore.
func (s *Rooms) Event(ctx context.Context, roomID, eventID string) (storage.StoredEvent, error) {
e, err := scanEvent(s.db.QueryRowContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_events e WHERE e.room_id = ? AND e.event_id = ?`, roomID, eventID).Scan)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.StoredEvent{}, fmt.Errorf("event %q: %w", eventID, storage.ErrNoSuchEvent)
case err != nil:
return storage.StoredEvent{}, fmt.Errorf("read event %q: %w", eventID, err)
}
return e, nil
}
// EventsByID implements storage.RoomStore.
func (s *Rooms) EventsByID(ctx context.Context, roomID string, eventIDs []string) ([]storage.StoredEvent, error) {
if len(eventIDs) == 0 {
return nil, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_events e
WHERE e.room_id = ? AND e.event_id IN `+inList, roomID, jsonList(eventIDs))
if err != nil {
return nil, fmt.Errorf("read events by ID: %w", err)
}
events, err := scanEvents(rows)
if err != nil {
return nil, fmt.Errorf("read events by ID: %w", err)
}
return events, nil
}
// CurrentState implements storage.RoomStore.
func (s *Rooms) CurrentState(ctx context.Context, roomID string) ([]storage.StateEntry, error) {
rows, err := s.db.QueryContext(ctx, `SELECT event_type, state_key, event_id, membership
FROM roomserver_current_state WHERE room_id = ?
ORDER BY event_type, state_key`, roomID)
if err != nil {
return nil, fmt.Errorf("read current state of %q: %w", roomID, err)
}
entries, err := scanStateEntries(rows)
if err != nil {
return nil, fmt.Errorf("read current state of %q: %w", roomID, err)
}
return entries, nil
}
func scanStateEntries(rows *sql.Rows) ([]storage.StateEntry, error) {
defer func() { _ = rows.Close() }()
var out []storage.StateEntry
for rows.Next() {
var e storage.StateEntry
if err := rows.Scan(&e.Type, &e.StateKey, &e.EventID, &e.Membership); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// CurrentStateEvents implements storage.RoomStore.
func (s *Rooms) CurrentStateEvents(ctx context.Context, roomID string) ([]storage.StoredEvent, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_current_state cs
JOIN roomserver_events e ON e.event_id = cs.event_id
WHERE cs.room_id = ?
ORDER BY cs.event_type, cs.state_key`, roomID)
if err != nil {
return nil, fmt.Errorf("read current state events of %q: %w", roomID, err)
}
events, err := scanEvents(rows)
if err != nil {
return nil, fmt.Errorf("read current state events of %q: %w", roomID, err)
}
return events, nil
}
// CurrentStateEvent implements storage.RoomStore.
func (s *Rooms) CurrentStateEvent(ctx context.Context, roomID, eventType, stateKey string) (storage.StoredEvent, error) {
e, err := scanEvent(s.db.QueryRowContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_current_state cs
JOIN roomserver_events e ON e.event_id = cs.event_id
WHERE cs.room_id = ? AND cs.event_type = ? AND cs.state_key = ?`, roomID, eventType, stateKey).Scan)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.StoredEvent{}, fmt.Errorf("state %s/%s of %q: %w",
eventType, stateKey, roomID, storage.ErrNoSuchEvent)
case err != nil:
return storage.StoredEvent{}, fmt.Errorf("read state %s/%s of %q: %w",
eventType, stateKey, roomID, err)
}
return e, nil
}
// StateBefore implements storage.RoomStore.
func (s *Rooms) StateBefore(ctx context.Context, roomID, eventID string) ([]storage.StateEntry, error) {
rows, err := s.db.QueryContext(ctx, `SELECT se.event_type, se.state_key, se.event_id, ''
FROM roomserver_events e
JOIN roomserver_state_entries se ON se.snapshot_id = e.state_before_id
WHERE e.room_id = ? AND e.event_id = ?
ORDER BY se.event_type, se.state_key`, roomID, eventID)
if err != nil {
return nil, fmt.Errorf("read the state before %q: %w", eventID, err)
}
entries, err := scanStateEntries(rows)
if err != nil {
return nil, fmt.Errorf("read the state before %q: %w", eventID, err)
}
return entries, nil
}
// Extremities implements storage.RoomStore.
func (s *Rooms) Extremities(ctx context.Context, roomID string) ([]storage.StoredEvent, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_extremities x
JOIN roomserver_events e ON e.event_id = x.event_id
WHERE x.room_id = ?
ORDER BY e.stream_ordering`, roomID)
if err != nil {
return nil, fmt.Errorf("read the forward extremities of %q: %w", roomID, err)
}
events, err := scanEvents(rows)
if err != nil {
return nil, fmt.Errorf("read the forward extremities of %q: %w", roomID, err)
}
return events, nil
}
// Members implements storage.RoomStore.
func (s *Rooms) Members(ctx context.Context, roomID string, memberships []string) ([]storage.StoredEvent, error) {
query := `SELECT ` + eventColumns + `
FROM roomserver_current_state cs
JOIN roomserver_events e ON e.event_id = cs.event_id
WHERE cs.room_id = ? AND cs.event_type = 'm.room.member'`
args := []any{roomID}
if len(memberships) > 0 {
query += ` AND cs.membership IN ` + inList
args = append(args, jsonList(memberships))
}
query += ` ORDER BY cs.state_key`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read the members of %q: %w", roomID, err)
}
events, err := scanEvents(rows)
if err != nil {
return nil, fmt.Errorf("read the members of %q: %w", roomID, err)
}
return events, nil
}
// MembershipEntries implements storage.RoomStore.
func (s *Rooms) MembershipEntries(ctx context.Context, roomID string, memberships []string) ([]storage.MembershipEntry, error) {
query := `
SELECT state_key, membership, event_id FROM roomserver_current_state
WHERE room_id = ? AND event_type = 'm.room.member'`
args := []any{roomID}
if len(memberships) > 0 {
query += ` AND membership IN ` + inList
args = append(args, jsonList(memberships))
}
query += ` ORDER BY state_key`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read the memberships of %q: %w", roomID, err)
}
defer func() { _ = rows.Close() }()
var out []storage.MembershipEntry
for rows.Next() {
var m storage.MembershipEntry
if err := rows.Scan(&m.UserID, &m.Membership, &m.EventID); err != nil {
return nil, fmt.Errorf("read the memberships of %q: %w", roomID, err)
}
out = append(out, m)
}
return out, rows.Err()
}
// Membership implements storage.RoomStore.
func (s *Rooms) Membership(ctx context.Context, roomID, userID string) (storage.MembershipEntry, error) {
var m storage.MembershipEntry
err := s.db.QueryRowContext(ctx, `SELECT state_key, membership, event_id FROM roomserver_current_state
WHERE room_id = ? AND event_type = 'm.room.member' AND state_key = ?`, roomID, userID).
Scan(&m.UserID, &m.Membership, &m.EventID)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.MembershipEntry{UserID: userID}, nil
case err != nil:
return storage.MembershipEntry{}, fmt.Errorf("read the membership of %q in %q: %w", userID, roomID, err)
}
return m, nil
}
// RoomsForUser implements storage.RoomStore.
func (s *Rooms) RoomsForUser(ctx context.Context, userID string, memberships []string) ([]string, error) {
query := `
SELECT room_id FROM roomserver_current_state
WHERE event_type = 'm.room.member' AND state_key = ?`
args := []any{userID}
if len(memberships) > 0 {
query += ` AND membership IN ` + inList
args = append(args, jsonList(memberships))
}
query += ` ORDER BY room_id`
out, err := s.queryStrings(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read the rooms of %q: %w", userID, err)
}
return out, nil
}
// TransactionEvent implements storage.RoomStore.
func (s *Rooms) TransactionEvent(ctx context.Context, ref storage.TransactionRef) (string, error) {
var eventID string
err := s.db.QueryRowContext(ctx, `SELECT event_id FROM roomserver_transactions
WHERE localpart = ? AND device_id = ? AND txn_id = ?`,
ref.Localpart, ref.DeviceID, ref.TxnID).Scan(&eventID)
switch {
case errors.Is(err, sql.ErrNoRows):
return "", fmt.Errorf("transaction %q: %w", ref.TxnID, storage.ErrNoSuchEvent)
case err != nil:
return "", fmt.Errorf("read transaction %q: %w", ref.TxnID, err)
}
return eventID, nil
}
// CreateAlias implements storage.RoomStore.
func (s *Rooms) CreateAlias(ctx context.Context, alias storage.RoomAlias) error {
err := s.write(ctx, func(t *tx) error {
taken, err := t.exists("roomserver_aliases", `alias = ?`, alias.Alias)
if err != nil {
return err
}
if taken {
return storage.ErrAliasExists
}
t.put("roomserver_aliases", rec{
"alias": alias.Alias, "room_id": alias.RoomID,
"creator": alias.Creator, "created_at_ms": alias.CreatedAtMS,
})
return nil
})
if err != nil {
return fmt.Errorf("create alias %q: %w", alias.Alias, err)
}
return nil
}
// DeleteAlias implements storage.RoomStore.
func (s *Rooms) DeleteAlias(ctx context.Context, alias string) error {
err := s.write(ctx, func(t *tx) error {
found, err := t.delKey("roomserver_aliases", alias)
if err != nil {
return err
}
if !found {
return fmt.Errorf("%q: %w", alias, storage.ErrNoSuchAlias)
}
return nil
})
if err != nil {
return fmt.Errorf("delete alias %q: %w", alias, err)
}
return nil
}
// Alias implements storage.RoomStore.
func (s *Rooms) Alias(ctx context.Context, alias string) (storage.RoomAlias, error) {
var out storage.RoomAlias
err := s.db.QueryRowContext(ctx, `SELECT alias, room_id, creator, created_at_ms
FROM roomserver_aliases WHERE alias = ?`, alias).
Scan(&out.Alias, &out.RoomID, &out.Creator, &out.CreatedAtMS)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.RoomAlias{}, fmt.Errorf("alias %q: %w", alias, storage.ErrNoSuchAlias)
case err != nil:
return storage.RoomAlias{}, fmt.Errorf("resolve alias %q: %w", alias, err)
}
return out, nil
}
// AliasesForRoom implements storage.RoomStore.
func (s *Rooms) AliasesForRoom(ctx context.Context, roomID string) ([]string, error) {
out, err := s.queryStrings(ctx, `SELECT alias FROM roomserver_aliases WHERE room_id = ? ORDER BY alias`, roomID)
if err != nil {
return nil, fmt.Errorf("read the aliases of %q: %w", roomID, err)
}
return out, nil
}
// SetPublished implements storage.RoomStore.
func (s *Rooms) SetPublished(ctx context.Context, roomID string, published bool) error {
err := s.write(ctx, func(t *tx) error {
n, err := t.setWhere("roomserver_rooms", rec{"is_published": published}, `room_id = ?`, roomID)
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("%q: %w", roomID, storage.ErrNoSuchRoom)
}
return nil
})
if err != nil {
return fmt.Errorf("set the published flag of %q: %w", roomID, err)
}
return nil
}
// roomSummaryColumns is the select list a room listing shares.
const roomSummaryColumns = `
room_id, room_version, creator, is_published, created_at_ms,
name, topic, avatar_url, canonical_alias,
join_rule, history_visibility, guest_access, room_type, joined_members`
func scanRoomSummary(scan func(...any) error) (storage.RoomSummary, error) {
var r storage.RoomSummary
err := scan(&r.ID, &r.Version, &r.Creator, &r.Published, &r.CreatedAtMS,
&r.Name, &r.Topic, &r.AvatarURL, &r.CanonicalAlias,
&r.JoinRule, &r.HistoryVisibility, &r.GuestAccess, &r.RoomType, &r.JoinedMembers)
return r, err
}
// PublishedRooms implements storage.RoomStore.
func (s *Rooms) PublishedRooms(ctx context.Context, filter storage.RoomFilter) ([]storage.RoomSummary, int, error) {
return s.listRooms(ctx, filter, true)
}
// ListRooms implements storage.RoomStore.
func (s *Rooms) ListRooms(ctx context.Context, filter storage.RoomFilter) ([]storage.RoomSummary, int, error) {
return s.listRooms(ctx, filter, false)
}
// listRooms is the body of both listings, ordered by descending member
// count and then by room ID.
func (s *Rooms) listRooms(ctx context.Context, filter storage.RoomFilter, publishedOnly bool) ([]storage.RoomSummary, int, error) {
where := ` WHERE 1 = 1`
var args []any
if publishedOnly {
where += ` AND is_published = ?`
args = append(args, true)
}
if search := strings.TrimSpace(filter.Search); search != "" {
pattern := "%" + strings.ToLower(search) + "%"
where += ` AND (LOWER(room_id) LIKE ? OR LOWER(name) LIKE ?
OR LOWER(topic) LIKE ? OR LOWER(canonical_alias) LIKE ?)`
args = append(args, pattern, pattern, pattern, pattern)
}
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM roomserver_rooms`+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count rooms: %w", err)
}
limit, offset := pageBounds(filter.Limit, filter.Offset, defaultRoomPageSize, maxRoomPageSize)
query := `SELECT ` + roomSummaryColumns + ` FROM roomserver_rooms` + where +
` ORDER BY joined_members DESC, room_id LIMIT ? OFFSET ?`
rows, err := s.db.QueryContext(ctx, query, append(args, limit, offset)...)
if err != nil {
return nil, 0, fmt.Errorf("list rooms: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]storage.RoomSummary, 0, limit)
for rows.Next() {
r, err := scanRoomSummary(rows.Scan)
if err != nil {
return nil, 0, fmt.Errorf("list rooms: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("list rooms: %w", err)
}
return out, total, nil
}
// RoomSummary implements storage.RoomStore.
func (s *Rooms) RoomSummary(ctx context.Context, roomID string) (storage.RoomSummary, error) {
r, err := scanRoomSummary(s.db.QueryRowContext(ctx, `SELECT `+roomSummaryColumns+
` FROM roomserver_rooms WHERE room_id = ?`, roomID).Scan)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.RoomSummary{}, fmt.Errorf("room %q: %w", roomID, storage.ErrNoSuchRoom)
case err != nil:
return storage.RoomSummary{}, fmt.Errorf("read room %q: %w", roomID, err)
}
return r, nil
}
// purgeTables are cleared of a room's rows, children before parents, the
// room row last.
var purgeTables = []struct{ table, where string }{
{"roomserver_state_entries", `snapshot_id IN (
SELECT snapshot_id FROM roomserver_state_snapshots WHERE room_id = ?)`},
{"roomserver_state_snapshots", `room_id = ?`},
{"roomserver_current_state", `room_id = ?`},
{"roomserver_extremities", `room_id = ?`},
{"roomserver_transactions", `room_id = ?`},
{"roomserver_aliases", `room_id = ?`},
{"roomserver_invite_state", `room_id = ?`},
{"roomserver_event_search", `room_id = ?`},
{"roomserver_events", `room_id = ?`},
{"roomserver_rooms", `room_id = ?`},
}
// PurgeRoom implements storage.RoomStore.
func (s *Rooms) PurgeRoom(ctx context.Context, roomID string) error {
err := s.write(ctx, func(t *tx) error {
found, err := t.exists("roomserver_rooms", `room_id = ?`, roomID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("%q: %w", roomID, storage.ErrNoSuchRoom)
}
for _, p := range purgeTables {
if _, err := t.delWhere(p.table, p.where, roomID); err != nil {
return err
}
}
return nil
})
if err != nil {
return fmt.Errorf("purge room %q: %w", roomID, err)
}
return nil
}
// RoomStats implements storage.RoomStore.
func (s *Rooms) RoomStats(ctx context.Context) (storage.RoomStats, error) {
var stats storage.RoomStats
err := s.db.QueryRowContext(ctx, `
SELECT
(SELECT COUNT(*) FROM roomserver_rooms),
(SELECT COUNT(*) FROM roomserver_rooms WHERE is_published = ?),
(SELECT COUNT(*) FROM roomserver_events)`, true).
Scan(&stats.Rooms, &stats.Published, &stats.Events)
if err != nil {
return storage.RoomStats{}, fmt.Errorf("read room statistics: %w", err)
}
return stats, nil
}
// EventByID implements storage.RoomStore.
func (s *Rooms) EventByID(ctx context.Context, eventID string) (storage.StoredEvent, error) {
e, err := scanEvent(s.db.QueryRowContext(ctx, `SELECT `+eventColumns+`
FROM roomserver_events e WHERE e.event_id = ?`, eventID).Scan)
switch {
case errors.Is(err, sql.ErrNoRows):
return storage.StoredEvent{}, fmt.Errorf("event %q: %w", eventID, storage.ErrNoSuchEvent)
case err != nil:
return storage.StoredEvent{}, fmt.Errorf("read event %q: %w", eventID, err)
}
return e, nil
}
// HasJoinedMemberOn implements storage.RoomStore. A user ID's server is
// everything after its first colon, and a localpart cannot contain one.
func (s *Rooms) HasJoinedMemberOn(ctx context.Context, roomID, serverName string) (bool, error) {
var one int
err := s.db.QueryRowContext(ctx, `SELECT 1 FROM roomserver_current_state
WHERE room_id = ? AND event_type = 'm.room.member' AND membership = 'join'
AND state_key LIKE ? ESCAPE '\'
LIMIT 1`, roomID, `%:`+escapeLike(serverName)).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("look for members of %q in %q: %w", serverName, roomID, err)
}
return true, nil
}
// UsersSharingRooms implements storage.RoomStore.
func (s *Rooms) UsersSharingRooms(ctx context.Context, userID string) ([]string, error) {
out, err := s.queryStrings(ctx, `
SELECT DISTINCT other.state_key
FROM roomserver_current_state mine
JOIN roomserver_current_state other
ON other.room_id = mine.room_id
AND other.event_type = 'm.room.member'
AND other.membership = 'join'
WHERE mine.event_type = 'm.room.member'
AND mine.state_key = ?
AND mine.membership = 'join'
ORDER BY other.state_key`, userID)
if err != nil {
return nil, fmt.Errorf("read the users sharing a room with %q: %w", userID, err)
}
return out, nil
}
// MemberChanges implements storage.RoomStore.
func (s *Rooms) MemberChanges(ctx context.Context, roomIDs []string, from, to int64) ([]storage.MemberChange, error) {
if len(roomIDs) == 0 {
return nil, nil
}
query := `
SELECT e.room_id, e.state_key, e.stream_ordering, COALESCE(c.membership, '')
FROM roomserver_events e
LEFT JOIN roomserver_current_state c
ON c.room_id = e.room_id AND c.event_type = 'm.room.member' AND c.state_key = e.state_key
WHERE e.event_type = 'm.room.member' AND e.has_state_key AND NOT e.outlier
AND e.room_id IN ` + inList + `
AND e.stream_ordering > ?`
args := []any{jsonList(roomIDs), from}
if to != 0 {
query += ` AND e.stream_ordering <= ?`
args = append(args, to)
}
query += ` ORDER BY e.stream_ordering`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read membership changes: %w", err)
}
defer func() { _ = rows.Close() }()
var out []storage.MemberChange
for rows.Next() {
var c storage.MemberChange
if err := rows.Scan(&c.RoomID, &c.UserID, &c.StreamOrdering, &c.Membership); err != nil {
return nil, fmt.Errorf("read membership changes: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// queryStrings runs a query returning one string column.
func (b *base) queryStrings(ctx context.Context, query string, args ...any) ([]string, error) {
rows, err := b.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
var _ storage.RoomStore = (*Rooms)(nil)