Skip to content

Commit b1ea76e

Browse files
authored
fix: keep large message listings within SQLite parameter limits (#238)
1 parent 0d365ac commit b1ea76e

4 files changed

Lines changed: 102 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Fix large message listings and TUI reply/mention hydration failing with SQLite's parameter limit, preserving all requested rows and display names.
56
- Update Crawlkit to v0.16.3, retaining the existing Go minimum and SQLite runtime pairing.
67

78
## 0.15.1 - 2026-09-13

‎docs/commands/messages.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ discrawl --json messages --channel maintainers --days 3
3535
- if `tail` is already running, plain `messages` reads the local archive without waiting; `messages --sync` fails fast instead of waiting behind the tail lock
3636
- `--dm` skips Git snapshot auto-update because DMs are never imported from the shared mirror
3737
- use either `--last` for the newest matching rows or `--all` for an uncapped oldest-to-newest slice
38+
- uncapped and large listings retain mention display names even beyond 32,766 messages; reply and mention lookups do not impose a separate row cap
3839
- ambiguous channel names fail with candidate guild/channel ids; resolve once with `discrawl channels resolve <name> --json` and reuse the numeric id
3940

4041
## Empty results

‎internal/store/messages.go‎

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package store
22

33
import (
44
"context"
5+
"database/sql"
6+
"encoding/json"
7+
"maps"
58
"regexp"
9+
"slices"
610
"strings"
711
"time"
812
)
@@ -198,7 +202,7 @@ func (s *Store) hydrateMessageThreadContext(ctx context.Context, rows []MessageR
198202
if len(rows) == 0 {
199203
return rows, nil
200204
}
201-
rootIDs := make([]any, 0, len(rows))
205+
rootIDs := make([]string, 0, len(rows))
202206
seenRoots := map[string]struct{}{}
203207
visible := map[string]struct{}{}
204208
for _, row := range rows {
@@ -256,9 +260,9 @@ func (s *Store) hydrateMessageThreadContext(ctx context.Context, rows []MessageR
256260
left join guilds g on g.id = m.guild_id and g.deleted_at is null
257261
left join channels c on c.id = m.channel_id
258262
left join members mem on mem.guild_id = m.guild_id and mem.user_id = m.author_id and mem.deleted_at is null
259-
where m.id in (` + placeholders(len(rootIDs)) + `)
263+
where m.id in (select value from json_each(?))
260264
order by m.created_at asc, m.id asc`
261-
contextRows, err := s.db.QueryContext(ctx, query, rootIDs...)
265+
contextRows, err := s.queryMessageIDs(ctx, query, rootIDs)
262266
if err != nil {
263267
return nil, err
264268
}
@@ -349,7 +353,7 @@ func (s *Store) resolveMessageDisplayMentions(ctx context.Context, rows []Messag
349353
if len(rows) == 0 {
350354
return nil
351355
}
352-
ids := make([]any, 0, len(rows))
356+
ids := make([]string, 0, len(rows))
353357
indexByID := make(map[string]int, len(rows))
354358
for index, row := range rows {
355359
id := strings.TrimSpace(row.MessageID)
@@ -362,8 +366,8 @@ func (s *Store) resolveMessageDisplayMentions(ctx context.Context, rows []Messag
362366
if len(ids) == 0 {
363367
return nil
364368
}
365-
query := `select message_id, target_type, target_id, target_name from mention_events where message_id in (` + placeholders(len(ids)) + `)`
366-
mentionRows, err := s.db.QueryContext(ctx, query, ids...)
369+
query := `select message_id, target_type, target_id, target_name from mention_events where message_id in (select value from json_each(?))`
370+
mentionRows, err := s.queryMessageIDs(ctx, query, ids)
367371
if err != nil {
368372
return err
369373
}
@@ -458,7 +462,6 @@ func (s *Store) discordMemberDisplayNames(ctx context.Context, ids map[string]st
458462
if len(ids) == 0 {
459463
return nil, nil
460464
}
461-
args := mapKeysAsAny(ids)
462465
query := `
463466
select guild_id, user_id,
464467
coalesce(
@@ -469,9 +472,9 @@ func (s *Store) discordMemberDisplayNames(ctx context.Context, ids map[string]st
469472
''
470473
)
471474
from members
472-
where deleted_at is null and user_id in (` + placeholders(len(args)) + `)
475+
where deleted_at is null and user_id in (select value from json_each(?))
473476
`
474-
rows, err := s.db.QueryContext(ctx, query, args...)
477+
rows, err := s.queryMessageIDs(ctx, query, slices.Collect(maps.Keys(ids)))
475478
if err != nil {
476479
return nil, err
477480
}
@@ -491,9 +494,8 @@ func (s *Store) discordChannelNames(ctx context.Context, ids map[string]struct{}
491494
if len(ids) == 0 {
492495
return nil, nil
493496
}
494-
args := mapKeysAsAny(ids)
495-
query := `select guild_id, id, coalesce(nullif(name, ''), '') from channels where id in (` + placeholders(len(args)) + `)`
496-
rows, err := s.db.QueryContext(ctx, query, args...)
497+
query := `select guild_id, id, coalesce(nullif(name, ''), '') from channels where id in (select value from json_each(?))`
498+
rows, err := s.queryMessageIDs(ctx, query, slices.Collect(maps.Keys(ids)))
497499
if err != nil {
498500
return nil, err
499501
}
@@ -509,12 +511,13 @@ func (s *Store) discordChannelNames(ctx context.Context, ids map[string]struct{}
509511
return out, rows.Err()
510512
}
511513

512-
func mapKeysAsAny(values map[string]struct{}) []any {
513-
out := make([]any, 0, len(values))
514-
for value := range values {
515-
out = append(out, value)
514+
func (s *Store) queryMessageIDs(ctx context.Context, query string, ids []string) (*sql.Rows, error) {
515+
// Bind one JSON array so uncapped listings do not exhaust SQLite parameters.
516+
encoded, err := json.Marshal(ids)
517+
if err != nil {
518+
return nil, err
516519
}
517-
return out
520+
return s.db.QueryContext(ctx, query, string(encoded))
518521
}
519522

520523
func rememberResolvedDiscordName(out map[string]string, guildID, id, name string) {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package store
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestMessageHydrationBeyondSQLiteVariableLimit(t *testing.T) {
13+
t.Parallel()
14+
15+
ctx := context.Background()
16+
s, err := Open(ctx, filepath.Join(t.TempDir(), "discrawl.db"))
17+
require.NoError(t, err)
18+
t.Cleanup(func() { require.NoError(t, s.Close()) })
19+
20+
// SQLite permits 32766 bound parameters; uncapped archive slices can exceed it.
21+
const count = 32767
22+
_, err = s.db.ExecContext(ctx, `
23+
with recursive sequence(n) as (select 1 union all select n + 1 from sequence where n < ?)
24+
insert into messages (id, guild_id, channel_id, message_type, created_at, content, normalized_content, raw_json, updated_at)
25+
select printf('m%05d', n), 'g1', 'archive', 0, '2026-09-01T00:00:00.000000000Z',
26+
'ping <@&role>', 'ping <@&role>', '{}', '2026-09-01T00:00:00.000000000Z' from sequence`, count)
27+
require.NoError(t, err)
28+
_, err = s.db.ExecContext(ctx, `
29+
insert into mention_events (message_id, guild_id, channel_id, target_type, target_id, target_name, event_at)
30+
select id, guild_id, channel_id, 'role', 'role', 'Readers', created_at from messages`)
31+
require.NoError(t, err)
32+
require.NoError(t, s.UpsertMember(ctx, MemberRecord{GuildID: "g1", UserID: "u32767", Username: "last-user", RoleIDsJSON: `[]`, RawJSON: `{}`}))
33+
require.NoError(t, s.UpsertChannel(ctx, ChannelRecord{ID: "c32767", GuildID: "g1", Kind: "text", Name: "last-channel", RawJSON: `{}`}))
34+
35+
t.Run("uncapped listing", func(t *testing.T) {
36+
rows, err := s.ListMessages(ctx, MessageListOptions{Channel: "archive"})
37+
require.NoError(t, err)
38+
require.Len(t, rows, count)
39+
require.Equal(t, "m00001", rows[0].MessageID)
40+
require.Equal(t, "m32767", rows[count-1].MessageID)
41+
for _, row := range rows {
42+
require.Equal(t, "ping @Readers", row.DisplayContent)
43+
}
44+
})
45+
46+
t.Run("reply roots", func(t *testing.T) {
47+
replies := make([]MessageRow, count)
48+
for i := range replies {
49+
replies[i] = MessageRow{MessageID: fmt.Sprintf("reply%d", i), GuildID: "g1", ReplyToMessage: fmt.Sprintf("m%05d", i+1)}
50+
}
51+
rows, err := s.hydrateMessageThreadContext(ctx, replies, count)
52+
require.NoError(t, err)
53+
require.Len(t, rows, 2*count)
54+
require.Equal(t, replies, rows[:count])
55+
require.Equal(t, "m00001", rows[count].MessageID)
56+
require.Equal(t, "m32767", rows[2*count-1].MessageID)
57+
require.Equal(t, "ping @Readers", rows[2*count-1].DisplayContent)
58+
})
59+
60+
for _, kind := range []string{"user", "channel"} {
61+
t.Run(kind+" names", func(t *testing.T) {
62+
rows := make([]MessageRow, count)
63+
for i := range rows {
64+
content := fmt.Sprintf("<@u%d>", i+1)
65+
if kind == "channel" {
66+
content = fmt.Sprintf("<#c%d>", i+1)
67+
}
68+
rows[i] = MessageRow{GuildID: "g1", DisplayContent: content}
69+
}
70+
require.NoError(t, s.resolveInlineDiscordMentions(ctx, rows))
71+
if kind == "user" {
72+
require.Equal(t, "<@u1>", rows[0].DisplayContent)
73+
require.Equal(t, "@last-user", rows[count-1].DisplayContent)
74+
} else {
75+
require.Equal(t, "<#c1>", rows[0].DisplayContent)
76+
require.Equal(t, "#last-channel", rows[count-1].DisplayContent)
77+
}
78+
})
79+
}
80+
}

0 commit comments

Comments
 (0)