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>
1150 lines
37 KiB
Go
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)
|