1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
|
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strconv"
"strings"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli/database"
)
// This file reads the timeline straight out of hicli's SQLite schema with hand
// written SQL instead of going through hicli's database query helpers.
//
// Why: hicli's only timeline reader is TimelineQuery.Get, whose SQL is
// `timeline.rowid < $2 ORDER BY timeline.rowid DESC` — strictly backwards. We
// need three more shapes: forwards ("everything after the row I already have",
// the SSE reconnect path), by-event-ID (to re-render a single message after an
// edit, redaction or late decryption), and a cursor lookup that maps an event
// ID back to its current timeline position. None have an equivalent in the
// library. As a bonus we select the ten columns webchat actually uses instead
// of scanning all 24 into a database.Event, and resolve sender display names in
// the same statement.
//
// The tradeoff: this couples webchat to hicli's schema (tables `timeline`,
// `event`, `current_state`). That schema is versioned and migrated by hicli, so
// a future upgrade could rename a column and break these queries at runtime
// rather than at compile time. Accepted deliberately: the queries are small and
// the alternative (no forward query) is worse.
//
// NOTE: writing to these tables is emphatically NOT done here. Pulling history
// that isn't cached yet still goes through hicli's Paginate, which owns
// /messages requests, megolm decryption, prev_batch bookkeeping and timeline
// row ID allocation.
// eventColumns is shared by every query below so one scanner fits them all.
//
// COALESCE(decrypted, content) picks the cleartext for encrypted events and the
// plaintext body for unencrypted ones; likewise for the type. Undecryptable
// events keep their m.room.encrypted type and carry a decryption_error, which
// toMessage turns into a placeholder row rather than dropping silently.
//
// The two LEFT JOINs resolve the sender's display name via the room's current
// m.room.member state. LEFT so a message from a departed member still renders.
//
// relation_type distinguishes an edit (m.replace) from a normal message; edits
// are folded into their target rather than shown as timeline rows of their own.
const eventColumns = `
e.event_id, e.sender, e.timestamp,
COALESCE(e.decrypted, e.content),
COALESCE(e.decrypted_type, e.type),
e.decryption_error, e.state_key, e.redacted_by, e.relation_type,
json_extract(member.content, '$.displayname')
FROM timeline t
JOIN event e ON e.rowid = t.event_rowid
LEFT JOIN current_state cs
ON cs.room_id = t.room_id
AND cs.event_type = 'm.room.member'
AND cs.state_key = e.sender
LEFT JOIN event member ON member.rowid = cs.event_rowid
`
// queryBackwardSQL walks from newest to oldest, for the initial page load.
// $2 = 0 means "start at the newest", matching hicli's own convention.
const queryBackwardSQL = `SELECT t.rowid, ` + eventColumns + `
WHERE t.room_id = $1 AND ($2 = 0 OR t.rowid < $2)
ORDER BY t.rowid DESC
LIMIT $3
`
// queryForwardSQL returns everything strictly newer than a row ID, oldest
// first. This is the reconnect path and is deliberately uncapped: after a long
// sleep we would rather push a large gap than silently skip messages.
const queryForwardSQL = `SELECT t.rowid, ` + eventColumns + `
WHERE t.room_id = $1 AND t.rowid > $2
ORDER BY t.rowid ASC
`
// queryByEventIDsSQL re-reads specific events by ID, for append and update
// frames.
//
// The ORDER BY is not optional. SQLite plans this query by driving from the
// event_id unique index (verified with EXPLAIN QUERY PLAN), so without it rows
// come back in event-ID order rather than timeline order — for an append that
// both renders messages out of order and, because the resume cursor is taken
// from the last message in the frame, can hand the client a cursor pointing at
// an older event than one it has already seen.
const queryByEventIDsSQL = `SELECT t.rowid, ` + eventColumns + `
WHERE t.room_id = $1 AND e.event_id IN (%s)
ORDER BY t.rowid ASC
`
// resolveCursorSQL maps an event ID back to its *current* timeline row ID.
//
// This is what makes event IDs usable as the SSE resume cursor. A timeline row
// ID cannot be used directly: `timeline.rowid` is `INTEGER PRIMARY KEY` without
// AUTOINCREMENT, so SQLite hands out max(rowid)+1 over the whole table and
// recycles values after a DELETE. hicli deletes the whole room's timeline on a
// limited sync (database/timeline.go), so a stored row ID can silently come to
// mean a *different* event afterwards. Event IDs are server-assigned, UNIQUE in
// the schema, and never reused, so re-resolving one on each reconnect is always
// correct.
//
// Returning no rows is itself meaningful: the event is no longer in the
// timeline, i.e. it was cleared by a limited sync, so the client must reset.
// Both index lookups are unique, so this is two seeks.
const resolveCursorSQL = `
SELECT t.rowid FROM timeline t
JOIN event e ON e.rowid = t.event_rowid
WHERE t.room_id = $1 AND e.event_id = $2
`
// editsSQL collects the edits (MSC2676 m.replace) for a batch of target events.
//
// The `edit.sender = target.sender` join condition is security-critical, not an
// optimisation: without it any member of the room could rewrite anyone else's
// messages by sending an m.replace pointing at them. hicli applies the same
// restriction in its own edit query (database/event.go).
//
// Redacted edits are excluded so that redacting a bad edit restores the
// previous text, which is what every other client does.
//
// NOTE: relation_type is derived by hicli from the *outer* event content
// (database/event.go, getRelatesToFromEvent) and is not re-derived after
// decryption. A sender that puts m.relates_to only inside the ciphertext would
// therefore not have its edits linked here. Spec-compliant clients (Element
// included) put m.relates_to in the cleartext content, so this works in
// practice.
const editsSQL = `
SELECT edit.relates_to,
COALESCE(edit.decrypted, edit.content),
edit.timestamp
FROM event edit
JOIN event target
ON target.room_id = edit.room_id
AND target.event_id = edit.relates_to
WHERE edit.room_id = $1
AND edit.relation_type = 'm.replace'
AND edit.redacted_by IS NULL
AND edit.state_key IS NULL
AND edit.sender = target.sender
AND edit.relates_to IN (%s)
ORDER BY edit.relates_to, edit.timestamp ASC
`
// timelineRow is one joined timeline+event row, before it is filtered down to
// the subset the UI displays.
type timelineRow struct {
RowID database.TimelineRowID
EventID id.EventID
Sender id.UserID
Timestamp int64
Content json.RawMessage
Type string
DecryptionError string
// StateKey is non-NULL for state events, which the UI never shows.
StateKey sql.NullString
RedactedBy sql.NullString
// RelationType is "m.replace" for edits, "m.annotation" for reactions, etc.
RelationType sql.NullString
// Displayname is the sender's current per-room display name, if any.
Displayname sql.NullString
}
func scanTimelineRows(rows dbutil.Rows) ([]timelineRow, error) {
defer rows.Close()
var out []timelineRow
for rows.Next() {
var r timelineRow
var content []byte
var decryptionError sql.NullString
if err := rows.Scan(
&r.RowID, &r.EventID, &r.Sender, &r.Timestamp,
&content, &r.Type, &decryptionError,
&r.StateKey, &r.RedactedBy, &r.RelationType, &r.Displayname,
); err != nil {
return nil, err
}
r.Content = content
r.DecryptionError = decryptionError.String
out = append(out, r)
}
return out, rows.Err()
}
// placeholders builds "$2, $3, ..." for an IN clause of n items starting at $2.
// database/sql has no portable way to bind a list, and hicli's helpers do the
// same thing internally.
func placeholders(n int) string {
var b strings.Builder
for i := range n {
if i > 0 {
b.WriteString(", ")
}
// +2 because $1 is always the room ID.
b.WriteString("$")
b.WriteString(strconv.Itoa(i + 2))
}
return b.String()
}
// queryBackward returns up to limit timeline rows older than before (0 =
// newest), newest first.
//
// Rows are returned *unfiltered* by event type on purpose. The caller uses the
// oldest row ID in the batch as its next cursor, and that cursor has to advance
// across every timeline row, not just the displayable ones — otherwise a window
// containing nothing but state events would leave the cursor unchanged and the
// walk would spin forever. Filtering happens afterwards in toMessage.
func (v *roomView) queryBackward(ctx context.Context, before database.TimelineRowID, limit int) ([]timelineRow, error) {
rows, err := v.s.cli.DB.Query(ctx, queryBackwardSQL, v.roomID, before, limit)
if err != nil {
return nil, err
}
return scanTimelineRows(rows)
}
// queryForward returns every timeline row newer than after, oldest first.
func (v *roomView) queryForward(ctx context.Context, after database.TimelineRowID) ([]timelineRow, error) {
rows, err := v.s.cli.DB.Query(ctx, queryForwardSQL, v.roomID, after)
if err != nil {
return nil, err
}
return scanTimelineRows(rows)
}
// queryByEventIDs re-reads a set of events by ID, for update frames. Events not
// in the timeline simply do not come back, which is the correct outcome: an
// update for a row the client cannot be holding is dropped.
func (v *roomView) queryByEventIDs(ctx context.Context, ids []id.EventID) ([]timelineRow, error) {
if len(ids) == 0 {
return nil, nil
}
args := make([]any, 0, len(ids)+1)
args = append(args, v.roomID)
for _, evtID := range ids {
args = append(args, evtID)
}
query := strings.Replace(queryByEventIDsSQL, "%s", placeholders(len(ids)), 1)
rows, err := v.s.cli.DB.Query(ctx, query, args...)
if err != nil {
return nil, err
}
return scanTimelineRows(rows)
}
// resolveCursor maps a client's event-ID cursor to its current timeline row ID.
// found=false means the event is no longer on the timeline, so the client's
// view is unrecoverable and it must be reset.
func (v *roomView) resolveCursor(ctx context.Context, eventID id.EventID) (rowID database.TimelineRowID, found bool, err error) {
err = v.s.cli.DB.QueryRow(ctx, resolveCursorSQL, v.roomID, eventID).Scan(&rowID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
return 0, false, err
}
return rowID, true, nil
}
// editRow is one m.replace event applying to a target message.
type editRow struct {
Body string
Timestamp int64
}
// loadEdits returns the edits for each of the given target event IDs, oldest
// first, keyed by target. One indexed query per batch (event_relates_to_idx),
// not one per message.
func (v *roomView) loadEdits(ctx context.Context, ids []id.EventID) (map[id.EventID][]editRow, error) {
if len(ids) == 0 {
return nil, nil
}
args := make([]any, 0, len(ids)+1)
args = append(args, v.roomID)
for _, evtID := range ids {
args = append(args, evtID)
}
query := strings.Replace(editsSQL, "%s", placeholders(len(ids)), 1)
rows, err := v.s.cli.DB.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[id.EventID][]editRow)
for rows.Next() {
var target id.EventID
var content []byte
var ts int64
if err := rows.Scan(&target, &content, &ts); err != nil {
return nil, err
}
// An edit's replacement text lives in m.new_content; the top-level body
// is a "* fallback" for clients that do not understand edits.
var parsed messageContent
if err := json.Unmarshal(content, &parsed); err != nil {
continue
}
body := ""
if parsed.NewContent != nil {
body = parsed.NewContent.Body
}
if body == "" {
continue
}
out[target] = append(out[target], editRow{Body: body, Timestamp: ts})
}
return out, rows.Err()
}
|