diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index 038014d3f9..a8c4b04c64 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -399,7 +399,7 @@ type SendMessageResult struct { Chat database.Chat } -// EditMessageOptions controls in-place user message edits. +// EditMessageOptions controls user message edits via soft-delete and re-insert. type EditMessageOptions struct { ChatID uuid.UUID CreatedBy uuid.UUID @@ -407,7 +407,7 @@ type EditMessageOptions struct { Content []codersdk.ChatMessagePart } -// EditMessageResult contains the updated user message and chat status. +// EditMessageResult contains the replacement user message and chat status. type EditMessageResult struct { Message database.ChatMessage Chat database.Chat @@ -710,7 +710,8 @@ func (p *Server) checkUsageLimit(ctx context.Context, store database.Store, owne return nil } -// EditMessage updates a user message in-place, truncates all following messages, +// EditMessage marks the old user message as deleted, soft-deletes all +// following messages, inserts a new message with the updated content, // clears queued messages, and moves the chat into pending status. func (p *Server) EditMessage( ctx context.Context, @@ -756,28 +757,43 @@ func (p *Server) EditMessage( return ErrEditedMessageNotUser } - updatedMessage, err := tx.UpdateChatMessageByID(ctx, database.UpdateChatMessageByIDParams{ - ModelConfigID: uuid.NullUUID{}, - Content: content, - ID: opts.EditedMessageID, - }) + // Soft-delete the original message instead of updating in place + // so that usage/cost data is preserved. + err = tx.SoftDeleteChatMessageByID(ctx, opts.EditedMessageID) if err != nil { - return xerrors.Errorf("update chat message: %w", err) + return xerrors.Errorf("soft-delete edited message: %w", err) } - err = tx.DeleteChatMessagesAfterID(ctx, database.DeleteChatMessagesAfterIDParams{ + // Soft-delete all messages that came after the edited one. + err = tx.SoftDeleteChatMessagesAfterID(ctx, database.SoftDeleteChatMessagesAfterIDParams{ ChatID: opts.ChatID, AfterID: opts.EditedMessageID, }) if err != nil { - return xerrors.Errorf("delete later chat messages: %w", err) + return xerrors.Errorf("soft-delete later chat messages: %w", err) } + // Insert a new message with the updated content. + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + ChatID: opts.ChatID, + } + appendChatMessage(&msgParams, newChatMessage( + database.ChatMessageRoleUser, + content, + existing.Visibility, + existing.ModelConfigID.UUID, + chatprompt.CurrentContentVersion, + ).withCreatedBy(opts.CreatedBy)) + newMessages, err := insertChatMessageWithStore(ctx, tx, msgParams) + if err != nil { + return xerrors.Errorf("insert replacement message: %w", err) + } + newMessage := newMessages[0] + err = tx.DeleteAllChatQueuedMessages(ctx, opts.ChatID) if err != nil { return xerrors.Errorf("delete queued messages: %w", err) } - updatedChat, err := tx.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ ID: opts.ChatID, Status: database.ChatStatusPending, @@ -790,7 +806,7 @@ func (p *Server) EditMessage( return xerrors.Errorf("set chat pending: %w", err) } - result.Message = updatedMessage + result.Message = newMessage result.Chat = updatedChat return nil }, nil) @@ -2709,7 +2725,7 @@ func (p *Server) runChat( err := p.db.InTx(func(tx database.Store) error { // Verify this worker still owns the chat before // inserting messages. This closes the race where - // EditMessage truncates history and clears worker_id + // EditMessage soft-deletes history and clears worker_id // while persistInterruptedStep (which uses an // uncancelable context) is still running. // diff --git a/coderd/chatd/chatd_test.go b/coderd/chatd/chatd_test.go index 2f562062ba..61565a523e 100644 --- a/coderd/chatd/chatd_test.go +++ b/coderd/chatd/chatd_test.go @@ -562,7 +562,9 @@ func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) { Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("edited")}, }) require.NoError(t, err) - require.Equal(t, editedMessageID, editResult.Message.ID) + // The edited message is soft-deleted and a new message is inserted, + // so the returned message ID will differ from the original. + require.NotEqual(t, editedMessageID, editResult.Message.ID) require.Equal(t, database.ChatStatusPending, editResult.Chat.Status) require.False(t, editResult.Chat.WorkerID.Valid) @@ -576,7 +578,7 @@ func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) { }) require.NoError(t, err) require.Len(t, messages, 1) - require.Equal(t, editedMessageID, messages[0].ID) + require.Equal(t, editResult.Message.ID, messages[0].ID) onlyMessage := db2sdk.ChatMessage(messages[0]) require.Len(t, onlyMessage.Content, 1) require.Equal(t, "edited", onlyMessage.Content[0].Text) diff --git a/coderd/chats_test.go b/coderd/chats_test.go index 6a38b592c3..5e4c11e3bf 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -2663,7 +2663,9 @@ func TestPatchChatMessage(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, userMessageID, edited.ID) + // The edited message is soft-deleted and a new one is inserted, + // so the returned ID will differ from the original. + require.NotEqual(t, userMessageID, edited.ID) require.Equal(t, codersdk.ChatMessageRoleUser, edited.Role) foundEditedText := false @@ -2753,7 +2755,9 @@ func TestPatchChatMessage(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, userMessageID, edited.ID) + // The edited message is soft-deleted and a new one is inserted, + // so the returned ID will differ from the original. + require.NotEqual(t, userMessageID, edited.ID) // Assert the edit response preserves the file_id. var foundText, foundFile bool diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 9f4976efa8..931a8cabfe 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1824,18 +1824,6 @@ func (q *querier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, u return q.db.DeleteApplicationConnectAPIKeysByUserID(ctx, userID) } -func (q *querier) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { - // Authorize update on the parent chat. - chat, err := q.db.GetChatByID(ctx, arg.ChatID) - if err != nil { - return err - } - if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { - return err - } - return q.db.DeleteChatMessagesAfterID(ctx, arg) -} - func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err @@ -5391,6 +5379,32 @@ func (q *querier) SelectUsageEventsForPublishing(ctx context.Context, arg time.T return q.db.SelectUsageEventsForPublishing(ctx, arg) } +func (q *querier) SoftDeleteChatMessageByID(ctx context.Context, id int64) error { + msg, err := q.db.GetChatMessageByID(ctx, id) + if err != nil { + return err + } + chat, err := q.db.GetChatByID(ctx, msg.ChatID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.SoftDeleteChatMessageByID(ctx, id) +} + +func (q *querier) SoftDeleteChatMessagesAfterID(ctx context.Context, arg database.SoftDeleteChatMessagesAfterIDParams) error { + chat, err := q.db.GetChatByID(ctx, arg.ChatID) + if err != nil { + return err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil { + return err + } + return q.db.SoftDeleteChatMessagesAfterID(ctx, arg) +} + func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) { return q.db.TryAcquireLock(ctx, id) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d11b349a09..df72ccc98b 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -401,16 +401,27 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().UnarchiveChatByID(gomock.Any(), chat.ID).Return(nil).AnyTimes() check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns() })) - s.Run("DeleteChatMessagesAfterID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + s.Run("SoftDeleteChatMessagesAfterID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) - arg := database.DeleteChatMessagesAfterIDParams{ + arg := database.SoftDeleteChatMessagesAfterIDParams{ ChatID: chat.ID, AfterID: 123, } dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() - dbm.EXPECT().DeleteChatMessagesAfterID(gomock.Any(), arg).Return(nil).AnyTimes() + dbm.EXPECT().SoftDeleteChatMessagesAfterID(gomock.Any(), arg).Return(nil).AnyTimes() check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns() })) + s.Run("SoftDeleteChatMessageByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + chat := testutil.Fake(s.T(), faker, database.Chat{}) + msg := database.ChatMessage{ + ID: 456, + ChatID: chat.ID, + } + dbm.EXPECT().GetChatMessageByID(gomock.Any(), msg.ID).Return(msg, nil).AnyTimes() + dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes() + dbm.EXPECT().SoftDeleteChatMessageByID(gomock.Any(), msg.ID).Return(nil).AnyTimes() + check.Args(msg.ID).Asserts(chat, policy.ActionUpdate).Returns() + })) s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { id := uuid.New() dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), id).Return(nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index fceae90d74..1d30d1f3ec 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -384,14 +384,6 @@ func (m queryMetricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.C return r0 } -func (m queryMetricsStore) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { - start := time.Now() - r0 := m.s.DeleteChatMessagesAfterID(ctx, arg) - m.queryLatencies.WithLabelValues("DeleteChatMessagesAfterID").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatMessagesAfterID").Inc() - return r0 -} - func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { start := time.Now() r0 := m.s.DeleteChatModelConfigByID(ctx, id) @@ -3776,6 +3768,22 @@ func (m queryMetricsStore) SelectUsageEventsForPublishing(ctx context.Context, n return r0, r1 } +func (m queryMetricsStore) SoftDeleteChatMessageByID(ctx context.Context, id int64) error { + start := time.Now() + r0 := m.s.SoftDeleteChatMessageByID(ctx, id) + m.queryLatencies.WithLabelValues("SoftDeleteChatMessageByID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "SoftDeleteChatMessageByID").Inc() + return r0 +} + +func (m queryMetricsStore) SoftDeleteChatMessagesAfterID(ctx context.Context, arg database.SoftDeleteChatMessagesAfterIDParams) error { + start := time.Now() + r0 := m.s.SoftDeleteChatMessagesAfterID(ctx, arg) + m.queryLatencies.WithLabelValues("SoftDeleteChatMessagesAfterID").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "SoftDeleteChatMessagesAfterID").Inc() + return r0 +} + func (m queryMetricsStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) { start := time.Now() r0, r1 := m.s.TryAcquireLock(ctx, pgTryAdvisoryXactLock) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 3aa4d683e5..4b3831307c 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -598,20 +598,6 @@ func (mr *MockStoreMockRecorder) DeleteApplicationConnectAPIKeysByUserID(ctx, us return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplicationConnectAPIKeysByUserID", reflect.TypeOf((*MockStore)(nil).DeleteApplicationConnectAPIKeysByUserID), ctx, userID) } -// DeleteChatMessagesAfterID mocks base method. -func (m *MockStore) DeleteChatMessagesAfterID(ctx context.Context, arg database.DeleteChatMessagesAfterIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteChatMessagesAfterID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// DeleteChatMessagesAfterID indicates an expected call of DeleteChatMessagesAfterID. -func (mr *MockStoreMockRecorder) DeleteChatMessagesAfterID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatMessagesAfterID", reflect.TypeOf((*MockStore)(nil).DeleteChatMessagesAfterID), ctx, arg) -} - // DeleteChatModelConfigByID mocks base method. func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { m.ctrl.T.Helper() @@ -7112,6 +7098,34 @@ func (mr *MockStoreMockRecorder) SelectUsageEventsForPublishing(ctx, now any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SelectUsageEventsForPublishing", reflect.TypeOf((*MockStore)(nil).SelectUsageEventsForPublishing), ctx, now) } +// SoftDeleteChatMessageByID mocks base method. +func (m *MockStore) SoftDeleteChatMessageByID(ctx context.Context, id int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoftDeleteChatMessageByID", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// SoftDeleteChatMessageByID indicates an expected call of SoftDeleteChatMessageByID. +func (mr *MockStoreMockRecorder) SoftDeleteChatMessageByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteChatMessageByID", reflect.TypeOf((*MockStore)(nil).SoftDeleteChatMessageByID), ctx, id) +} + +// SoftDeleteChatMessagesAfterID mocks base method. +func (m *MockStore) SoftDeleteChatMessagesAfterID(ctx context.Context, arg database.SoftDeleteChatMessagesAfterIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoftDeleteChatMessagesAfterID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// SoftDeleteChatMessagesAfterID indicates an expected call of SoftDeleteChatMessagesAfterID. +func (mr *MockStoreMockRecorder) SoftDeleteChatMessagesAfterID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteChatMessagesAfterID", reflect.TypeOf((*MockStore)(nil).SoftDeleteChatMessagesAfterID), ctx, arg) +} + // TryAcquireLock mocks base method. func (m *MockStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 65fec3083a..836767f60f 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1290,7 +1290,8 @@ CREATE TABLE chat_messages ( created_by uuid, content_version smallint NOT NULL, total_cost_micros bigint, - runtime_ms bigint + runtime_ms bigint, + deleted boolean DEFAULT false NOT NULL ); CREATE SEQUENCE chat_messages_id_seq diff --git a/coderd/database/migrations/000446_chat_messages_deleted.down.sql b/coderd/database/migrations/000446_chat_messages_deleted.down.sql new file mode 100644 index 0000000000..c0032ff779 --- /dev/null +++ b/coderd/database/migrations/000446_chat_messages_deleted.down.sql @@ -0,0 +1,2 @@ +DELETE FROM chat_messages WHERE deleted = true; +ALTER TABLE chat_messages DROP COLUMN deleted; diff --git a/coderd/database/migrations/000446_chat_messages_deleted.up.sql b/coderd/database/migrations/000446_chat_messages_deleted.up.sql new file mode 100644 index 0000000000..0f1310793c --- /dev/null +++ b/coderd/database/migrations/000446_chat_messages_deleted.up.sql @@ -0,0 +1 @@ +ALTER TABLE chat_messages ADD COLUMN deleted boolean NOT NULL DEFAULT false; diff --git a/coderd/database/models.go b/coderd/database/models.go index 65f4e0c10a..ec21b110db 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4225,6 +4225,7 @@ type ChatMessage struct { ContentVersion int16 `db:"content_version" json:"content_version"` TotalCostMicros sql.NullInt64 `db:"total_cost_micros" json:"total_cost_micros"` RuntimeMs sql.NullInt64 `db:"runtime_ms" json:"runtime_ms"` + Deleted bool `db:"deleted" json:"deleted"` } type ChatModelConfig struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index cc9885efa0..4ab7bb53a7 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -98,7 +98,6 @@ type sqlcQuerier interface { // be recreated. DeleteAllWebpushSubscriptions(ctx context.Context) error DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error - DeleteChatMessagesAfterID(ctx context.Context, arg DeleteChatMessagesAfterIDParams) error DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error DeleteChatProviderByID(ctx context.Context, id uuid.UUID) error DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error @@ -756,6 +755,8 @@ type sqlcQuerier interface { // for the table. // The CTE and the reorder is required because UPDATE doesn't guarantee order. SelectUsageEventsForPublishing(ctx context.Context, now time.Time) ([]UsageEvent, error) + SoftDeleteChatMessageByID(ctx context.Context, id int64) error + SoftDeleteChatMessagesAfterID(ctx context.Context, arg SoftDeleteChatMessagesAfterIDParams) error // Non blocking lock. Returns true if the lock was acquired, false otherwise. // // This must be called from within a transaction. The lock will be automatically diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 8aba6e9cb8..3221888f57 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3605,24 +3605,6 @@ func (q *sqlQuerier) DeleteAllChatQueuedMessages(ctx context.Context, chatID uui return err } -const deleteChatMessagesAfterID = `-- name: DeleteChatMessagesAfterID :exec -DELETE FROM - chat_messages -WHERE - chat_id = $1::uuid - AND id > $2::bigint -` - -type DeleteChatMessagesAfterIDParams struct { - ChatID uuid.UUID `db:"chat_id" json:"chat_id"` - AfterID int64 `db:"after_id" json:"after_id"` -} - -func (q *sqlQuerier) DeleteChatMessagesAfterID(ctx context.Context, arg DeleteChatMessagesAfterIDParams) error { - _, err := q.db.ExecContext(ctx, deleteChatMessagesAfterID, arg.ChatID, arg.AfterID) - return err -} - const deleteChatQueuedMessage = `-- name: DeleteChatQueuedMessage :exec DELETE FROM chat_queued_messages WHERE id = $1 AND chat_id = $2 ` @@ -4189,11 +4171,12 @@ func (q *sqlQuerier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds [ const getChatMessageByID = `-- name: GetChatMessageByID :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted FROM chat_messages WHERE id = $1::bigint + AND deleted = false ` func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMessage, error) { @@ -4219,19 +4202,21 @@ func (q *sqlQuerier) GetChatMessageByID(ctx context.Context, id int64) (ChatMess &i.ContentVersion, &i.TotalCostMicros, &i.RuntimeMs, + &i.Deleted, ) return i, err } const getChatMessagesByChatID = `-- name: GetChatMessagesByChatID :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted FROM chat_messages WHERE chat_id = $1::uuid AND id > $2::bigint AND visibility IN ('user', 'both') + AND deleted = false ORDER BY created_at ASC ` @@ -4270,6 +4255,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes &i.ContentVersion, &i.TotalCostMicros, &i.RuntimeMs, + &i.Deleted, ); err != nil { return nil, err } @@ -4286,7 +4272,7 @@ func (q *sqlQuerier) GetChatMessagesByChatID(ctx context.Context, arg GetChatMes const getChatMessagesByChatIDDescPaginated = `-- name: GetChatMessagesByChatIDDescPaginated :many SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted FROM chat_messages WHERE @@ -4296,6 +4282,7 @@ WHERE ELSE true END AND visibility IN ('user', 'both') + AND deleted = false ORDER BY id DESC LIMIT @@ -4337,6 +4324,7 @@ func (q *sqlQuerier) GetChatMessagesByChatIDDescPaginated(ctx context.Context, a &i.ContentVersion, &i.TotalCostMicros, &i.RuntimeMs, + &i.Deleted, ); err != nil { return nil, err } @@ -4360,6 +4348,7 @@ WITH latest_compressed_summary AS ( WHERE chat_id = $1::uuid AND compressed = TRUE + AND deleted = false AND visibility = 'model' ORDER BY created_at DESC, @@ -4368,12 +4357,13 @@ WITH latest_compressed_summary AS ( 1 ) SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted FROM chat_messages WHERE chat_id = $1::uuid AND visibility IN ('model', 'both') + AND deleted = false AND ( ( role = 'system' @@ -4437,6 +4427,7 @@ func (q *sqlQuerier) GetChatMessagesForPromptByChatID(ctx context.Context, chatI &i.ContentVersion, &i.TotalCostMicros, &i.RuntimeMs, + &i.Deleted, ); err != nil { return nil, err } @@ -4641,12 +4632,13 @@ func (q *sqlQuerier) GetChats(ctx context.Context, arg GetChatsParams) ([]Chat, const getLastChatMessageByRole = `-- name: GetLastChatMessageByRole :one SELECT - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted FROM chat_messages WHERE chat_id = $1::uuid AND role = $2::chat_message_role + AND deleted = false ORDER BY created_at DESC, id DESC LIMIT @@ -4681,6 +4673,7 @@ func (q *sqlQuerier) GetLastChatMessageByRole(ctx context.Context, arg GetLastCh &i.ContentVersion, &i.TotalCostMicros, &i.RuntimeMs, + &i.Deleted, ) return i, err } @@ -4908,7 +4901,7 @@ SELECT NULLIF(UNNEST($16::bigint[]), 0), NULLIF(UNNEST($17::bigint[]), 0) RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted ` type InsertChatMessagesParams struct { @@ -4978,6 +4971,7 @@ func (q *sqlQuerier) InsertChatMessages(ctx context.Context, arg InsertChatMessa &i.ContentVersion, &i.TotalCostMicros, &i.RuntimeMs, + &i.Deleted, ); err != nil { return nil, err } @@ -5174,6 +5168,40 @@ func (q *sqlQuerier) ResolveUserChatSpendLimit(ctx context.Context, userID uuid. return effective_limit_micros, err } +const softDeleteChatMessageByID = `-- name: SoftDeleteChatMessageByID :exec +UPDATE + chat_messages +SET + deleted = true +WHERE + id = $1::bigint +` + +func (q *sqlQuerier) SoftDeleteChatMessageByID(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, softDeleteChatMessageByID, id) + return err +} + +const softDeleteChatMessagesAfterID = `-- name: SoftDeleteChatMessagesAfterID :exec +UPDATE + chat_messages +SET + deleted = true +WHERE + chat_id = $1::uuid + AND id > $2::bigint +` + +type SoftDeleteChatMessagesAfterIDParams struct { + ChatID uuid.UUID `db:"chat_id" json:"chat_id"` + AfterID int64 `db:"after_id" json:"after_id"` +} + +func (q *sqlQuerier) SoftDeleteChatMessagesAfterID(ctx context.Context, arg SoftDeleteChatMessagesAfterIDParams) error { + _, err := q.db.ExecContext(ctx, softDeleteChatMessagesAfterID, arg.ChatID, arg.AfterID) + return err +} + const unarchiveChatByID = `-- name: UnarchiveChatByID :exec UPDATE chats SET archived = false, updated_at = NOW() WHERE id = $1::uuid ` @@ -5259,7 +5287,7 @@ SET WHERE id = $3::bigint RETURNING - id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms + id, chat_id, model_config_id, created_at, role, content, visibility, input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_creation_tokens, cache_read_tokens, context_limit, compressed, created_by, content_version, total_cost_micros, runtime_ms, deleted ` type UpdateChatMessageByIDParams struct { @@ -5291,6 +5319,7 @@ func (q *sqlQuerier) UpdateChatMessageByID(ctx context.Context, arg UpdateChatMe &i.ContentVersion, &i.TotalCostMicros, &i.RuntimeMs, + &i.Deleted, ) return i, err } diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index de10803224..d6d1e37653 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -5,13 +5,23 @@ WHERE id = @id OR root_chat_id = @id; -- name: UnarchiveChatByID :exec UPDATE chats SET archived = false, updated_at = NOW() WHERE id = @id::uuid; --- name: DeleteChatMessagesAfterID :exec -DELETE FROM +-- name: SoftDeleteChatMessagesAfterID :exec +UPDATE chat_messages +SET + deleted = true WHERE chat_id = @chat_id::uuid AND id > @after_id::bigint; +-- name: SoftDeleteChatMessageByID :exec +UPDATE + chat_messages +SET + deleted = true +WHERE + id = @id::bigint; + -- name: GetChatByID :one SELECT * @@ -26,7 +36,8 @@ SELECT FROM chat_messages WHERE - id = @id::bigint; + id = @id::bigint + AND deleted = false; -- name: GetChatMessagesByChatID :many SELECT @@ -37,6 +48,7 @@ WHERE chat_id = @chat_id::uuid AND id > @after_id::bigint AND visibility IN ('user', 'both') + AND deleted = false ORDER BY created_at ASC; @@ -52,6 +64,7 @@ WHERE ELSE true END AND visibility IN ('user', 'both') + AND deleted = false ORDER BY id DESC LIMIT @@ -66,6 +79,7 @@ WITH latest_compressed_summary AS ( WHERE chat_id = @chat_id::uuid AND compressed = TRUE + AND deleted = false AND visibility = 'model' ORDER BY created_at DESC, @@ -80,6 +94,7 @@ FROM WHERE chat_id = @chat_id::uuid AND visibility IN ('model', 'both') + AND deleted = false AND ( ( role = 'system' @@ -496,6 +511,7 @@ FROM WHERE chat_id = @chat_id::uuid AND role = @role::chat_message_role + AND deleted = false ORDER BY created_at DESC, id DESC LIMIT