diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ad563..c957e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed multi-workspace desktop/API sync scoping, fail-closed workspace authentication, workspace-qualified purges, and explicit watch workspace selection. Thanks @zm2231. - Prevented unchanged desktop refreshes from duplicating message events and added preview-first retained-history compaction with `purge --keep-message-events`. Thanks @barbieri. - Normalized relative runtime paths to absolute paths so commands can open databases created with `init --db `. +- Treat nullable optional message metadata as empty when reading historical archives. ### Maintenance diff --git a/internal/store/store.go b/internal/store/store.go index 4a84f2d..cb12087 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1372,9 +1372,10 @@ func (s *Store) searchAuto(ctx context.Context, workspaceID string, query string func (s *Store) searchFTS(ctx context.Context, workspaceID string, query string, limit int) ([]MessageRow, error) { sqlQuery := ` -select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, m.user_id, +select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, coalesce(m.user_id, ''), coalesce(nullif(u.display_name, ''), nullif(u.real_name, ''), nullif(u.name, ''), ''), - m.text, m.normalized_text, m.thread_ts, m.reply_count, m.latest_reply, m.subtype, m.source_name + m.text, m.normalized_text, coalesce(m.thread_ts, ''), m.reply_count, + coalesce(m.latest_reply, ''), coalesce(m.subtype, ''), m.source_name from message_fts f join messages m on f.message_key = m.channel_id || '|' || m.ts left join workspaces w on w.id = m.workspace_id @@ -1400,9 +1401,10 @@ limit ? func (s *Store) searchLike(ctx context.Context, workspaceID string, query string, limit int) ([]MessageRow, error) { pattern := "%" + escapeLike(strings.ToLower(strings.TrimSpace(query))) + "%" sqlQuery := ` -select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, m.user_id, +select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, coalesce(m.user_id, ''), coalesce(nullif(u.display_name, ''), nullif(u.real_name, ''), nullif(u.name, ''), ''), - m.text, m.normalized_text, m.thread_ts, m.reply_count, m.latest_reply, m.subtype, m.source_name + m.text, m.normalized_text, coalesce(m.thread_ts, ''), m.reply_count, + coalesce(m.latest_reply, ''), coalesce(m.subtype, ''), m.source_name from messages m left join workspaces w on w.id = m.workspace_id left join channels c on c.id = m.channel_id @@ -1471,9 +1473,10 @@ func escapeLike(value string) string { func (s *Store) Messages(ctx context.Context, workspaceID string, channelID string, userID string, limit int) ([]MessageRow, error) { query := ` -select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, m.user_id, +select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, coalesce(m.user_id, ''), coalesce(nullif(u.display_name, ''), nullif(u.real_name, ''), nullif(u.name, ''), ''), - m.text, m.normalized_text, m.thread_ts, m.reply_count, m.latest_reply, m.subtype, m.source_name + m.text, m.normalized_text, coalesce(m.thread_ts, ''), m.reply_count, + coalesce(m.latest_reply, ''), coalesce(m.subtype, ''), m.source_name from messages m left join workspaces w on w.id = m.workspace_id left join channels c on c.id = m.channel_id @@ -1557,9 +1560,10 @@ func (s *Store) hydrateThreadContext(ctx context.Context, rows []MessageRow, lim contextLimit = 2000 } query := ` -select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, m.user_id, +select m.workspace_id, coalesce(w.name, ''), m.channel_id, coalesce(c.name, ''), m.ts, coalesce(m.user_id, ''), coalesce(nullif(u.display_name, ''), nullif(u.real_name, ''), nullif(u.name, ''), ''), - m.text, m.normalized_text, m.thread_ts, m.reply_count, m.latest_reply, m.subtype, m.source_name + m.text, m.normalized_text, coalesce(m.thread_ts, ''), m.reply_count, + coalesce(m.latest_reply, ''), coalesce(m.subtype, ''), m.source_name from messages m left join workspaces w on w.id = m.workspace_id left join channels c on c.id = m.channel_id diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 4dc5d5a..90d5024 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -52,6 +52,52 @@ func TestStoreRoundTrip(t *testing.T) { require.Equal(t, 1, status.Messages) } +func TestMessageReadsHandleNullableOptionalFields(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + s, err := Open(dbPath) + require.NoError(t, err) + defer func() { require.NoError(t, s.Close()) }() + + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, s.UpsertWorkspace(ctx, Workspace{ID: "T1", Name: "team", RawJSON: "{}", UpdatedAt: now})) + require.NoError(t, s.UpsertChannel(ctx, Channel{ID: "C1", WorkspaceID: "T1", Name: "eng", Kind: "public_channel", RawJSON: "{}", UpdatedAt: now})) + require.NoError(t, s.UpsertMessage(ctx, Message{ + ChannelID: "C1", + TS: "123.45", + WorkspaceID: "T1", + Text: "nullable optionals", + NormalizedText: "nullable optionals", + SourceRank: 1, + SourceName: "api-user", + RawJSON: "{}", + UpdatedAt: now, + }, nil)) + _, err = s.DB().ExecContext(ctx, ` +update messages +set user_id = null, thread_ts = null, latest_reply = null, subtype = null +where channel_id = 'C1' and ts = '123.45' +`) + require.NoError(t, err) + + assertEmptyOptionals := func(t *testing.T, rows []MessageRow, err error) { + t.Helper() + require.NoError(t, err) + require.Len(t, rows, 1) + require.Empty(t, rows[0].UserID) + require.Empty(t, rows[0].ThreadTS) + require.Empty(t, rows[0].LatestReply) + require.Empty(t, rows[0].Subtype) + } + + rows, err := s.Search(ctx, "", "nullable", 10) + assertEmptyOptionals(t, rows, err) + rows, err = s.searchLike(ctx, "", "optionals", 10) + assertEmptyOptionals(t, rows, err) + rows, err = s.Messages(ctx, "T1", "C1", "", 10) + assertEmptyOptionals(t, rows, err) +} + func TestSearchMessagesAutoEscapesAndFallsBack(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") s, err := Open(dbPath)