Files
Warp/server/internal/storage/sqlstore/rooms.go
T
tajniak81andClaude Opus 5.5 fc49f56339 Phase 7: E2EE support
Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.

internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.

Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.

Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.

Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.

Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:34:08 +02:00

1273 lines
43 KiB
Go

package sqlstore
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"slices"
"strings"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Rooms is the shared SQL implementation of storage.RoomStore.
//
// Like the user store it is written once with `?` placeholders and rebound
// per driver. The statements stay inside the syntax both engines agree on:
// ON CONFLICT ... DO UPDATE with `excluded`, LIMIT/OFFSET, and no JSON
// functions at all — what a room's events mean is the roomserver's
// business, and the only reason this package ever looks inside one is that
// it does not.
type Rooms struct {
db *sql.DB
rebind func(string) string
isUnique func(error) bool
}
// NewRooms builds a room store.
func NewRooms(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Rooms {
if rebind == nil {
rebind = func(query string) string { return query }
}
if isUnique == nil {
isUnique = func(error) bool { return false }
}
return &Rooms{db: db, rebind: rebind, isUnique: isUnique}
}
// Page sizes for a room listing. The public room directory is reachable
// without a token on some deployments, so an unbounded ?limit= there would
// be a way to ask the server to serialise every room it knows.
const (
defaultRoomPageSize = 100
maxRoomPageSize = 1000
)
// The counters in roomserver_sequences.
const (
seqStreamOrdering = "stream_ordering"
seqStateSnapshot = "state_snapshot"
// seqBackfillOrdering numbers backfilled history. It starts at 0 and
// only ever goes down; see WriteBackfill.
seqBackfillOrdering = "backfill_ordering"
)
// q rebinds a statement for this driver.
func (s *Rooms) q(query string) string { return s.rebind(query) }
// tx runs fn in a transaction, rolling back on any error.
func (s *Rooms) tx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
const insertRoomSQL = `
INSERT INTO roomserver_rooms (room_id, room_version, creator, is_published, created_at_ms)
VALUES (?, ?, ?, ?, ?)`
// CreateRoom implements storage.RoomStore.
func (s *Rooms) CreateRoom(ctx context.Context, room storage.NewRoom) error {
_, err := s.db.ExecContext(ctx, s.q(insertRoomSQL),
room.ID, room.Version, room.Creator, room.Published, room.CreatedAtMS)
if err != nil {
if s.isUnique(err) {
return fmt.Errorf("create room %q: %w", room.ID, storage.ErrRoomExists)
}
return fmt.Errorf("create room %q: %w", room.ID, err)
}
return nil
}
const selectRoomSQL = `
SELECT room_id, room_version, creator, is_published, created_at_ms
FROM roomserver_rooms WHERE room_id = ?`
// 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, s.q(selectRoomSQL), 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
}
// Write implements storage.RoomStore.
//
// The order inside the transaction is the order the pieces depend on each
// other: the stream position first, because the event row needs it; the
// state snapshot next, because the event row points at it; then the event,
// then everything that refers to the event by ID.
func (s *Rooms) Write(ctx context.Context, w storage.RoomWrite) (int64, error) {
var ordering int64
err := s.tx(ctx, func(tx *sql.Tx) error {
var err error
if ordering, err = s.nextSequence(ctx, tx, seqStreamOrdering); err != nil {
return err
}
// An outlier has no state snapshot: that is what makes it one.
var snapshotID int64
if !w.Outlier {
if snapshotID, err = s.stateSnapshot(ctx, tx, w.Event.RoomID, w.StateBefore); err != nil {
return err
}
}
if err := s.insertEvent(ctx, tx, w.Event, ordering, snapshotID, w.Outlier); err != nil {
return err
}
if w.Event.Type == memberEventType && w.Event.StateKey != nil {
if err := s.replaceInviteState(ctx, tx, w.Event.RoomID, *w.Event.StateKey, w.InviteState); err != nil {
return err
}
}
if w.Redacts != "" {
if err := s.applyRedaction(ctx, tx, w.Event.RoomID, w.Redacts,
w.Event.EventID, w.RedactedJSON); err != nil {
return err
}
}
if err := s.applyStateDelta(ctx, tx, w); err != nil {
return err
}
if err := s.replaceExtremities(ctx, tx, w.Event.RoomID, w.Extremities); err != nil {
return err
}
if w.Profile != nil {
if err := s.updateProfile(ctx, tx, w.Event.RoomID, *w.Profile); err != nil {
return err
}
}
// The member count is recomputed whenever current state moved,
// because that is the only thing that can change it.
if len(w.StateDelta) > 0 || w.StateDeltaIsWholeState {
if err := s.recountMembers(ctx, tx, w.Event.RoomID); err != nil {
return err
}
}
if w.Transaction != nil {
if err := s.recordTransaction(ctx, tx, *w.Transaction,
w.Event.EventID, w.Event.RoomID, w.Event.OriginServerTS); err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, fmt.Errorf("write event %s: %w", w.Event.EventID, err)
}
return ordering, nil
}
// nextSequence hands out the next value of one of this component's
// counters; see sequences.go for why they are rows rather than sequences.
func (s *Rooms) nextSequence(ctx context.Context, tx *sql.Tx, name string) (int64, error) {
return nextSequence(ctx, tx, s.q, roomSequences, name)
}
const (
selectSnapshotSQL = `
SELECT snapshot_id FROM roomserver_state_snapshots
WHERE room_id = ? AND state_hash = ?`
insertSnapshotSQL = `
INSERT INTO roomserver_state_snapshots (snapshot_id, room_id, state_hash)
VALUES (?, ?, ?)`
insertStateEntrySQL = `
INSERT INTO roomserver_state_entries (snapshot_id, event_type, state_key, event_id)
VALUES (?, ?, ?, ?)`
)
// stateSnapshot stores a state set, re-using the row of an identical one.
//
// Deduplication is what makes a per-event state snapshot affordable: every
// message in a room whose state is not changing resolves to the snapshot
// the previous one already stored, so the table grows with state changes
// rather than with traffic. An empty state set is snapshot 0 and stores
// nothing, which is the create event's case.
func (s *Rooms) stateSnapshot(ctx context.Context, tx *sql.Tx, roomID string, entries []storage.StateEntry) (int64, error) {
if len(entries) == 0 {
return 0, nil
}
hash := stateHash(entries)
var id int64
err := tx.QueryRowContext(ctx, s.q(selectSnapshotSQL), roomID, hash).Scan(&id)
switch {
case err == nil:
return id, nil
case !errors.Is(err, sql.ErrNoRows):
return 0, fmt.Errorf("look up state snapshot: %w", err)
}
if id, err = s.nextSequence(ctx, tx, seqStateSnapshot); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, s.q(insertSnapshotSQL), id, roomID, hash); err != nil {
return 0, fmt.Errorf("insert state snapshot: %w", err)
}
for _, e := range entries {
if _, err := tx.ExecContext(ctx, s.q(insertStateEntrySQL),
id, e.Type, e.StateKey, e.EventID); err != nil {
return 0, fmt.Errorf("insert state entry %s/%s: %w", e.Type, e.StateKey, err)
}
}
return id, nil
}
// stateHash identifies a state set by its contents.
//
// The entries are sorted first so that the same state always hashes the
// same whatever order it was assembled in, and each field is written with
// its length so that no two different sets can be run together into the
// same bytes.
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))
}
// memberEventType is the one event type the store has to recognise: its
// rows carry a membership, and its arrival clears a stored invite.
const memberEventType = "m.room.member"
const (
insertEventSQL = `
INSERT INTO roomserver_events (
event_id, room_id, stream_ordering, event_type, state_key,
sender, depth, origin_server_ts, event_json, state_before_id, outlier)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
selectOutlierSQL = `SELECT outlier FROM roomserver_events WHERE event_id = ?`
promoteOutlierSQL = `
UPDATE roomserver_events
SET stream_ordering = ?, state_before_id = ?, outlier = ?
WHERE event_id = ? AND outlier = ?`
)
// insertEvent writes the event row.
//
// An event this server already holds is not written twice. The one
// exception is an outlier arriving again as an ordinary event, which is
// promoted in place: it gets the new stream position and the state
// snapshot it now has, and joins the timeline. Its JSON is left as it was —
// the event ID says the two copies are the same event, and the stored one
// may already have been redacted.
func (s *Rooms) insertEvent(ctx context.Context, tx *sql.Tx, e storage.StoredEvent, ordering, snapshotID int64, outlier bool) error {
var existingOutlier bool
err := tx.QueryRowContext(ctx, s.q(selectOutlierSQL), e.EventID).Scan(&existingOutlier)
switch {
case err == nil:
if existingOutlier && !outlier {
if _, err := tx.ExecContext(ctx, s.q(promoteOutlierSQL),
ordering, snapshotID, false, e.EventID, true); err != nil {
return fmt.Errorf("promote outlier: %w", err)
}
}
// The same event twice is not a failure worth propagating: an
// event ID is a hash of its contents, so the second copy says
// exactly what the first one did.
return nil
case !errors.Is(err, sql.ErrNoRows):
return fmt.Errorf("look up event: %w", err)
}
_, err = tx.ExecContext(ctx, s.q(insertEventSQL),
e.EventID, e.RoomID, ordering, e.Type, e.StateKey,
e.Sender, e.Depth, e.OriginServerTS, string(e.JSON), snapshotID, outlier)
if err != nil {
if s.isUnique(err) {
return nil
}
return fmt.Errorf("insert event: %w", err)
}
return nil
}
const (
deleteInviteStateSQL = `DELETE FROM roomserver_invite_state WHERE room_id = ? AND user_id = ?`
insertInviteStateSQL = `
INSERT INTO roomserver_invite_state (room_id, user_id, state_json) VALUES (?, ?, ?)`
)
// replaceInviteState clears a user's stored invite state and, for an
// invite that came with some, stores the new one.
func (s *Rooms) replaceInviteState(ctx context.Context, tx *sql.Tx, roomID, userID string, state []byte) error {
if _, err := tx.ExecContext(ctx, s.q(deleteInviteStateSQL), roomID, userID); err != nil {
return fmt.Errorf("clear invite state: %w", err)
}
if len(state) == 0 {
return nil
}
if _, err := tx.ExecContext(ctx, s.q(insertInviteStateSQL), roomID, userID, string(state)); err != nil {
return fmt.Errorf("store invite state: %w", err)
}
return nil
}
const selectInviteStateSQL = `
SELECT state_json FROM roomserver_invite_state WHERE room_id = ? AND user_id = ?`
// InviteState implements storage.RoomStore.
func (s *Rooms) InviteState(ctx context.Context, roomID, userID string) ([]byte, error) {
var state string
err := s.db.QueryRowContext(ctx, s.q(selectInviteStateSQL), 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.
//
// The stream positions are taken as one block, in one UPDATE, rather than
// one at a time: a join to a large room hands over tens of thousands of
// events, and a counter bumped per event would be that many statements
// holding the counter's row lock.
func (s *Rooms) WriteOutliers(ctx context.Context, roomID string, events []storage.StoredEvent) error {
if len(events) == 0 {
return nil
}
err := s.tx(ctx, func(tx *sql.Tx) error {
top, err := advanceSequence(ctx, tx, s.q, roomSequences, seqStreamOrdering, int64(len(events)))
if err != nil {
return err
}
first := top - int64(len(events)) + 1
for i, e := range events {
if e.RoomID == "" {
e.RoomID = roomID
}
if err := s.insertEvent(ctx, tx, e, first+int64(i), 0, true); err != nil {
return fmt.Errorf("outlier %s: %w", e.EventID, err)
}
}
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, in one UPDATE: the events already in
// the timeline are sorted out first, inside the transaction, so that the
// block taken is exactly as big as what goes into it. A hole below the
// room would be harmless — nothing streams through backfilled history, it
// is only ever read by bounds — but there is no reason to leave one.
//
// The batch takes the block oldest first: if the counter stood at -10 and
// three events are written, they become -13, -12 and -11.
func (s *Rooms) WriteBackfill(ctx context.Context, roomID string, events []storage.BackfillEvent) error {
if len(events) == 0 {
return nil
}
err := s.tx(ctx, func(tx *sql.Tx) error {
// 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
promote := map[string]bool{}
for _, b := range events {
var outlier bool
err := tx.QueryRowContext(ctx, s.q(selectOutlierSQL), b.Event.EventID).Scan(&outlier)
switch {
case errors.Is(err, sql.ErrNoRows):
write = append(write, b)
case err != nil:
return fmt.Errorf("look up event %s: %w", b.Event.EventID, err)
case outlier:
write = append(write, b)
promote[b.Event.EventID] = true
}
}
if len(write) == 0 {
return nil
}
bottom, err := advanceSequence(ctx, tx, s.q, 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 := s.stateSnapshot(ctx, tx, e.RoomID, b.StateBefore)
if err != nil {
return err
}
if promote[e.EventID] {
if _, err := tx.ExecContext(ctx, s.q(promoteOutlierSQL),
ordering, snapshotID, false, e.EventID, true); err != nil {
return fmt.Errorf("promote outlier %s: %w", e.EventID, err)
}
continue
}
if _, err := tx.ExecContext(ctx, s.q(insertEventSQL),
e.EventID, e.RoomID, ordering, e.Type, e.StateKey,
e.Sender, e.Depth, e.OriginServerTS, string(e.JSON), snapshotID, false); err != nil {
return fmt.Errorf("insert event %s: %w", e.EventID, err)
}
}
return nil
})
if err != nil {
return fmt.Errorf("backfill %d events into %q: %w", len(events), roomID, err)
}
return nil
}
const applyRedactionSQL = `
UPDATE roomserver_events SET event_json = ?, redacted_by = ?
WHERE room_id = ? AND event_id = ? AND redacted_by = ''`
// applyRedaction replaces a stored event with its redacted form.
//
// The condition on redacted_by makes a second redaction of the same event a
// no-op rather than a rewrite: the first one already stripped the content,
// and the event may only be redacted once.
func (s *Rooms) applyRedaction(ctx context.Context, tx *sql.Tx, roomID, targetID, redactionID string, redacted []byte) error {
_, err := tx.ExecContext(ctx, s.q(applyRedactionSQL),
string(redacted), redactionID, roomID, targetID)
if err != nil {
return fmt.Errorf("apply redaction to %s: %w", targetID, err)
}
return nil
}
const (
upsertCurrentStateSQL = `
INSERT INTO roomserver_current_state (room_id, event_type, state_key, event_id, membership)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (room_id, event_type, state_key) DO UPDATE
SET event_id = excluded.event_id, membership = excluded.membership`
deleteCurrentStateSQL = `DELETE FROM roomserver_current_state WHERE room_id = ?`
)
// applyStateDelta moves the room's current state.
//
// The usual case is a single tuple to upsert. A whole-state write — which
// only state resolution produces — clears the table for the room first,
// because tuples the resolution dropped have to go.
func (s *Rooms) applyStateDelta(ctx context.Context, tx *sql.Tx, w storage.RoomWrite) error {
if len(w.StateDelta) == 0 && !w.StateDeltaIsWholeState {
return nil
}
if w.StateDeltaIsWholeState {
if _, err := tx.ExecContext(ctx, s.q(deleteCurrentStateSQL), w.Event.RoomID); err != nil {
return fmt.Errorf("clear current state: %w", err)
}
}
for _, e := range w.StateDelta {
if _, err := tx.ExecContext(ctx, s.q(upsertCurrentStateSQL),
w.Event.RoomID, e.Type, e.StateKey, e.EventID, e.Membership); err != nil {
return fmt.Errorf("set current state %s/%s: %w", e.Type, e.StateKey, err)
}
}
return nil
}
const (
deleteExtremitiesSQL = `DELETE FROM roomserver_extremities WHERE room_id = ?`
insertExtremitySQL = `
INSERT INTO roomserver_extremities (room_id, event_id) VALUES (?, ?)
ON CONFLICT (room_id, event_id) DO NOTHING`
)
func (s *Rooms) replaceExtremities(ctx context.Context, tx *sql.Tx, roomID string, extremities []string) error {
if len(extremities) == 0 {
return nil
}
if _, err := tx.ExecContext(ctx, s.q(deleteExtremitiesSQL), roomID); err != nil {
return fmt.Errorf("clear forward extremities: %w", err)
}
for _, id := range extremities {
if _, err := tx.ExecContext(ctx, s.q(insertExtremitySQL), roomID, id); err != nil {
return fmt.Errorf("insert forward extremity %s: %w", id, err)
}
}
return nil
}
const updateProfileSQL = `
UPDATE roomserver_rooms SET
name = ?, topic = ?, avatar_url = ?, canonical_alias = ?,
join_rule = ?, history_visibility = ?, guest_access = ?, room_type = ?
WHERE room_id = ?`
func (s *Rooms) updateProfile(ctx context.Context, tx *sql.Tx, roomID string, p storage.RoomProfile) error {
_, err := tx.ExecContext(ctx, s.q(updateProfileSQL),
p.Name, p.Topic, p.AvatarURL, p.CanonicalAlias,
p.JoinRule, p.HistoryVisibility, p.GuestAccess, p.RoomType, roomID)
if err != nil {
return fmt.Errorf("update room profile: %w", err)
}
return nil
}
const recountMembersSQL = `
UPDATE roomserver_rooms SET joined_members = (
SELECT COUNT(*) FROM roomserver_current_state
WHERE room_id = ? AND membership = 'join'
) WHERE room_id = ?`
func (s *Rooms) recountMembers(ctx context.Context, tx *sql.Tx, roomID string) error {
if _, err := tx.ExecContext(ctx, s.q(recountMembersSQL), roomID, roomID); err != nil {
return fmt.Errorf("recount joined members: %w", err)
}
return nil
}
const insertTransactionSQL = `
INSERT INTO roomserver_transactions (localpart, device_id, txn_id, event_id, room_id, sent_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (localpart, device_id, txn_id) DO NOTHING`
func (s *Rooms) recordTransaction(ctx context.Context, tx *sql.Tx, ref storage.TransactionRef, eventID, roomID string, atMS int64) error {
_, err := tx.ExecContext(ctx, s.q(insertTransactionSQL),
ref.Localpart, ref.DeviceID, ref.TxnID, eventID, roomID, atMS)
if err != nil {
return fmt.Errorf("record transaction %q: %w", ref.TxnID, err)
}
return nil
}
// eventColumns is the select list every event read shares, in the order
// scanEvent expects.
const eventColumns = `
e.room_id, e.event_id, e.event_type, e.state_key, e.sender,
e.depth, e.origin_server_ts, e.event_json, e.stream_ordering, e.redacted_by, e.outlier`
// 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)
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()
}
var selectEventSQL = `
SELECT ` + eventColumns + `
FROM roomserver_events e WHERE e.room_id = ? AND e.event_id = ?`
// Event implements storage.RoomStore.
func (s *Rooms) Event(ctx context.Context, roomID, eventID string) (storage.StoredEvent, error) {
e, err := scanEvent(s.db.QueryRowContext(ctx, s.q(selectEventSQL), 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
}
query := `SELECT ` + eventColumns + `
FROM roomserver_events e
WHERE e.room_id = ? AND e.event_id IN (` + placeholders(len(eventIDs)) + `)`
args := make([]any, 0, len(eventIDs)+1)
args = append(args, roomID)
for _, id := range eventIDs {
args = append(args, id)
}
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
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
}
const selectCurrentStateSQL = `
SELECT event_type, state_key, event_id, membership
FROM roomserver_current_state WHERE room_id = ?
ORDER BY event_type, state_key`
// CurrentState implements storage.RoomStore.
func (s *Rooms) CurrentState(ctx context.Context, roomID string) ([]storage.StateEntry, error) {
rows, err := s.db.QueryContext(ctx, s.q(selectCurrentStateSQL), 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()
}
var selectCurrentStateEventsSQL = `
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`
// CurrentStateEvents implements storage.RoomStore.
func (s *Rooms) CurrentStateEvents(ctx context.Context, roomID string) ([]storage.StoredEvent, error) {
rows, err := s.db.QueryContext(ctx, s.q(selectCurrentStateEventsSQL), 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
}
var selectCurrentStateEventSQL = `
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 = ?`
// 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,
s.q(selectCurrentStateEventSQL), 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
}
const selectStateBeforeSQL = `
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`
// StateBefore implements storage.RoomStore.
//
// The membership column is not stored on a snapshot entry: it exists on
// current state to make a membership lookup an index hit, and a historical
// snapshot is only ever read as a whole and then parsed anyway.
func (s *Rooms) StateBefore(ctx context.Context, roomID, eventID string) ([]storage.StateEntry, error) {
rows, err := s.db.QueryContext(ctx, s.q(selectStateBeforeSQL), 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
}
var selectExtremitiesSQL = `
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`
// Extremities implements storage.RoomStore.
func (s *Rooms) Extremities(ctx context.Context, roomID string) ([]storage.StoredEvent, error) {
rows, err := s.db.QueryContext(ctx, s.q(selectExtremitiesSQL), 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 (` + placeholders(len(memberships)) + `)`
for _, m := range memberships {
args = append(args, m)
}
}
query += ` ORDER BY cs.state_key`
rows, err := s.db.QueryContext(ctx, s.q(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 (` + placeholders(len(memberships)) + `)`
for _, m := range memberships {
args = append(args, m)
}
}
query += ` ORDER BY state_key`
rows, err := s.db.QueryContext(ctx, s.q(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()
}
const selectMembershipSQL = `
SELECT state_key, membership, event_id FROM roomserver_current_state
WHERE room_id = ? AND event_type = 'm.room.member' AND state_key = ?`
// 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, s.q(selectMembershipSQL), roomID, userID).
Scan(&m.UserID, &m.Membership, &m.EventID)
switch {
case errors.Is(err, sql.ErrNoRows):
// Never a member is not an error: it is the same answer as having
// left, which is what the rules above this ask for.
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 (` + placeholders(len(memberships)) + `)`
for _, m := range memberships {
args = append(args, m)
}
}
query += ` ORDER BY room_id`
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("read the rooms of %q: %w", userID, err)
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var roomID string
if err := rows.Scan(&roomID); err != nil {
return nil, fmt.Errorf("read the rooms of %q: %w", userID, err)
}
out = append(out, roomID)
}
return out, rows.Err()
}
const selectTransactionSQL = `
SELECT event_id FROM roomserver_transactions
WHERE localpart = ? AND device_id = ? AND txn_id = ?`
// TransactionEvent implements storage.RoomStore.
func (s *Rooms) TransactionEvent(ctx context.Context, ref storage.TransactionRef) (string, error) {
var eventID string
err := s.db.QueryRowContext(ctx, s.q(selectTransactionSQL),
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
}
const insertAliasSQL = `
INSERT INTO roomserver_aliases (alias, room_id, creator, created_at_ms)
VALUES (?, ?, ?, ?)`
// CreateAlias implements storage.RoomStore.
func (s *Rooms) CreateAlias(ctx context.Context, alias storage.RoomAlias) error {
_, err := s.db.ExecContext(ctx, s.q(insertAliasSQL),
alias.Alias, alias.RoomID, alias.Creator, alias.CreatedAtMS)
if err != nil {
if s.isUnique(err) {
return fmt.Errorf("create alias %q: %w", alias.Alias, storage.ErrAliasExists)
}
return fmt.Errorf("create alias %q: %w", alias.Alias, err)
}
return nil
}
const deleteAliasSQL = `DELETE FROM roomserver_aliases WHERE alias = ?`
// DeleteAlias implements storage.RoomStore.
func (s *Rooms) DeleteAlias(ctx context.Context, alias string) error {
result, err := s.db.ExecContext(ctx, s.q(deleteAliasSQL), alias)
if err != nil {
return fmt.Errorf("delete alias %q: %w", alias, err)
}
return expectOneRow(result, alias, storage.ErrNoSuchAlias)
}
const selectAliasSQL = `
SELECT alias, room_id, creator, created_at_ms
FROM roomserver_aliases WHERE alias = ?`
// 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, s.q(selectAliasSQL), 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
}
const selectRoomAliasesSQL = `
SELECT alias FROM roomserver_aliases WHERE room_id = ? ORDER BY alias`
// AliasesForRoom implements storage.RoomStore.
func (s *Rooms) AliasesForRoom(ctx context.Context, roomID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, s.q(selectRoomAliasesSQL), roomID)
if err != nil {
return nil, fmt.Errorf("read the aliases of %q: %w", roomID, err)
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var alias string
if err := rows.Scan(&alias); err != nil {
return nil, fmt.Errorf("read the aliases of %q: %w", roomID, err)
}
out = append(out, alias)
}
return out, rows.Err()
}
const setPublishedSQL = `UPDATE roomserver_rooms SET is_published = ? WHERE room_id = ?`
// SetPublished implements storage.RoomStore.
func (s *Rooms) SetPublished(ctx context.Context, roomID string, published bool) error {
result, err := s.db.ExecContext(ctx, s.q(setPublishedSQL), published, roomID)
if err != nil {
return fmt.Errorf("set the published flag of %q: %w", roomID, err)
}
return expectOneRow(result, roomID, storage.ErrNoSuchRoom)
}
// roomSummaryColumns is the select list a room listing shares, in the order
// scanRoomSummary expects.
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: the same query with and without
// the published condition.
//
// Rooms are ordered by descending member count and then by room ID. The
// second key is not decoration: paging a listing whose order is not total
// can show the same room on two pages and skip another, and ties in member
// count are the common case in a directory of small rooms.
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,
s.q(`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, s.q(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
}
var selectRoomSummarySQL = `SELECT ` + roomSummaryColumns +
` FROM roomserver_rooms WHERE room_id = ?`
// RoomSummary implements storage.RoomStore.
func (s *Rooms) RoomSummary(ctx context.Context, roomID string) (storage.RoomSummary, error) {
r, err := scanRoomSummary(s.db.QueryRowContext(ctx, s.q(selectRoomSummarySQL), 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
}
// purgeStatements are run in order, children before parents.
//
// Both engines are given ON DELETE CASCADE on these tables, but SQLite
// enforces foreign keys only when the pragma is on, and the transactions
// table has no key at all because it outlives the room it refers to. The
// explicit order is what makes a purge mean the same thing on both.
var purgeStatements = []string{
`DELETE FROM roomserver_state_entries WHERE snapshot_id IN (
SELECT snapshot_id FROM roomserver_state_snapshots WHERE room_id = ?)`,
`DELETE FROM roomserver_state_snapshots WHERE room_id = ?`,
`DELETE FROM roomserver_current_state WHERE room_id = ?`,
`DELETE FROM roomserver_extremities WHERE room_id = ?`,
`DELETE FROM roomserver_transactions WHERE room_id = ?`,
`DELETE FROM roomserver_aliases WHERE room_id = ?`,
`DELETE FROM roomserver_invite_state WHERE room_id = ?`,
`DELETE FROM roomserver_events WHERE room_id = ?`,
`DELETE FROM roomserver_rooms WHERE room_id = ?`,
}
// PurgeRoom implements storage.RoomStore.
func (s *Rooms) PurgeRoom(ctx context.Context, roomID string) error {
err := s.tx(ctx, func(tx *sql.Tx) error {
for i, stmt := range purgeStatements {
result, err := tx.ExecContext(ctx, s.q(stmt), roomID)
if err != nil {
return err
}
// The last statement is the room row itself: no row there means
// there was no room, and the whole purge rolls back so that the
// caller's 404 is the truth.
if i == len(purgeStatements)-1 {
if err := expectOneRow(result, roomID, storage.ErrNoSuchRoom); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return fmt.Errorf("purge room %q: %w", roomID, err)
}
return nil
}
const roomStatsSQL = `
SELECT
(SELECT COUNT(*) FROM roomserver_rooms),
(SELECT COUNT(*) FROM roomserver_rooms WHERE is_published = ?),
(SELECT COUNT(*) FROM roomserver_events)`
// RoomStats implements storage.RoomStore.
func (s *Rooms) RoomStats(ctx context.Context) (storage.RoomStats, error) {
var stats storage.RoomStats
err := s.db.QueryRowContext(ctx, s.q(roomStatsSQL), true).
Scan(&stats.Rooms, &stats.Published, &stats.Events)
if err != nil {
return storage.RoomStats{}, fmt.Errorf("read room statistics: %w", err)
}
return stats, nil
}
// placeholders renders n comma-separated `?` placeholders for an IN clause.
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?, ", n), ", ")
}
// pageBounds clamps a caller's limit and offset.
func pageBounds(limit, offset, defaultLimit, maxLimit int) (int, int) {
if limit <= 0 {
limit = defaultLimit
}
if limit > maxLimit {
limit = maxLimit
}
if offset < 0 {
offset = 0
}
return limit, offset
}
// expectOneRow turns "the UPDATE or DELETE matched nothing" into notFound.
func expectOneRow(result sql.Result, subject string, notFound error) error {
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("count the affected rows: %w", err)
}
if n == 0 {
return fmt.Errorf("%q: %w", subject, notFound)
}
return nil
}
var selectEventByIDSQL = `
SELECT ` + eventColumns + `
FROM roomserver_events e WHERE e.event_id = ?`
// EventByID implements storage.RoomStore.
func (s *Rooms) EventByID(ctx context.Context, eventID string) (storage.StoredEvent, error) {
e, err := scanEvent(s.db.QueryRowContext(ctx, s.q(selectEventByIDSQL), 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
}
const selectJoinedMemberOnSQL = `
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`
// HasJoinedMemberOn implements storage.RoomStore.
//
// A user ID's server is everything after its first colon, and a localpart
// cannot contain one, so "ends with ':' plus the server name" is exactly
// "belongs to that server".
func (s *Rooms) HasJoinedMemberOn(ctx context.Context, roomID, serverName string) (bool, error) {
var one int
err := s.db.QueryRowContext(ctx, s.q(selectJoinedMemberOnSQL), 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
}
// selectUsersSharingRoomsSQL joins the current state to itself on the room:
// the rows of every room the user is joined to, paired with every joined
// member of the same room. Both sides are lookups on the membership index.
const selectUsersSharingRoomsSQL = `
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`
// UsersSharingRooms implements storage.RoomStore.
func (s *Rooms) UsersSharingRooms(ctx context.Context, userID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, s.q(selectUsersSharingRoomsSQL), userID)
if err != nil {
return nil, fmt.Errorf("read the users sharing a room with %q: %w", userID, err)
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var other string
if err := rows.Scan(&other); err != nil {
return nil, fmt.Errorf("read the users sharing a room with %q: %w", userID, err)
}
out = append(out, other)
}
return out, rows.Err()
}
// 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.state_key IS NOT NULL AND NOT e.outlier
AND e.room_id IN (` + placeholders(len(roomIDs)) + `)
AND e.stream_ordering > ?`
args := make([]any, 0, len(roomIDs)+2)
for _, roomID := range roomIDs {
args = append(args, roomID)
}
args = append(args, 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, s.q(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()
}