refactor(reaction): link reactions to memo IDs (#6221)
Backend Tests / Static Checks (push) Waiting to run
Backend Tests / Tests (internal) (push) Waiting to run
Backend Tests / Tests (other) (push) Waiting to run
Backend Tests / Tests (server) (push) Waiting to run
Backend Tests / Tests (store) (push) Waiting to run
Build Canary Image / build-frontend (push) Waiting to run
Build Canary Image / build-push (linux/amd64) (push) Blocked by required conditions
Build Canary Image / build-push (linux/arm64) (push) Blocked by required conditions
Build Canary Image / merge (push) Blocked by required conditions
Frontend Tests / Lint (push) Waiting to run
Frontend Tests / Build (push) Waiting to run
Proto Linter / Lint Protos (push) Waiting to run
Release Please / release-please (push) Waiting to run
Backend Tests / Static Checks (push) Waiting to run
Backend Tests / Tests (internal) (push) Waiting to run
Backend Tests / Tests (other) (push) Waiting to run
Backend Tests / Tests (server) (push) Waiting to run
Backend Tests / Tests (store) (push) Waiting to run
Build Canary Image / build-frontend (push) Waiting to run
Build Canary Image / build-push (linux/amd64) (push) Blocked by required conditions
Build Canary Image / build-push (linux/arm64) (push) Blocked by required conditions
Build Canary Image / merge (push) Blocked by required conditions
Frontend Tests / Lint (push) Waiting to run
Frontend Tests / Build (push) Waiting to run
Proto Linter / Lint Protos (push) Waiting to run
Release Please / release-please (push) Waiting to run
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
# Memos
|
||||
|
||||
Memos is the note-taking domain centered on short-form memos and the resources attached to them.
|
||||
|
||||
## Language
|
||||
|
||||
**Memo ID**:
|
||||
The stable internal identity of a memo. It does not change when a memo's public identifier changes.
|
||||
_Avoid_: Memo UID, slug
|
||||
|
||||
**Memo UID**:
|
||||
The unique public identifier used in a memo resource name and URL. It may be user-defined when the memo is created or generated automatically.
|
||||
_Avoid_: Memo ID, database ID
|
||||
|
||||
**Memo resource name**:
|
||||
The API identity of a memo in the form `memos/{memo UID}`.
|
||||
_Avoid_: Memo ID, content ID
|
||||
|
||||
**Memo reaction**:
|
||||
A response attached to exactly one memo. Reactions are not a generic mechanism for targeting other kinds of content.
|
||||
_Avoid_: Content reaction, generic reaction, reaction target
|
||||
@@ -96,8 +96,7 @@ service MemoService {
|
||||
option (google.api.http) = {get: "/api/v1/{name=memos/*}/reactions"};
|
||||
option (google.api.method_signature) = "name";
|
||||
}
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a
|
||||
// memo. The reaction's content_id is the memo's resource name (memos/{memo}).
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a memo.
|
||||
rpc UpsertMemoReaction(UpsertMemoReactionRequest) returns (Reaction) {
|
||||
option (google.api.http) = {
|
||||
post: "/api/v1/{name=memos/*}/reactions"
|
||||
@@ -157,6 +156,7 @@ enum Visibility {
|
||||
PUBLIC = 3;
|
||||
}
|
||||
|
||||
// Reaction is a reaction attached to a memo.
|
||||
message Reaction {
|
||||
option (google.api.resource) = {
|
||||
type: "memos.api.v1/Reaction"
|
||||
@@ -180,13 +180,8 @@ message Reaction {
|
||||
(google.api.resource_reference) = {type: "memos.api.v1/User"}
|
||||
];
|
||||
|
||||
// The resource name of the content.
|
||||
// For memo reactions, this should be the memo's resource name.
|
||||
// Format: memos/{memo}
|
||||
string content_id = 3 [
|
||||
(google.api.field_behavior) = REQUIRED,
|
||||
(google.api.resource_reference) = {type: "memos.api.v1/Memo"}
|
||||
];
|
||||
reserved 3;
|
||||
reserved "content_id";
|
||||
|
||||
// Required. The type of reaction (e.g., "👍", "❤️", "😄").
|
||||
string reaction_type = 4 [(google.api.field_behavior) = REQUIRED];
|
||||
|
||||
@@ -123,8 +123,7 @@ type MemoServiceClient interface {
|
||||
ListMemoComments(context.Context, *connect.Request[v1.ListMemoCommentsRequest]) (*connect.Response[v1.ListMemoCommentsResponse], error)
|
||||
// ListMemoReactions lists reactions for a memo.
|
||||
ListMemoReactions(context.Context, *connect.Request[v1.ListMemoReactionsRequest]) (*connect.Response[v1.ListMemoReactionsResponse], error)
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a
|
||||
// memo. The reaction's content_id is the memo's resource name (memos/{memo}).
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a memo.
|
||||
UpsertMemoReaction(context.Context, *connect.Request[v1.UpsertMemoReactionRequest]) (*connect.Response[v1.Reaction], error)
|
||||
// DeleteMemoReaction deletes a reaction for a memo.
|
||||
DeleteMemoReaction(context.Context, *connect.Request[v1.DeleteMemoReactionRequest]) (*connect.Response[emptypb.Empty], error)
|
||||
@@ -433,8 +432,7 @@ type MemoServiceHandler interface {
|
||||
ListMemoComments(context.Context, *connect.Request[v1.ListMemoCommentsRequest]) (*connect.Response[v1.ListMemoCommentsResponse], error)
|
||||
// ListMemoReactions lists reactions for a memo.
|
||||
ListMemoReactions(context.Context, *connect.Request[v1.ListMemoReactionsRequest]) (*connect.Response[v1.ListMemoReactionsResponse], error)
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a
|
||||
// memo. The reaction's content_id is the memo's resource name (memos/{memo}).
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a memo.
|
||||
UpsertMemoReaction(context.Context, *connect.Request[v1.UpsertMemoReactionRequest]) (*connect.Response[v1.Reaction], error)
|
||||
// DeleteMemoReaction deletes a reaction for a memo.
|
||||
DeleteMemoReaction(context.Context, *connect.Request[v1.DeleteMemoReactionRequest]) (*connect.Response[emptypb.Empty], error)
|
||||
|
||||
@@ -131,6 +131,7 @@ func (MemoRelation_Type) EnumDescriptor() ([]byte, []int) {
|
||||
return file_api_v1_memo_service_proto_rawDescGZIP(), []int{12, 0}
|
||||
}
|
||||
|
||||
// Reaction is a reaction attached to a memo.
|
||||
type Reaction struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The resource name of the reaction.
|
||||
@@ -139,10 +140,6 @@ type Reaction struct {
|
||||
// The resource name of the creator.
|
||||
// Format: users/{user}
|
||||
Creator string `protobuf:"bytes,2,opt,name=creator,proto3" json:"creator,omitempty"`
|
||||
// The resource name of the content.
|
||||
// For memo reactions, this should be the memo's resource name.
|
||||
// Format: memos/{memo}
|
||||
ContentId string `protobuf:"bytes,3,opt,name=content_id,json=contentId,proto3" json:"content_id,omitempty"`
|
||||
// Required. The type of reaction (e.g., "👍", "❤️", "😄").
|
||||
ReactionType string `protobuf:"bytes,4,opt,name=reaction_type,json=reactionType,proto3" json:"reaction_type,omitempty"`
|
||||
// Output only. The creation timestamp.
|
||||
@@ -195,13 +192,6 @@ func (x *Reaction) GetCreator() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Reaction) GetContentId() string {
|
||||
if x != nil {
|
||||
return x.ContentId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Reaction) GetReactionType() string {
|
||||
if x != nil {
|
||||
return x.ReactionType
|
||||
@@ -2316,18 +2306,16 @@ var File_api_v1_memo_service_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_api_v1_memo_service_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x19api/v1/memo_service.proto\x12\fmemos.api.v1\x1a\x1fapi/v1/attachment_service.proto\x1a\x13api/v1/common.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xdb\x02\n" +
|
||||
"\x19api/v1/memo_service.proto\x12\fmemos.api.v1\x1a\x1fapi/v1/attachment_service.proto\x1a\x13api/v1/common.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xb3\x02\n" +
|
||||
"\bReaction\x12\x1a\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x06\xe0A\x03\xe0A\bR\x04name\x123\n" +
|
||||
"\acreator\x18\x02 \x01(\tB\x19\xe0A\x03\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/UserR\acreator\x128\n" +
|
||||
"\n" +
|
||||
"content_id\x18\x03 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/MemoR\tcontentId\x12(\n" +
|
||||
"\x11memos.api.v1/UserR\acreator\x12(\n" +
|
||||
"\rreaction_type\x18\x04 \x01(\tB\x03\xe0A\x02R\freactionType\x12@\n" +
|
||||
"\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
|
||||
"createTime:X\xeaAU\n" +
|
||||
"\x15memos.api.v1/Reaction\x12!memos/{memo}/reactions/{reaction}\x1a\x04name*\treactions2\breaction\"\xbe\b\n" +
|
||||
"\x15memos.api.v1/Reaction\x12!memos/{memo}/reactions/{reaction}\x1a\x04name*\treactions2\breactionJ\x04\b\x03\x10\x04R\n" +
|
||||
"content_id\"\xbe\b\n" +
|
||||
"\x04Memo\x12\x17\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12.\n" +
|
||||
"\x05state\x18\x02 \x01(\x0e2\x13.memos.api.v1.StateB\x03\xe0A\x02R\x05state\x123\n" +
|
||||
|
||||
@@ -76,8 +76,7 @@ type MemoServiceClient interface {
|
||||
ListMemoComments(ctx context.Context, in *ListMemoCommentsRequest, opts ...grpc.CallOption) (*ListMemoCommentsResponse, error)
|
||||
// ListMemoReactions lists reactions for a memo.
|
||||
ListMemoReactions(ctx context.Context, in *ListMemoReactionsRequest, opts ...grpc.CallOption) (*ListMemoReactionsResponse, error)
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a
|
||||
// memo. The reaction's content_id is the memo's resource name (memos/{memo}).
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a memo.
|
||||
UpsertMemoReaction(ctx context.Context, in *UpsertMemoReactionRequest, opts ...grpc.CallOption) (*Reaction, error)
|
||||
// DeleteMemoReaction deletes a reaction for a memo.
|
||||
DeleteMemoReaction(ctx context.Context, in *DeleteMemoReactionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
@@ -338,8 +337,7 @@ type MemoServiceServer interface {
|
||||
ListMemoComments(context.Context, *ListMemoCommentsRequest) (*ListMemoCommentsResponse, error)
|
||||
// ListMemoReactions lists reactions for a memo.
|
||||
ListMemoReactions(context.Context, *ListMemoReactionsRequest) (*ListMemoReactionsResponse, error)
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a
|
||||
// memo. The reaction's content_id is the memo's resource name (memos/{memo}).
|
||||
// UpsertMemoReaction adds or updates the authenticated user's reaction on a memo.
|
||||
UpsertMemoReaction(context.Context, *UpsertMemoReactionRequest) (*Reaction, error)
|
||||
// DeleteMemoReaction deletes a reaction for a memo.
|
||||
DeleteMemoReaction(context.Context, *DeleteMemoReactionRequest) (*emptypb.Empty, error)
|
||||
|
||||
+2
-10
@@ -1054,9 +1054,7 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- MemoService
|
||||
description: |-
|
||||
UpsertMemoReaction adds or updates the authenticated user's reaction on a
|
||||
memo. The reaction's content_id is the memo's resource name (memos/{memo}).
|
||||
description: UpsertMemoReaction adds or updates the authenticated user's reaction on a memo.
|
||||
operationId: MemoService_UpsertMemoReaction
|
||||
parameters:
|
||||
- name: memo
|
||||
@@ -3599,7 +3597,6 @@ components:
|
||||
format: double
|
||||
Reaction:
|
||||
required:
|
||||
- contentId
|
||||
- reactionType
|
||||
type: object
|
||||
properties:
|
||||
@@ -3615,12 +3612,6 @@ components:
|
||||
description: |-
|
||||
The resource name of the creator.
|
||||
Format: users/{user}
|
||||
contentId:
|
||||
type: string
|
||||
description: |-
|
||||
The resource name of the content.
|
||||
For memo reactions, this should be the memo's resource name.
|
||||
Format: memos/{memo}
|
||||
reactionType:
|
||||
type: string
|
||||
description: "Required. The type of reaction (e.g., \"\U0001F44D\", \"❤️\", \"\U0001F604\")."
|
||||
@@ -3629,6 +3620,7 @@ components:
|
||||
type: string
|
||||
description: Output only. The creation timestamp.
|
||||
format: date-time
|
||||
description: Reaction is a reaction attached to a memo.
|
||||
RefreshTokenRequest:
|
||||
type: object
|
||||
properties: {}
|
||||
|
||||
@@ -287,24 +287,22 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
|
||||
return response, nil
|
||||
}
|
||||
|
||||
reactionMap := make(map[string][]*store.Reaction)
|
||||
contentIDs := make([]string, 0, len(memos))
|
||||
reactionMap := make(map[int32][]*store.Reaction)
|
||||
|
||||
attachmentMap := make(map[int32][]*store.Attachment)
|
||||
memoIDs := make([]int32, 0, len(memos))
|
||||
|
||||
for _, m := range memos {
|
||||
contentIDs = append(contentIDs, fmt.Sprintf("%s%s", MemoNamePrefix, m.UID))
|
||||
memoIDs = append(memoIDs, m.ID)
|
||||
}
|
||||
|
||||
// REACTIONS
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs})
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{MemoIDList: memoIDs})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||||
}
|
||||
for _, reaction := range reactions {
|
||||
reactionMap[reaction.ContentID] = append(reactionMap[reaction.ContentID], reaction)
|
||||
reactionMap[reaction.MemoID] = append(reactionMap[reaction.MemoID], reaction)
|
||||
}
|
||||
|
||||
// ATTACHMENTS
|
||||
@@ -333,8 +331,7 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
|
||||
}
|
||||
for _, memo := range memos {
|
||||
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
|
||||
reactions := reactionMap[memoName]
|
||||
reactions := reactionMap[memo.ID]
|
||||
attachments := attachmentMap[memo.ID]
|
||||
relations := relationMap[memo.ID]
|
||||
|
||||
@@ -381,7 +378,7 @@ func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest
|
||||
}
|
||||
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &request.Name,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||||
@@ -614,7 +611,7 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
|
||||
}
|
||||
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &request.Name,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||||
@@ -635,7 +632,7 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
|
||||
}
|
||||
}
|
||||
|
||||
// Delete memo comments first (store.DeleteMemo handles their relations and attachments)
|
||||
// Delete memo comments first (store.DeleteMemo handles their reactions, relations and attachments)
|
||||
commentType := store.MemoRelationComment
|
||||
relations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{RelatedMemoID: &memo.ID, Type: &commentType})
|
||||
if err != nil {
|
||||
@@ -647,7 +644,7 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the memo (store.DeleteMemo handles relation and attachment cleanup)
|
||||
// Delete the memo (store.DeleteMemo handles reaction, relation and attachment cleanup)
|
||||
if err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete memo")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package v1
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -203,27 +202,22 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
|
||||
return nil, status.Errorf(codes.Internal, "failed to list memos")
|
||||
}
|
||||
|
||||
memoIDToNameMap := make(map[int32]string)
|
||||
contentIDs := make([]string, 0, len(memos))
|
||||
memoIDsForAttachments := make([]int32, 0, len(memos))
|
||||
memoIDs := make([]int32, 0, len(memos))
|
||||
|
||||
for _, memo := range memos {
|
||||
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
|
||||
memoIDToNameMap[memo.ID] = memoName
|
||||
contentIDs = append(contentIDs, memoName)
|
||||
memoIDsForAttachments = append(memoIDsForAttachments, memo.ID)
|
||||
memoIDs = append(memoIDs, memo.ID)
|
||||
}
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs})
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{MemoIDList: memoIDs})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||||
}
|
||||
|
||||
memoReactionsMap := make(map[string][]*store.Reaction)
|
||||
memoReactionsMap := make(map[int32][]*store.Reaction)
|
||||
for _, reaction := range reactions {
|
||||
memoReactionsMap[reaction.ContentID] = append(memoReactionsMap[reaction.ContentID], reaction)
|
||||
memoReactionsMap[reaction.MemoID] = append(memoReactionsMap[reaction.MemoID], reaction)
|
||||
}
|
||||
|
||||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDsForAttachments})
|
||||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDs})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
||||
}
|
||||
@@ -250,8 +244,7 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
|
||||
}
|
||||
var memosResponse []*v1pb.Memo
|
||||
for _, m := range memos {
|
||||
memoName := memoIDToNameMap[m.ID]
|
||||
reactions := memoReactionsMap[memoName]
|
||||
reactions := memoReactionsMap[m.ID]
|
||||
attachments := attachmentMap[m.ID]
|
||||
relations := relationMap[m.ID]
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
|
||||
}
|
||||
|
||||
func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation, creatorMap map[int32]*store.User) (*v1pb.Memo, error) {
|
||||
name := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
|
||||
name := buildMemoName(memo.UID)
|
||||
creator := creatorMap[memo.CreatorID]
|
||||
if creator == nil {
|
||||
return nil, errMemoCreatorNotFound
|
||||
@@ -51,11 +51,11 @@ func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, mem
|
||||
}
|
||||
|
||||
if memo.ParentUID != nil {
|
||||
parentName := fmt.Sprintf("%s%s", MemoNamePrefix, *memo.ParentUID)
|
||||
parentName := buildMemoName(*memo.ParentUID)
|
||||
memoMessage.Parent = &parentName
|
||||
}
|
||||
|
||||
reactionMessages, err := s.convertReactionsFromStoreWithCreators(ctx, reactions, creatorMap)
|
||||
reactionMessages, err := s.convertReactionsFromStoreWithCreators(ctx, reactions, creatorMap, name)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to convert reactions")
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (s *APIV1Service) listUsersByIDWithExisting(ctx context.Context, userIDs []
|
||||
return usersByID, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) convertReactionsFromStoreWithCreators(ctx context.Context, reactions []*store.Reaction, creatorMap map[int32]*store.User) ([]*v1pb.Reaction, error) {
|
||||
func (s *APIV1Service) convertReactionsFromStoreWithCreators(ctx context.Context, reactions []*store.Reaction, creatorMap map[int32]*store.User, memoName string) ([]*v1pb.Reaction, error) {
|
||||
if len(reactions) == 0 {
|
||||
return []*v1pb.Reaction{}, nil
|
||||
}
|
||||
@@ -135,13 +135,13 @@ func (s *APIV1Service) convertReactionsFromStoreWithCreators(ctx context.Context
|
||||
|
||||
reactionMessages := make([]*v1pb.Reaction, 0, len(reactions))
|
||||
for _, reaction := range reactions {
|
||||
reactionMessage, err := convertReactionFromStoreWithCreators(reaction, creatorsByID)
|
||||
reactionMessage, err := convertReactionFromStoreWithCreators(reaction, creatorsByID, memoName)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, errReactionCreatorNotFound) {
|
||||
slog.Warn("Skipping reaction with missing creator",
|
||||
slog.Int64("reaction_id", int64(reaction.ID)),
|
||||
slog.Int64("creator_id", int64(reaction.CreatorID)),
|
||||
slog.String("content_id", reaction.ContentID),
|
||||
slog.Int64("memo_id", int64(reaction.MemoID)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (s *APIV1Service) convertReactionsFromStoreWithCreators(ctx context.Context
|
||||
return reactionMessages, nil
|
||||
}
|
||||
|
||||
func convertReactionFromStoreWithCreators(reaction *store.Reaction, creatorsByID map[int32]*store.User) (*v1pb.Reaction, error) {
|
||||
func convertReactionFromStoreWithCreators(reaction *store.Reaction, creatorsByID map[int32]*store.User, memoName string) (*v1pb.Reaction, error) {
|
||||
creator := creatorsByID[reaction.CreatorID]
|
||||
if creator == nil {
|
||||
return nil, errReactionCreatorNotFound
|
||||
@@ -160,9 +160,8 @@ func convertReactionFromStoreWithCreators(reaction *store.Reaction, creatorsByID
|
||||
|
||||
reactionUID := fmt.Sprintf("%d", reaction.ID)
|
||||
return &v1pb.Reaction{
|
||||
Name: fmt.Sprintf("%s/%s%s", reaction.ContentID, ReactionNamePrefix, reactionUID),
|
||||
Name: fmt.Sprintf("%s/%s%s", memoName, ReactionNamePrefix, reactionUID),
|
||||
Creator: BuildUserName(creator.Username),
|
||||
ContentId: reaction.ContentID,
|
||||
ReactionType: reaction.ReactionType,
|
||||
CreateTime: timestamppb.New(time.Unix(reaction.CreatedTs, 0)),
|
||||
}, nil
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *APIV1Service) GetSharedMemo(ctx context.Context, request *v1pb.GetShare
|
||||
}
|
||||
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: stringPointer(fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)),
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||||
@@ -212,10 +212,6 @@ func (s *APIV1Service) getActiveMemoShare(ctx context.Context, shareID string) (
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func stringPointer(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// convertMemoShareFromStore converts a store MemoShare to the proto MemoShare message.
|
||||
// name format: memos/{memoUID}/shares/{shareToken}.
|
||||
func convertMemoShareFromStore(ms *store.MemoShare, memoUID string) *v1pb.MemoShare {
|
||||
|
||||
@@ -19,9 +19,8 @@ func (s *APIV1Service) buildUpdatedMemoState(ctx context.Context, memoID int32)
|
||||
return nil, nil, nil, errors.New("memo not found")
|
||||
}
|
||||
|
||||
memoName := buildMemoName(memo.UID)
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &memoName,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, errors.Wrap(err, "failed to list reactions")
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"log/slog"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -41,7 +42,7 @@ func (s *APIV1Service) ListMemoReactions(ctx context.Context, request *v1pb.List
|
||||
}
|
||||
|
||||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &request.Name,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||||
@@ -50,7 +51,7 @@ func (s *APIV1Service) ListMemoReactions(ctx context.Context, request *v1pb.List
|
||||
response := &v1pb.ListMemoReactionsResponse{
|
||||
Reactions: []*v1pb.Reaction{},
|
||||
}
|
||||
response.Reactions, err = s.convertReactionsFromStoreWithCreators(ctx, reactions, nil)
|
||||
response.Reactions, err = s.convertReactionsFromStoreWithCreators(ctx, reactions, nil, buildMemoName(memo.UID))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert reactions")
|
||||
}
|
||||
@@ -67,7 +68,7 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups
|
||||
}
|
||||
|
||||
// Extract memo UID and check visibility before allowing reaction.
|
||||
memoUID, err := ExtractMemoUIDFromName(request.Reaction.ContentId)
|
||||
memoUID, err := ExtractMemoUIDFromName(request.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||||
}
|
||||
@@ -86,14 +87,18 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups
|
||||
|
||||
reaction, err := s.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: request.Reaction.ContentId,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: request.Reaction.ReactionType,
|
||||
})
|
||||
if err != nil {
|
||||
if stderrors.Is(err, store.ErrReactionMemoNotFound) {
|
||||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "failed to upsert reaction")
|
||||
}
|
||||
|
||||
reactionMessage, err := s.convertReactionFromStore(ctx, reaction)
|
||||
memoName := buildMemoName(memo.UID)
|
||||
reactionMessage, err := s.convertReactionFromStore(ctx, reaction, memoName)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to convert reaction")
|
||||
}
|
||||
@@ -103,7 +108,7 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups
|
||||
if memo.ParentUID != nil {
|
||||
parentMemo, _ = s.Store.GetMemo(ctx, &store.FindMemo{UID: memo.ParentUID})
|
||||
}
|
||||
s.SSEHub.Broadcast(buildMemoReactionSSEEvent(SSEEventReactionUpserted, request.Reaction.ContentId, memo, parentMemo))
|
||||
s.SSEHub.Broadcast(buildMemoReactionSSEEvent(SSEEventReactionUpserted, memoName, memo, parentMemo))
|
||||
|
||||
return reactionMessage, nil
|
||||
}
|
||||
@@ -137,43 +142,40 @@ func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.Del
|
||||
if reaction.CreatorID != user.ID && !isSuperUser(user) {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteReaction(ctx, &store.DeleteReaction{
|
||||
ID: reactionID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete reaction")
|
||||
}
|
||||
|
||||
memoUID, err := ExtractMemoUIDFromName(reaction.ContentID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||||
}
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
||||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &reaction.MemoID})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
||||
}
|
||||
|
||||
// Broadcast live refresh event (reaction belongs to a memo).
|
||||
var parentMemo *store.Memo
|
||||
if memo != nil && memo.ParentUID != nil {
|
||||
parentMemo, _ = s.Store.GetMemo(ctx, &store.FindMemo{UID: memo.ParentUID})
|
||||
if err := s.Store.DeleteReaction(ctx, &store.DeleteReaction{ID: &reactionID}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete reaction")
|
||||
}
|
||||
|
||||
// A concurrent memo deletion also removes its reactions, so the delete above
|
||||
// stays idempotent. There is no memo left to broadcast a refresh for.
|
||||
if memo != nil {
|
||||
// Broadcast live refresh event (reaction belongs to a memo).
|
||||
var parentMemo *store.Memo
|
||||
if memo.ParentUID != nil {
|
||||
parentMemo, _ = s.Store.GetMemo(ctx, &store.FindMemo{UID: memo.ParentUID})
|
||||
}
|
||||
s.SSEHub.Broadcast(buildMemoReactionSSEEvent(SSEEventReactionDeleted, buildMemoName(memo.UID), memo, parentMemo))
|
||||
}
|
||||
s.SSEHub.Broadcast(buildMemoReactionSSEEvent(SSEEventReactionDeleted, reaction.ContentID, memo, parentMemo))
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) convertReactionFromStore(ctx context.Context, reaction *store.Reaction) (*v1pb.Reaction, error) {
|
||||
func (s *APIV1Service) convertReactionFromStore(ctx context.Context, reaction *store.Reaction, memoName string) (*v1pb.Reaction, error) {
|
||||
creatorsByID, err := s.listUsersByIDWithExisting(ctx, []int32{reaction.CreatorID}, nil)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get reaction creator")
|
||||
}
|
||||
reactionMessage, err := convertReactionFromStoreWithCreators(reaction, creatorsByID)
|
||||
reactionMessage, err := convertReactionFromStoreWithCreators(reaction, creatorsByID, memoName)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to convert reaction with missing creator",
|
||||
slog.Int64("reaction_id", int64(reaction.ID)),
|
||||
slog.Int64("creator_id", int64(reaction.CreatorID)),
|
||||
slog.String("content_id", reaction.ContentID),
|
||||
slog.Int64("memo_id", int64(reaction.MemoID)),
|
||||
)
|
||||
return nil, status.Errorf(codes.NotFound, "reaction creator not found")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func resolveSSECreatorID(memo *store.Memo, parentMemo *store.Memo) int32 {
|
||||
|
||||
// buildMemoReactionSSEEvent constructs an SSEEvent for a reaction on a memo.
|
||||
// Pass parentMemo when the memo is a comment (memo.ParentUID != nil).
|
||||
func buildMemoReactionSSEEvent(eventType SSEEventType, contentID string, memo *store.Memo, parentMemo *store.Memo) *SSEEvent {
|
||||
func buildMemoReactionSSEEvent(eventType SSEEventType, memoName string, memo *store.Memo, parentMemo *store.Memo) *SSEEvent {
|
||||
parent := ""
|
||||
if memo != nil && memo.ParentUID != nil {
|
||||
parent = buildMemoName(*memo.ParentUID)
|
||||
@@ -32,7 +32,7 @@ func buildMemoReactionSSEEvent(eventType SSEEventType, contentID string, memo *s
|
||||
}
|
||||
return &SSEEvent{
|
||||
Type: eventType,
|
||||
Name: contentID,
|
||||
Name: memoName,
|
||||
Parent: parent,
|
||||
Visibility: visibility,
|
||||
CreatorID: resolveSSECreatorID(memo, parentMemo),
|
||||
|
||||
@@ -178,7 +178,6 @@ func TestUpsertMemoReaction_SSEEvent(t *testing.T) {
|
||||
_, err = svc.UpsertMemoReaction(uctx, &v1pb.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &v1pb.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
@@ -209,7 +208,6 @@ func TestDeleteMemoReaction_SSEEvent(t *testing.T) {
|
||||
reaction, err := svc.UpsertMemoReaction(uctx, &v1pb.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &v1pb.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "❤️",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -141,7 +141,7 @@ func seedListMemosBenchmarkData(ctx context.Context, stores *store.Store, hostUs
|
||||
if i%5 == 0 {
|
||||
if _, err := stores.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: hostUser.ID,
|
||||
ContentID: "memos/" + memo.UID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "thumbs-up",
|
||||
}); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -177,7 +177,6 @@ func TestListMemos(t *testing.T) {
|
||||
reactionOne, err := ts.Service.UpsertMemoReaction(userOneCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memoThree.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memoThree.Name,
|
||||
ReactionType: "❤️",
|
||||
},
|
||||
})
|
||||
@@ -189,7 +188,6 @@ func TestListMemos(t *testing.T) {
|
||||
reactionTwo, err := ts.Service.UpsertMemoReaction(userTwoCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memoThree.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memoThree.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
@@ -450,7 +448,6 @@ func TestListMemosSkipsReactionsWithMissingCreators(t *testing.T) {
|
||||
_, err = ts.Service.UpsertMemoReaction(reactorCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -87,7 +87,6 @@ func TestGetSharedMemo_IncludesReactions(t *testing.T) {
|
||||
reaction, err := ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
@@ -107,7 +106,7 @@ func TestGetSharedMemo_IncludesReactions(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, sharedMemo.Reactions, 1)
|
||||
require.Equal(t, "👍", sharedMemo.Reactions[0].ReactionType)
|
||||
require.Equal(t, memo.Name, sharedMemo.Reactions[0].ContentId)
|
||||
require.Equal(t, reaction.Name, sharedMemo.Reactions[0].Name)
|
||||
}
|
||||
|
||||
func TestCreateMemoShare_RejectsComment(t *testing.T) {
|
||||
@@ -185,7 +184,6 @@ func TestGetSharedMemo_SkipsReactionsWithMissingCreators(t *testing.T) {
|
||||
_, err = ts.Service.UpsertMemoReaction(reactorCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -10,6 +11,82 @@ import (
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func TestMemoReactionResourceNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
user, err := ts.CreateRegularUser(ctx, "reaction-resource-user")
|
||||
require.NoError(t, err)
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
MemoId: "reaction-resource-memo",
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "reaction resource names",
|
||||
Visibility: apiv1.Visibility_PUBLIC,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reaction, err := ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, strings.HasPrefix(reaction.Name, memo.Name+"/reactions/"))
|
||||
|
||||
memoID := parseMemoIDFromNameForTest(t, ts, memo.Name)
|
||||
storedReaction, err := ts.Store.GetReaction(ctx, &store.FindReaction{MemoID: &memoID, CreatorID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedReaction)
|
||||
require.Equal(t, memoID, storedReaction.MemoID)
|
||||
|
||||
listed, err := ts.Service.ListMemoReactions(ctx, &apiv1.ListMemoReactionsRequest{Name: memo.Name})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listed.Reactions, 1)
|
||||
require.Equal(t, reaction.Name, listed.Reactions[0].Name)
|
||||
|
||||
fetchedMemo, err := ts.Service.GetMemo(ctx, &apiv1.GetMemoRequest{Name: memo.Name})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fetchedMemo.Reactions, 1)
|
||||
require.Equal(t, reaction.Name, fetchedMemo.Reactions[0].Name)
|
||||
}
|
||||
|
||||
func TestListMemoCommentsIncludesReactionResourceNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
user, err := ts.CreateRegularUser(ctx, "comment-reaction-user")
|
||||
require.NoError(t, err)
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
parent, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{Content: "parent", Visibility: apiv1.Visibility_PUBLIC},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
comment, err := ts.Service.CreateMemoComment(userCtx, &apiv1.CreateMemoCommentRequest{
|
||||
Name: parent.Name,
|
||||
Comment: &apiv1.Memo{Content: "comment", Visibility: apiv1.Visibility_PUBLIC},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: comment.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ReactionType: "🔥",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
comments, err := ts.Service.ListMemoComments(userCtx, &apiv1.ListMemoCommentsRequest{Name: parent.Name})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, comments.Memos, 1)
|
||||
require.Len(t, comments.Memos[0].Reactions, 1)
|
||||
require.True(t, strings.HasPrefix(comments.Memos[0].Reactions[0].Name, comment.Name+"/reactions/"))
|
||||
}
|
||||
|
||||
func TestDeleteMemoReaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -36,7 +113,6 @@ func TestDeleteMemoReaction(t *testing.T) {
|
||||
reaction, err := ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
@@ -51,6 +127,41 @@ func TestDeleteMemoReaction(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("DeleteMemoReaction succeeds when memo was concurrently deleted", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
user, err := ts.CreateRegularUser(ctx, "concurrent-delete-user")
|
||||
require.NoError(t, err)
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{Content: "concurrent delete", Visibility: apiv1.Visibility_PUBLIC},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
reaction, err := ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
memoID := parseMemoIDFromNameForTest(t, ts, memo.Name)
|
||||
storedReaction, err := ts.Store.GetReaction(ctx, &store.FindReaction{MemoID: &memoID, CreatorID: &user.ID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, storedReaction)
|
||||
// Simulate the state visible between DeleteMemoReaction's reaction and
|
||||
// memo reads after a concurrent memo deletion commits.
|
||||
_, err = ts.Store.GetDriver().GetDB().ExecContext(ctx, "DELETE FROM memo WHERE id = ?", memoID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.Service.DeleteMemoReaction(userCtx, &apiv1.DeleteMemoReactionRequest{Name: reaction.Name})
|
||||
require.NoError(t, err)
|
||||
deletedReaction, err := ts.Store.GetReaction(ctx, &store.FindReaction{ID: &storedReaction.ID})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, deletedReaction)
|
||||
})
|
||||
|
||||
t.Run("DeleteMemoReaction success by host user", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
@@ -79,7 +190,6 @@ func TestDeleteMemoReaction(t *testing.T) {
|
||||
reaction, err := ts.Service.UpsertMemoReaction(regularUserCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
@@ -121,7 +231,6 @@ func TestDeleteMemoReaction(t *testing.T) {
|
||||
reaction, err := ts.Service.UpsertMemoReaction(user1Ctx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
@@ -159,7 +268,6 @@ func TestDeleteMemoReaction(t *testing.T) {
|
||||
reaction, err := ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "👍",
|
||||
},
|
||||
})
|
||||
@@ -220,7 +328,6 @@ func TestListMemoReactionsSkipsMissingCreators(t *testing.T) {
|
||||
_, err = ts.Service.UpsertMemoReaction(reactorCtx, &apiv1.UpsertMemoReactionRequest{
|
||||
Name: memo.Name,
|
||||
Reaction: &apiv1.Reaction{
|
||||
ContentId: memo.Name,
|
||||
ReactionType: "🔥",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -146,6 +146,7 @@ func TestDeleteUserSelfDeleteRemovesOwnedResourcesAndMemoSubtrees(t *testing.T)
|
||||
foreignMemoStore, err := ts.Store.GetMemo(ctx, &store.FindMemo{UID: &foreignMemoUID})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, foreignMemoStore)
|
||||
userCommentOnForeignMemoID := parseMemoIDFromNameForTest(t, ts, userCommentOnForeignMemo.Name)
|
||||
|
||||
attachedAttachment, err := ts.Store.CreateAttachment(ctx, &store.Attachment{
|
||||
UID: "attach-owner-memo",
|
||||
@@ -187,25 +188,25 @@ func TestDeleteUserSelfDeleteRemovesOwnedResourcesAndMemoSubtrees(t *testing.T)
|
||||
|
||||
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: ownMemo.Name,
|
||||
MemoID: ownMemoStore.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: userCommentOnForeignMemo.Name,
|
||||
MemoID: userCommentOnForeignMemoID,
|
||||
ReactionType: "🔥",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: foreignMemo.Name,
|
||||
MemoID: foreignMemoStore.ID,
|
||||
ReactionType: "👋",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
peerReactionOnForeignMemo, err := ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: foreignMemo.Name,
|
||||
MemoID: foreignMemoStore.ID,
|
||||
ReactionType: "✅",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -284,15 +285,15 @@ func TestDeleteUserSelfDeleteRemovesOwnedResourcesAndMemoSubtrees(t *testing.T)
|
||||
_, err = os.Stat(motionCachePath)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
|
||||
ownMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &ownMemo.Name})
|
||||
ownMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{MemoID: &ownMemoStore.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, ownMemoReactions)
|
||||
|
||||
userCommentReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &userCommentOnForeignMemo.Name})
|
||||
userCommentReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{MemoID: &userCommentOnForeignMemoID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, userCommentReactions)
|
||||
|
||||
foreignMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{ContentID: &foreignMemo.Name})
|
||||
foreignMemoReactions, err := ts.Store.ListReactions(ctx, &store.FindReaction{MemoID: &foreignMemoStore.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, foreignMemoReactions, 1)
|
||||
require.Equal(t, peerReactionOnForeignMemo.ID, foreignMemoReactions[0].ID)
|
||||
@@ -365,7 +366,7 @@ func TestDeleteUserRollbackPreservesAllResources(t *testing.T) {
|
||||
|
||||
reaction, err := ts.Store.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: ownMemo.Name,
|
||||
MemoID: ownMemoStore.ID,
|
||||
ReactionType: "💥",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -156,7 +156,6 @@ func TestBuildToolFromOperationTailorsRequestBodySchemas(t *testing.T) {
|
||||
"memo": "memos/abc123",
|
||||
"body": map[string]any{
|
||||
"reaction": map[string]any{
|
||||
"contentId": "memos/abc123",
|
||||
"reactionType": "👍",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -266,7 +266,6 @@ func TestMCPToolCallBindsMemoFromPathForBodyStarOperations(t *testing.T) {
|
||||
toolName: "memo_upsert_memo_reaction",
|
||||
body: map[string]any{
|
||||
"reaction": map[string]any{
|
||||
"contentId": "memos/abc123",
|
||||
"reactionType": "👍",
|
||||
},
|
||||
},
|
||||
|
||||
+14
-6
@@ -219,14 +219,22 @@ func (d *DB) UpdateMemo(ctx context.Context, update *store.UpdateMemo) error {
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMemo(ctx context.Context, delete *store.DeleteMemo) error {
|
||||
where, args := []string{"`id` = ?"}, []any{delete.ID}
|
||||
stmt := "DELETE FROM `memo` WHERE " + strings.Join(where, " AND ")
|
||||
result, err := d.db.ExecContext(ctx, stmt, args...)
|
||||
tx, err := d.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to start memo delete transaction")
|
||||
}
|
||||
if _, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM `memo` WHERE `id` = ?", delete.ID); err != nil {
|
||||
return errors.Wrap(err, "failed to delete memo")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM `reaction` WHERE `memo_id` = ?", delete.ID); err != nil {
|
||||
return errors.Wrap(err, "failed to delete memo reactions")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.Wrap(err, "failed to commit memo delete transaction")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+50
-21
@@ -10,28 +10,46 @@ import (
|
||||
)
|
||||
|
||||
func (d *DB) UpsertReaction(ctx context.Context, upsert *store.Reaction) (*store.Reaction, error) {
|
||||
fields := []string{"`creator_id`", "`content_id`", "`reaction_type`"}
|
||||
placeholder := []string{"?", "?", "?"}
|
||||
args := []interface{}{upsert.CreatorID, upsert.ContentID, upsert.ReactionType}
|
||||
stmt := "INSERT INTO `reaction` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")"
|
||||
result, err := d.db.ExecContext(ctx, stmt, args...)
|
||||
// MySQL has no INSERT ... RETURNING, so keep the insert and readback in one
|
||||
// transaction to match the other drivers' atomic operation.
|
||||
tx, err := d.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to start reaction upsert transaction")
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO reaction (creator_id, memo_id, reaction_type)
|
||||
SELECT ?, memo.id, ?
|
||||
FROM memo
|
||||
WHERE memo.id = ?
|
||||
FOR SHARE
|
||||
`, upsert.CreatorID, upsert.ReactionType, upsert.MemoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return nil, errors.Wrap(store.ErrReactionMemoNotFound, "failed to create reaction")
|
||||
}
|
||||
|
||||
rawID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := int32(rawID)
|
||||
reaction, err := d.GetReaction(ctx, &store.FindReaction{ID: &id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
upsert.ID = int32(rawID)
|
||||
if err := tx.QueryRowContext(ctx, "SELECT UNIX_TIMESTAMP(created_ts) FROM reaction WHERE id = ?", upsert.ID).Scan(&upsert.CreatedTs); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read created reaction")
|
||||
}
|
||||
if reaction == nil {
|
||||
return nil, errors.Errorf("failed to create reaction")
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to commit reaction upsert transaction")
|
||||
}
|
||||
return reaction, nil
|
||||
return upsert, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*store.Reaction, error) {
|
||||
@@ -43,16 +61,16 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "`creator_id` = ?"), append(args, *find.CreatorID)
|
||||
}
|
||||
if find.ContentID != nil {
|
||||
where, args = append(where, "`content_id` = ?"), append(args, *find.ContentID)
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID)
|
||||
}
|
||||
if len(find.ContentIDList) > 0 {
|
||||
placeholders := make([]string, 0, len(find.ContentIDList))
|
||||
for _, id := range find.ContentIDList {
|
||||
if len(find.MemoIDList) > 0 {
|
||||
placeholders := make([]string, 0, len(find.MemoIDList))
|
||||
for _, id := range find.MemoIDList {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
where = append(where, "`content_id` IN ("+strings.Join(placeholders, ",")+")")
|
||||
where = append(where, "`memo_id` IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
@@ -60,7 +78,7 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
id,
|
||||
UNIX_TIMESTAMP(created_ts) AS created_ts,
|
||||
creator_id,
|
||||
content_id,
|
||||
memo_id,
|
||||
reaction_type
|
||||
FROM reaction
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
@@ -79,7 +97,7 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
&reaction.ID,
|
||||
&reaction.CreatedTs,
|
||||
&reaction.CreatorID,
|
||||
&reaction.ContentID,
|
||||
&reaction.MemoID,
|
||||
&reaction.ReactionType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -108,6 +126,17 @@ func (d *DB) GetReaction(ctx context.Context, find *store.FindReaction) (*store.
|
||||
}
|
||||
|
||||
func (d *DB) DeleteReaction(ctx context.Context, delete *store.DeleteReaction) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM `reaction` WHERE `id` = ?", delete.ID)
|
||||
where, args := []string{}, []any{}
|
||||
if delete.ID != nil {
|
||||
where, args = append(where, "`id` = ?"), append(args, *delete.ID)
|
||||
}
|
||||
if delete.MemoID != nil {
|
||||
where, args = append(where, "`memo_id` = ?"), append(args, *delete.MemoID)
|
||||
}
|
||||
if len(where) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM `reaction` WHERE "+strings.Join(where, " AND "), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,13 +13,8 @@ import (
|
||||
|
||||
const deleteUserBatchSize = 500
|
||||
|
||||
type deleteUserMemoRef struct {
|
||||
ID int32
|
||||
UID string
|
||||
}
|
||||
|
||||
type deleteUserTargetSet struct {
|
||||
memos []deleteUserMemoRef
|
||||
memoIDs []int32
|
||||
attachments []*store.Attachment
|
||||
attachmentIDs []int32
|
||||
userSettingKeys []storepb.UserSetting_Key
|
||||
@@ -61,13 +56,13 @@ func (d *DB) DeleteUser(ctx context.Context, delete *store.DeleteUser) (*store.D
|
||||
func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*deleteUserTargetSet, error) {
|
||||
targets := &deleteUserTargetSet{}
|
||||
|
||||
memos, err := listDeleteUserMemoTree(ctx, tx, userID)
|
||||
memoIDs, err := listDeleteUserMemoTree(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets.memos = memos
|
||||
targets.memoIDs = memoIDs
|
||||
|
||||
attachments, err := listDeleteUserAttachments(ctx, tx, userID, memoIDsFromRefs(memos))
|
||||
attachments, err := listDeleteUserAttachments(ctx, tx, userID, memoIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,7 +75,7 @@ func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*d
|
||||
}
|
||||
targets.userSettingKeys = userSettingKeys
|
||||
|
||||
inboxIDs, err := listDeleteUserInboxIDs(ctx, tx, userID, memoIDSetFromRefs(memos))
|
||||
inboxIDs, err := listDeleteUserInboxIDs(ctx, tx, userID, memoIDSet(memoIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,10 +85,15 @@ func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*d
|
||||
}
|
||||
|
||||
func deleteUserTargetsTx(ctx context.Context, tx *sql.Tx, userID int32, targets *deleteUserTargetSet) error {
|
||||
memoIDs := memoIDsFromRefs(targets.memos)
|
||||
contentIDs := memoContentIDsFromRefs(targets.memos)
|
||||
memoIDs := targets.memoIDs
|
||||
|
||||
if err := deleteReactionsByContentIDsTx(ctx, tx, contentIDs); err != nil {
|
||||
// Delete the memo rows before their reactions: a concurrent UpsertReaction
|
||||
// then blocks on the uncommitted parent delete instead of inserting a row
|
||||
// behind the reaction sweep. Do not reorder these two.
|
||||
if err := deleteMemosTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteReactionsByMemoIDsTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteAttachmentsByIDsTx(ctx, tx, targets.attachmentIDs); err != nil {
|
||||
@@ -117,38 +117,35 @@ func deleteUserTargetsTx(ctx context.Context, tx *sql.Tx, userID int32, targets
|
||||
if err := deleteMemoRelationsTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteMemosTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteUserRowTx(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTree(ctx context.Context, tx *sql.Tx, userID int32) ([]deleteUserMemoRef, error) {
|
||||
func listDeleteUserMemoTree(ctx context.Context, tx *sql.Tx, userID int32) ([]int32, error) {
|
||||
return listDeleteUserMemoTreeIterative(ctx, tx, userID)
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTreeIterative(ctx context.Context, tx *sql.Tx, userID int32) ([]deleteUserMemoRef, error) {
|
||||
roots, err := queryDeleteUserMemoRefs(ctx, tx, `
|
||||
SELECT id, uid
|
||||
func listDeleteUserMemoTreeIterative(ctx context.Context, tx *sql.Tx, userID int32) ([]int32, error) {
|
||||
roots, err := queryDeleteUserMemoIDs(ctx, tx, `
|
||||
SELECT id
|
||||
FROM memo
|
||||
WHERE creator_id = `+deleteUserPlaceholder(1), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memos := make([]deleteUserMemoRef, 0, len(roots))
|
||||
memoIDs := make([]int32, 0, len(roots))
|
||||
seen := make(map[int32]struct{})
|
||||
frontier := make([]int32, 0, len(roots))
|
||||
for _, memo := range roots {
|
||||
if _, exists := seen[memo.ID]; exists {
|
||||
for _, id := range roots {
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[memo.ID] = struct{}{}
|
||||
memos = append(memos, memo)
|
||||
frontier = append(frontier, memo.ID)
|
||||
seen[id] = struct{}{}
|
||||
memoIDs = append(memoIDs, id)
|
||||
frontier = append(frontier, id)
|
||||
}
|
||||
|
||||
for len(frontier) > 0 {
|
||||
@@ -156,8 +153,8 @@ func listDeleteUserMemoTreeIterative(ctx context.Context, tx *sql.Tx, userID int
|
||||
nextFrontier := make([]int32, 0)
|
||||
for _, batch := range deleteUserBatches(currentFrontier, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(1, batch)
|
||||
children, err := queryDeleteUserMemoRefs(ctx, tx, `
|
||||
SELECT child.id, child.uid
|
||||
children, err := queryDeleteUserMemoIDs(ctx, tx, `
|
||||
SELECT child.id
|
||||
FROM memo child
|
||||
JOIN memo_relation rel ON rel.memo_id = child.id AND rel.type = 'COMMENT'
|
||||
WHERE rel.related_memo_id IN `+clause, args...)
|
||||
@@ -165,41 +162,41 @@ func listDeleteUserMemoTreeIterative(ctx context.Context, tx *sql.Tx, userID int
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
if _, exists := seen[child.ID]; exists {
|
||||
for _, id := range children {
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[child.ID] = struct{}{}
|
||||
memos = append(memos, child)
|
||||
nextFrontier = append(nextFrontier, child.ID)
|
||||
seen[id] = struct{}{}
|
||||
memoIDs = append(memoIDs, id)
|
||||
nextFrontier = append(nextFrontier, id)
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier
|
||||
}
|
||||
|
||||
return memos, nil
|
||||
return memoIDs, nil
|
||||
}
|
||||
|
||||
func queryDeleteUserMemoRefs(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]deleteUserMemoRef, error) {
|
||||
func queryDeleteUserMemoIDs(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]int32, error) {
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
memos := make([]deleteUserMemoRef, 0)
|
||||
memoIDs := make([]int32, 0)
|
||||
for rows.Next() {
|
||||
var memo deleteUserMemoRef
|
||||
if err := rows.Scan(&memo.ID, &memo.UID); err != nil {
|
||||
var id int32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memos = append(memos, memo)
|
||||
memoIDs = append(memoIDs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memos, nil
|
||||
return memoIDs, nil
|
||||
}
|
||||
|
||||
func listDeleteUserAttachments(ctx context.Context, tx *sql.Tx, userID int32, memoIDs []int32) ([]*store.Attachment, error) {
|
||||
@@ -414,10 +411,10 @@ func memoIDInSet(id int32, memoIDSet map[int32]struct{}) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
func deleteReactionsByContentIDsTx(ctx context.Context, tx *sql.Tx, contentIDs []string) error {
|
||||
for _, batch := range deleteUserBatches(contentIDs, deleteUserBatchSize) {
|
||||
func deleteReactionsByMemoIDsTx(ctx context.Context, tx *sql.Tx, memoIDs []int32) error {
|
||||
for _, batch := range deleteUserBatches(memoIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE content_id IN `+clause, args...); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE memo_id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -530,30 +527,14 @@ func deleteUserBatches[T any](values []T, size int) [][]T {
|
||||
return batches
|
||||
}
|
||||
|
||||
func memoIDsFromRefs(memos []deleteUserMemoRef) []int32 {
|
||||
ids := make([]int32, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
ids = append(ids, memo.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func memoIDSetFromRefs(memos []deleteUserMemoRef) map[int32]struct{} {
|
||||
idSet := make(map[int32]struct{}, len(memos))
|
||||
for _, memo := range memos {
|
||||
idSet[memo.ID] = struct{}{}
|
||||
func memoIDSet(memoIDs []int32) map[int32]struct{} {
|
||||
idSet := make(map[int32]struct{}, len(memoIDs))
|
||||
for _, id := range memoIDs {
|
||||
idSet[id] = struct{}{}
|
||||
}
|
||||
return idSet
|
||||
}
|
||||
|
||||
func memoContentIDsFromRefs(memos []deleteUserMemoRef) []string {
|
||||
contentIDs := make([]string, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
contentIDs = append(contentIDs, "memos/"+memo.UID)
|
||||
}
|
||||
return contentIDs
|
||||
}
|
||||
|
||||
func attachmentIDsFromList(attachments []*store.Attachment) []int32 {
|
||||
ids := make([]int32, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
|
||||
@@ -204,14 +204,22 @@ func (d *DB) UpdateMemo(ctx context.Context, update *store.UpdateMemo) error {
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMemo(ctx context.Context, delete *store.DeleteMemo) error {
|
||||
where, args := []string{"id = " + placeholder(1)}, []any{delete.ID}
|
||||
stmt := `DELETE FROM memo WHERE ` + strings.Join(where, " AND ")
|
||||
result, err := d.db.ExecContext(ctx, stmt, args...)
|
||||
tx, err := d.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to start memo delete transaction")
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM memo WHERE id = "+placeholder(1), delete.ID); err != nil {
|
||||
return errors.Wrap(err, "failed to delete memo")
|
||||
}
|
||||
if _, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM reaction WHERE memo_id = "+placeholder(1), delete.ID); err != nil {
|
||||
return errors.Wrap(err, "failed to delete memo reactions")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.Wrap(err, "failed to commit memo delete transaction")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,19 +2,29 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (d *DB) UpsertReaction(ctx context.Context, upsert *store.Reaction) (*store.Reaction, error) {
|
||||
fields := []string{"creator_id", "content_id", "reaction_type"}
|
||||
args := []interface{}{upsert.CreatorID, upsert.ContentID, upsert.ReactionType}
|
||||
stmt := "INSERT INTO reaction (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts"
|
||||
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
if err := d.db.QueryRowContext(ctx, `
|
||||
INSERT INTO reaction (creator_id, memo_id, reaction_type)
|
||||
SELECT $1, memo.id, $2
|
||||
FROM memo
|
||||
WHERE memo.id = $3
|
||||
FOR KEY SHARE OF memo
|
||||
RETURNING id, created_ts
|
||||
`, upsert.CreatorID, upsert.ReactionType, upsert.MemoID).Scan(
|
||||
&upsert.ID,
|
||||
&upsert.CreatedTs,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.Wrap(store.ErrReactionMemoNotFound, "failed to create reaction")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -31,16 +41,16 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "creator_id = "+placeholder(len(args)+1)), append(args, *find.CreatorID)
|
||||
}
|
||||
if find.ContentID != nil {
|
||||
where, args = append(where, "content_id = "+placeholder(len(args)+1)), append(args, *find.ContentID)
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *find.MemoID)
|
||||
}
|
||||
if len(find.ContentIDList) > 0 {
|
||||
holders := make([]string, 0, len(find.ContentIDList))
|
||||
for _, id := range find.ContentIDList {
|
||||
if len(find.MemoIDList) > 0 {
|
||||
holders := make([]string, 0, len(find.MemoIDList))
|
||||
for _, id := range find.MemoIDList {
|
||||
holders = append(holders, placeholder(len(args)+1))
|
||||
args = append(args, id)
|
||||
}
|
||||
where = append(where, "content_id IN ("+strings.Join(holders, ", ")+")")
|
||||
where = append(where, "memo_id IN ("+strings.Join(holders, ", ")+")")
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
@@ -48,7 +58,7 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
id,
|
||||
created_ts,
|
||||
creator_id,
|
||||
content_id,
|
||||
memo_id,
|
||||
reaction_type
|
||||
FROM reaction
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
@@ -67,7 +77,7 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
&reaction.ID,
|
||||
&reaction.CreatedTs,
|
||||
&reaction.CreatorID,
|
||||
&reaction.ContentID,
|
||||
&reaction.MemoID,
|
||||
&reaction.ReactionType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -96,6 +106,17 @@ func (d *DB) GetReaction(ctx context.Context, find *store.FindReaction) (*store.
|
||||
}
|
||||
|
||||
func (d *DB) DeleteReaction(ctx context.Context, delete *store.DeleteReaction) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM reaction WHERE id = $1", delete.ID)
|
||||
where, args := []string{}, []any{}
|
||||
if delete.ID != nil {
|
||||
where, args = append(where, "id = "+placeholder(len(args)+1)), append(args, *delete.ID)
|
||||
}
|
||||
if delete.MemoID != nil {
|
||||
where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *delete.MemoID)
|
||||
}
|
||||
if len(where) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM reaction WHERE "+strings.Join(where, " AND "), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,13 +13,8 @@ import (
|
||||
|
||||
const deleteUserBatchSize = 500
|
||||
|
||||
type deleteUserMemoRef struct {
|
||||
ID int32
|
||||
UID string
|
||||
}
|
||||
|
||||
type deleteUserTargetSet struct {
|
||||
memos []deleteUserMemoRef
|
||||
memoIDs []int32
|
||||
attachments []*store.Attachment
|
||||
attachmentIDs []int32
|
||||
userSettingKeys []storepb.UserSetting_Key
|
||||
@@ -61,13 +56,13 @@ func (d *DB) DeleteUser(ctx context.Context, delete *store.DeleteUser) (*store.D
|
||||
func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*deleteUserTargetSet, error) {
|
||||
targets := &deleteUserTargetSet{}
|
||||
|
||||
memos, err := listDeleteUserMemoTree(ctx, tx, userID)
|
||||
memoIDs, err := listDeleteUserMemoTree(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets.memos = memos
|
||||
targets.memoIDs = memoIDs
|
||||
|
||||
attachments, err := listDeleteUserAttachments(ctx, tx, userID, memoIDsFromRefs(memos))
|
||||
attachments, err := listDeleteUserAttachments(ctx, tx, userID, memoIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,7 +75,7 @@ func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*d
|
||||
}
|
||||
targets.userSettingKeys = userSettingKeys
|
||||
|
||||
inboxIDs, err := listDeleteUserInboxIDs(ctx, tx, userID, memoIDSetFromRefs(memos))
|
||||
inboxIDs, err := listDeleteUserInboxIDs(ctx, tx, userID, memoIDSet(memoIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,10 +85,15 @@ func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*d
|
||||
}
|
||||
|
||||
func deleteUserTargetsTx(ctx context.Context, tx *sql.Tx, userID int32, targets *deleteUserTargetSet) error {
|
||||
memoIDs := memoIDsFromRefs(targets.memos)
|
||||
contentIDs := memoContentIDsFromRefs(targets.memos)
|
||||
memoIDs := targets.memoIDs
|
||||
|
||||
if err := deleteReactionsByContentIDsTx(ctx, tx, contentIDs); err != nil {
|
||||
// Delete the memo rows before their reactions: a concurrent UpsertReaction
|
||||
// then blocks on the uncommitted parent delete instead of inserting a row
|
||||
// behind the reaction sweep. Do not reorder these two.
|
||||
if err := deleteMemosTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteReactionsByMemoIDsTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteAttachmentsByIDsTx(ctx, tx, targets.attachmentIDs); err != nil {
|
||||
@@ -117,32 +117,29 @@ func deleteUserTargetsTx(ctx context.Context, tx *sql.Tx, userID int32, targets
|
||||
if err := deleteMemoRelationsTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteMemosTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteUserRowTx(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTree(ctx context.Context, tx *sql.Tx, userID int32) ([]deleteUserMemoRef, error) {
|
||||
func listDeleteUserMemoTree(ctx context.Context, tx *sql.Tx, userID int32) ([]int32, error) {
|
||||
return listDeleteUserMemoTreeRecursive(ctx, tx, userID)
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTreeRecursive(ctx context.Context, tx *sql.Tx, userID int32) ([]deleteUserMemoRef, error) {
|
||||
func listDeleteUserMemoTreeRecursive(ctx context.Context, tx *sql.Tx, userID int32) ([]int32, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
WITH RECURSIVE memo_tree(id, uid) AS (
|
||||
SELECT id, uid
|
||||
WITH RECURSIVE memo_tree(id) AS (
|
||||
SELECT id
|
||||
FROM memo
|
||||
WHERE creator_id = `+deleteUserPlaceholder(1)+`
|
||||
UNION
|
||||
SELECT child.id, child.uid
|
||||
SELECT child.id
|
||||
FROM memo child
|
||||
JOIN memo_relation rel ON rel.memo_id = child.id AND rel.type = 'COMMENT'
|
||||
JOIN memo_tree parent ON rel.related_memo_id = parent.id
|
||||
)
|
||||
SELECT id, uid
|
||||
SELECT id
|
||||
FROM memo_tree
|
||||
`, userID)
|
||||
if err != nil {
|
||||
@@ -150,19 +147,19 @@ func listDeleteUserMemoTreeRecursive(ctx context.Context, tx *sql.Tx, userID int
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
memos := make([]deleteUserMemoRef, 0)
|
||||
memoIDs := make([]int32, 0)
|
||||
for rows.Next() {
|
||||
var memo deleteUserMemoRef
|
||||
if err := rows.Scan(&memo.ID, &memo.UID); err != nil {
|
||||
var id int32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memos = append(memos, memo)
|
||||
memoIDs = append(memoIDs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memos, nil
|
||||
return memoIDs, nil
|
||||
}
|
||||
|
||||
func listDeleteUserAttachments(ctx context.Context, tx *sql.Tx, userID int32, memoIDs []int32) ([]*store.Attachment, error) {
|
||||
@@ -377,10 +374,10 @@ func memoIDInSet(id int32, memoIDSet map[int32]struct{}) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
func deleteReactionsByContentIDsTx(ctx context.Context, tx *sql.Tx, contentIDs []string) error {
|
||||
for _, batch := range deleteUserBatches(contentIDs, deleteUserBatchSize) {
|
||||
func deleteReactionsByMemoIDsTx(ctx context.Context, tx *sql.Tx, memoIDs []int32) error {
|
||||
for _, batch := range deleteUserBatches(memoIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE content_id IN `+clause, args...); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE memo_id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -493,30 +490,14 @@ func deleteUserBatches[T any](values []T, size int) [][]T {
|
||||
return batches
|
||||
}
|
||||
|
||||
func memoIDsFromRefs(memos []deleteUserMemoRef) []int32 {
|
||||
ids := make([]int32, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
ids = append(ids, memo.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func memoIDSetFromRefs(memos []deleteUserMemoRef) map[int32]struct{} {
|
||||
idSet := make(map[int32]struct{}, len(memos))
|
||||
for _, memo := range memos {
|
||||
idSet[memo.ID] = struct{}{}
|
||||
func memoIDSet(memoIDs []int32) map[int32]struct{} {
|
||||
idSet := make(map[int32]struct{}, len(memoIDs))
|
||||
for _, id := range memoIDs {
|
||||
idSet[id] = struct{}{}
|
||||
}
|
||||
return idSet
|
||||
}
|
||||
|
||||
func memoContentIDsFromRefs(memos []deleteUserMemoRef) []string {
|
||||
contentIDs := make([]string, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
contentIDs = append(contentIDs, "memos/"+memo.UID)
|
||||
}
|
||||
return contentIDs
|
||||
}
|
||||
|
||||
func attachmentIDsFromList(attachments []*store.Attachment) []int32 {
|
||||
ids := make([]int32, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
|
||||
+14
-6
@@ -197,14 +197,22 @@ func (d *DB) UpdateMemo(ctx context.Context, update *store.UpdateMemo) error {
|
||||
}
|
||||
|
||||
func (d *DB) DeleteMemo(ctx context.Context, delete *store.DeleteMemo) error {
|
||||
where, args := []string{"`id` = ?"}, []any{delete.ID}
|
||||
stmt := "DELETE FROM `memo` WHERE " + strings.Join(where, " AND ")
|
||||
result, err := d.db.ExecContext(ctx, stmt, args...)
|
||||
tx, err := d.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "failed to start memo delete transaction")
|
||||
}
|
||||
if _, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM `memo` WHERE `id` = ?", delete.ID); err != nil {
|
||||
return errors.Wrap(err, "failed to delete memo")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "DELETE FROM `reaction` WHERE `memo_id` = ?", delete.ID); err != nil {
|
||||
return errors.Wrap(err, "failed to delete memo reactions")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.Wrap(err, "failed to commit memo delete transaction")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+39
-55
@@ -3,21 +3,27 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (d *DB) UpsertReaction(ctx context.Context, upsert *store.Reaction) (*store.Reaction, error) {
|
||||
fields := []string{"`creator_id`", "`content_id`", "`reaction_type`"}
|
||||
placeholder := []string{"?", "?", "?"}
|
||||
args := []interface{}{upsert.CreatorID, upsert.ContentID, upsert.ReactionType}
|
||||
stmt := "INSERT INTO `reaction` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`"
|
||||
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
if err := d.db.QueryRowContext(ctx, `
|
||||
INSERT INTO reaction (creator_id, memo_id, reaction_type)
|
||||
SELECT ?, memo.id, ?
|
||||
FROM memo
|
||||
WHERE memo.id = ?
|
||||
RETURNING id, created_ts
|
||||
`, upsert.CreatorID, upsert.ReactionType, upsert.MemoID).Scan(
|
||||
&upsert.ID,
|
||||
&upsert.CreatedTs,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.Wrap(store.ErrReactionMemoNotFound, "failed to create reaction")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -34,20 +40,16 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "creator_id = ?"), append(args, *find.CreatorID)
|
||||
}
|
||||
if find.ContentID != nil {
|
||||
where, args = append(where, "content_id = ?"), append(args, *find.ContentID)
|
||||
if find.MemoID != nil {
|
||||
where, args = append(where, "memo_id = ?"), append(args, *find.MemoID)
|
||||
}
|
||||
if len(find.ContentIDList) > 0 {
|
||||
placeholders := make([]string, 0, len(find.ContentIDList))
|
||||
for range find.ContentIDList {
|
||||
if len(find.MemoIDList) > 0 {
|
||||
placeholders := make([]string, 0, len(find.MemoIDList))
|
||||
for _, id := range find.MemoIDList {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
if len(placeholders) > 0 {
|
||||
where = append(where, "content_id IN ("+strings.Join(placeholders, ",")+")")
|
||||
for _, id := range find.ContentIDList {
|
||||
args = append(args, id)
|
||||
}
|
||||
}
|
||||
where = append(where, "memo_id IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, `
|
||||
@@ -55,7 +57,7 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
id,
|
||||
created_ts,
|
||||
creator_id,
|
||||
content_id,
|
||||
memo_id,
|
||||
reaction_type
|
||||
FROM reaction
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
@@ -74,7 +76,7 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
&reaction.ID,
|
||||
&reaction.CreatedTs,
|
||||
&reaction.CreatorID,
|
||||
&reaction.ContentID,
|
||||
&reaction.MemoID,
|
||||
&reaction.ReactionType,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -90,47 +92,29 @@ func (d *DB) ListReactions(ctx context.Context, find *store.FindReaction) ([]*st
|
||||
}
|
||||
|
||||
func (d *DB) GetReaction(ctx context.Context, find *store.FindReaction) (*store.Reaction, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
|
||||
if find.ID != nil {
|
||||
where, args = append(where, "id = ?"), append(args, *find.ID)
|
||||
}
|
||||
if find.CreatorID != nil {
|
||||
where, args = append(where, "creator_id = ?"), append(args, *find.CreatorID)
|
||||
}
|
||||
if find.ContentID != nil {
|
||||
where, args = append(where, "content_id = ?"), append(args, *find.ContentID)
|
||||
}
|
||||
|
||||
reaction := &store.Reaction{}
|
||||
if err := d.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
id,
|
||||
created_ts,
|
||||
creator_id,
|
||||
content_id,
|
||||
reaction_type
|
||||
FROM reaction
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
LIMIT 1`,
|
||||
args...,
|
||||
).Scan(
|
||||
&reaction.ID,
|
||||
&reaction.CreatedTs,
|
||||
&reaction.CreatorID,
|
||||
&reaction.ContentID,
|
||||
&reaction.ReactionType,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
list, err := d.ListReactions(ctx, find)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return reaction, nil
|
||||
return list[0], nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteReaction(ctx context.Context, delete *store.DeleteReaction) error {
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM `reaction` WHERE `id` = ?", delete.ID)
|
||||
where, args := []string{}, []any{}
|
||||
if delete.ID != nil {
|
||||
where, args = append(where, "`id` = ?"), append(args, *delete.ID)
|
||||
}
|
||||
if delete.MemoID != nil {
|
||||
where, args = append(where, "`memo_id` = ?"), append(args, *delete.MemoID)
|
||||
}
|
||||
if len(where) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := d.db.ExecContext(ctx, "DELETE FROM `reaction` WHERE "+strings.Join(where, " AND "), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,13 +13,8 @@ import (
|
||||
|
||||
const deleteUserBatchSize = 500
|
||||
|
||||
type deleteUserMemoRef struct {
|
||||
ID int32
|
||||
UID string
|
||||
}
|
||||
|
||||
type deleteUserTargetSet struct {
|
||||
memos []deleteUserMemoRef
|
||||
memoIDs []int32
|
||||
attachments []*store.Attachment
|
||||
attachmentIDs []int32
|
||||
userSettingKeys []storepb.UserSetting_Key
|
||||
@@ -61,13 +56,13 @@ func (d *DB) DeleteUser(ctx context.Context, delete *store.DeleteUser) (*store.D
|
||||
func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*deleteUserTargetSet, error) {
|
||||
targets := &deleteUserTargetSet{}
|
||||
|
||||
memos, err := listDeleteUserMemoTree(ctx, tx, userID)
|
||||
memoIDs, err := listDeleteUserMemoTree(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets.memos = memos
|
||||
targets.memoIDs = memoIDs
|
||||
|
||||
attachments, err := listDeleteUserAttachments(ctx, tx, userID, memoIDsFromRefs(memos))
|
||||
attachments, err := listDeleteUserAttachments(ctx, tx, userID, memoIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,7 +75,7 @@ func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*d
|
||||
}
|
||||
targets.userSettingKeys = userSettingKeys
|
||||
|
||||
inboxIDs, err := listDeleteUserInboxIDs(ctx, tx, userID, memoIDSetFromRefs(memos))
|
||||
inboxIDs, err := listDeleteUserInboxIDs(ctx, tx, userID, memoIDSet(memoIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,10 +85,15 @@ func collectDeleteUserTargets(ctx context.Context, tx *sql.Tx, userID int32) (*d
|
||||
}
|
||||
|
||||
func deleteUserTargetsTx(ctx context.Context, tx *sql.Tx, userID int32, targets *deleteUserTargetSet) error {
|
||||
memoIDs := memoIDsFromRefs(targets.memos)
|
||||
contentIDs := memoContentIDsFromRefs(targets.memos)
|
||||
memoIDs := targets.memoIDs
|
||||
|
||||
if err := deleteReactionsByContentIDsTx(ctx, tx, contentIDs); err != nil {
|
||||
// Delete the memo rows before their reactions: a concurrent UpsertReaction
|
||||
// then blocks on the uncommitted parent delete instead of inserting a row
|
||||
// behind the reaction sweep. Do not reorder these two.
|
||||
if err := deleteMemosTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteReactionsByMemoIDsTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteAttachmentsByIDsTx(ctx, tx, targets.attachmentIDs); err != nil {
|
||||
@@ -117,32 +117,29 @@ func deleteUserTargetsTx(ctx context.Context, tx *sql.Tx, userID int32, targets
|
||||
if err := deleteMemoRelationsTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteMemosTx(ctx, tx, memoIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteUserRowTx(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTree(ctx context.Context, tx *sql.Tx, userID int32) ([]deleteUserMemoRef, error) {
|
||||
func listDeleteUserMemoTree(ctx context.Context, tx *sql.Tx, userID int32) ([]int32, error) {
|
||||
return listDeleteUserMemoTreeRecursive(ctx, tx, userID)
|
||||
}
|
||||
|
||||
func listDeleteUserMemoTreeRecursive(ctx context.Context, tx *sql.Tx, userID int32) ([]deleteUserMemoRef, error) {
|
||||
func listDeleteUserMemoTreeRecursive(ctx context.Context, tx *sql.Tx, userID int32) ([]int32, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
WITH RECURSIVE memo_tree(id, uid) AS (
|
||||
SELECT id, uid
|
||||
WITH RECURSIVE memo_tree(id) AS (
|
||||
SELECT id
|
||||
FROM memo
|
||||
WHERE creator_id = `+deleteUserPlaceholder(1)+`
|
||||
UNION
|
||||
SELECT child.id, child.uid
|
||||
SELECT child.id
|
||||
FROM memo child
|
||||
JOIN memo_relation rel ON rel.memo_id = child.id AND rel.type = 'COMMENT'
|
||||
JOIN memo_tree parent ON rel.related_memo_id = parent.id
|
||||
)
|
||||
SELECT id, uid
|
||||
SELECT id
|
||||
FROM memo_tree
|
||||
`, userID)
|
||||
if err != nil {
|
||||
@@ -150,19 +147,19 @@ func listDeleteUserMemoTreeRecursive(ctx context.Context, tx *sql.Tx, userID int
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
memos := make([]deleteUserMemoRef, 0)
|
||||
memoIDs := make([]int32, 0)
|
||||
for rows.Next() {
|
||||
var memo deleteUserMemoRef
|
||||
if err := rows.Scan(&memo.ID, &memo.UID); err != nil {
|
||||
var id int32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memos = append(memos, memo)
|
||||
memoIDs = append(memoIDs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return memos, nil
|
||||
return memoIDs, nil
|
||||
}
|
||||
|
||||
func listDeleteUserAttachments(ctx context.Context, tx *sql.Tx, userID int32, memoIDs []int32) ([]*store.Attachment, error) {
|
||||
@@ -377,10 +374,10 @@ func memoIDInSet(id int32, memoIDSet map[int32]struct{}) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
func deleteReactionsByContentIDsTx(ctx context.Context, tx *sql.Tx, contentIDs []string) error {
|
||||
for _, batch := range deleteUserBatches(contentIDs, deleteUserBatchSize) {
|
||||
func deleteReactionsByMemoIDsTx(ctx context.Context, tx *sql.Tx, memoIDs []int32) error {
|
||||
for _, batch := range deleteUserBatches(memoIDs, deleteUserBatchSize) {
|
||||
clause, args := deleteUserInClause(1, batch)
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE content_id IN `+clause, args...); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM reaction WHERE memo_id IN `+clause, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -493,30 +490,14 @@ func deleteUserBatches[T any](values []T, size int) [][]T {
|
||||
return batches
|
||||
}
|
||||
|
||||
func memoIDsFromRefs(memos []deleteUserMemoRef) []int32 {
|
||||
ids := make([]int32, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
ids = append(ids, memo.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func memoIDSetFromRefs(memos []deleteUserMemoRef) map[int32]struct{} {
|
||||
idSet := make(map[int32]struct{}, len(memos))
|
||||
for _, memo := range memos {
|
||||
idSet[memo.ID] = struct{}{}
|
||||
func memoIDSet(memoIDs []int32) map[int32]struct{} {
|
||||
idSet := make(map[int32]struct{}, len(memoIDs))
|
||||
for _, id := range memoIDs {
|
||||
idSet[id] = struct{}{}
|
||||
}
|
||||
return idSet
|
||||
}
|
||||
|
||||
func memoContentIDsFromRefs(memos []deleteUserMemoRef) []string {
|
||||
contentIDs := make([]string, 0, len(memos))
|
||||
for _, memo := range memos {
|
||||
contentIDs = append(contentIDs, "memos/"+memo.UID)
|
||||
}
|
||||
return contentIDs
|
||||
}
|
||||
|
||||
func attachmentIDsFromList(attachments []*store.Attachment) []int32 {
|
||||
ids := make([]int32, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Replaces the memo resource name stored in reaction.content_id with the
|
||||
-- memo's stable internal ID. The inner join intentionally drops orphaned
|
||||
-- reactions whose resource name no longer resolves to an existing memo.
|
||||
CREATE TABLE `reaction_new` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
`created_ts` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`creator_id` INT NOT NULL,
|
||||
`memo_id` INT NOT NULL,
|
||||
`reaction_type` VARCHAR(256) NOT NULL,
|
||||
UNIQUE(`creator_id`, `memo_id`, `reaction_type`)
|
||||
);
|
||||
|
||||
INSERT INTO `reaction_new` (`id`, `created_ts`, `creator_id`, `memo_id`, `reaction_type`)
|
||||
SELECT
|
||||
`reaction`.`id`,
|
||||
`reaction`.`created_ts`,
|
||||
`reaction`.`creator_id`,
|
||||
`memo`.`id`,
|
||||
`reaction`.`reaction_type`
|
||||
FROM `reaction`
|
||||
JOIN `memo` ON `reaction`.`content_id` = CONCAT('memos/', `memo`.`uid`);
|
||||
|
||||
DROP TABLE `reaction`;
|
||||
RENAME TABLE `reaction_new` TO `reaction`;
|
||||
@@ -87,14 +87,14 @@ CREATE TABLE `inbox` (
|
||||
`message` TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- reaction
|
||||
-- memo reaction
|
||||
CREATE TABLE `reaction` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
`created_ts` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`creator_id` INT NOT NULL,
|
||||
`content_id` VARCHAR(256) NOT NULL,
|
||||
`memo_id` INT NOT NULL,
|
||||
`reaction_type` VARCHAR(256) NOT NULL,
|
||||
UNIQUE(`creator_id`,`content_id`,`reaction_type`)
|
||||
UNIQUE(`creator_id`,`memo_id`,`reaction_type`)
|
||||
);
|
||||
|
||||
-- memo_share
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Replaces the memo resource name stored in reaction.content_id with the
|
||||
-- memo's stable internal ID. Rows that cannot be resolved are intentionally
|
||||
-- deleted before memo_id is made non-nullable.
|
||||
ALTER TABLE reaction ADD COLUMN memo_id INTEGER;
|
||||
|
||||
UPDATE reaction
|
||||
SET memo_id = memo.id
|
||||
FROM memo
|
||||
WHERE reaction.content_id = 'memos/' || memo.uid;
|
||||
|
||||
DELETE FROM reaction WHERE memo_id IS NULL;
|
||||
|
||||
ALTER TABLE reaction
|
||||
DROP CONSTRAINT reaction_creator_id_content_id_reaction_type_key,
|
||||
DROP COLUMN content_id,
|
||||
ALTER COLUMN memo_id SET NOT NULL,
|
||||
ADD UNIQUE (creator_id, memo_id, reaction_type);
|
||||
@@ -87,14 +87,14 @@ CREATE TABLE inbox (
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- reaction
|
||||
-- memo reaction
|
||||
CREATE TABLE reaction (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
|
||||
creator_id INTEGER NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
memo_id INTEGER NOT NULL,
|
||||
reaction_type TEXT NOT NULL,
|
||||
UNIQUE(creator_id, content_id, reaction_type)
|
||||
UNIQUE(creator_id, memo_id, reaction_type)
|
||||
);
|
||||
|
||||
-- memo_share
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Replaces the memo resource name stored in reaction.content_id with the
|
||||
-- memo's stable internal ID. The inner join intentionally drops orphaned
|
||||
-- reactions whose resource name no longer resolves to an existing memo.
|
||||
CREATE TABLE reaction_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
creator_id INTEGER NOT NULL,
|
||||
memo_id INTEGER NOT NULL,
|
||||
reaction_type TEXT NOT NULL,
|
||||
UNIQUE(creator_id, memo_id, reaction_type)
|
||||
);
|
||||
|
||||
INSERT INTO reaction_new (id, created_ts, creator_id, memo_id, reaction_type)
|
||||
SELECT
|
||||
reaction.id,
|
||||
reaction.created_ts,
|
||||
reaction.creator_id,
|
||||
memo.id,
|
||||
reaction.reaction_type
|
||||
FROM reaction
|
||||
JOIN memo ON reaction.content_id = 'memos/' || memo.uid;
|
||||
|
||||
DROP TABLE reaction;
|
||||
ALTER TABLE reaction_new RENAME TO reaction;
|
||||
@@ -88,14 +88,14 @@ CREATE TABLE inbox (
|
||||
message TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- reaction
|
||||
-- memo reaction
|
||||
CREATE TABLE reaction (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
creator_id INTEGER NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
memo_id INTEGER NOT NULL,
|
||||
reaction_type TEXT NOT NULL,
|
||||
UNIQUE(creator_id, content_id, reaction_type)
|
||||
UNIQUE(creator_id, memo_id, reaction_type)
|
||||
);
|
||||
|
||||
-- memo_share
|
||||
|
||||
+13
-7
@@ -2,26 +2,32 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrReactionMemoNotFound indicates that a reaction's memo no longer exists.
|
||||
var ErrReactionMemoNotFound = errors.New("reaction memo not found")
|
||||
|
||||
// Reaction is a reaction attached to a memo.
|
||||
type Reaction struct {
|
||||
ID int32
|
||||
CreatedTs int64
|
||||
CreatorID int32
|
||||
// ContentID is the id of the content that the reaction is for.
|
||||
ContentID string
|
||||
// MemoID is the ID of the memo that the reaction is for.
|
||||
MemoID int32
|
||||
ReactionType string
|
||||
}
|
||||
|
||||
type FindReaction struct {
|
||||
ID *int32
|
||||
CreatorID *int32
|
||||
ContentID *string
|
||||
ContentIDList []string
|
||||
ID *int32
|
||||
CreatorID *int32
|
||||
MemoID *int32
|
||||
MemoIDList []int32
|
||||
}
|
||||
|
||||
type DeleteReaction struct {
|
||||
ID int32
|
||||
ID *int32
|
||||
MemoID *int32
|
||||
}
|
||||
|
||||
func (s *Store) UpsertReaction(ctx context.Context, upsert *Reaction) (*Reaction, error) {
|
||||
|
||||
@@ -152,41 +152,41 @@ INSERT INTO memo_relation VALUES(5,15,'REFERENCE'); -- weekly review → Deep W
|
||||
-- Reactions (types must come from the MEMO_RELATED reactions list below)
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(1,2,'memos/welcome0000001','🎉');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(2,3,'memos/welcome0000001','🎉');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(3,4,'memos/welcome0000001','👍');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(4,2,'memos/sponsors000001','🚀');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(5,3,'memos/sponsors000001','👍');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(6,2,'memos/goldenhour0001','🔥');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(7,4,'memos/goldenhour0001','💛');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(8,1,'memos/goldenhour0001','👏');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(9,2,'memos/samquote000001','💛');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(10,3,'memos/samquote000001','👀');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(11,1,'memos/johnnyweekly001','👏');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(12,4,'memos/johnnyweekly001','💡');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(13,1,'memos/johnnyshipped01','🚀');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(14,4,'memos/johnnyshipped01','🎉');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(15,3,'memos/johnnyshipped01','🔥');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(16,3,'memos/johnnytilgit001','💡');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(17,4,'memos/johnnytilgit001','+1');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(18,3,'memos/samramen000001','👌');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(19,2,'memos/samramen000001','💛');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(20,1,'memos/sammovies00001','👍');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(21,4,'memos/bobnighttrain1','💛');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(22,3,'memos/johnnygitcheat1','🔥');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(23,4,'memos/johnnygitcheat1','💡');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(24,4,'memos/webclipper0001','💡');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(25,1,'memos/tagtipmemo0001','👍');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(26,2,'memos/protectedintro','👀');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(27,3,'memos/protectedintro','✅');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(28,4,'memos/bobpacklist001','😂');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(29,3,'memos/sambookpile001','😂');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(30,2,'memos/v030changelog1','🎉');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(31,4,'memos/v030changelog1','🚀');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(32,3,'memos/v030changelog1','🔥');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(33,1,'memos/johnnybookmark1','👀');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(34,2,'memos/rendertest0001','😂');
|
||||
INSERT INTO reaction (id,creator_id,content_id,reaction_type) VALUES(35,4,'memos/rendertest0001','💡');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(1,2,1,'🎉');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(2,3,1,'🎉');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(3,4,1,'👍');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(4,2,2,'🚀');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(5,3,2,'👍');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(6,2,3,'🔥');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(7,4,3,'💛');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(8,1,3,'👏');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(9,2,4,'💛');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(10,3,4,'👀');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(11,1,5,'👏');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(12,4,5,'💡');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(13,1,7,'🚀');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(14,4,7,'🎉');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(15,3,7,'🔥');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(16,3,8,'💡');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(17,4,8,'+1');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(18,3,9,'👌');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(19,2,9,'💛');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(20,1,11,'👍');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(21,4,12,'💛');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(22,3,14,'🔥');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(23,4,14,'💡');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(24,4,10,'💡');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(25,1,6,'👍');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(26,2,16,'👀');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(27,3,16,'✅');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(28,4,18,'😂');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(29,3,19,'😂');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(30,2,27,'🎉');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(31,4,27,'🚀');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(32,3,27,'🔥');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(33,1,29,'👀');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(34,2,30,'😂');
|
||||
INSERT INTO reaction (id,creator_id,memo_id,reaction_type) VALUES(35,4,30,'💡');
|
||||
|
||||
-- ============================================================================
|
||||
-- Attachment: Bob's golden-hour art (stored in the database blob column;
|
||||
|
||||
+91
-38
@@ -348,6 +348,60 @@ func assertStorageMigrationAttachments(ctx context.Context, t *testing.T, ts *st
|
||||
require.Equal(t, "existing-s3", named.GetStorageId())
|
||||
}
|
||||
|
||||
// TestMigrationReactionMemoID verifies that the reaction migration resolves
|
||||
// legacy memo resource names to internal memo IDs and discards orphaned rows.
|
||||
func TestMigrationReactionMemoID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
driver := getDriverFromEnv()
|
||||
dsn := getTestingProfileForDriver(t, driver).DSN
|
||||
|
||||
db, err := sql.Open(driver, dsn)
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, legacySchemaFixture(driver))
|
||||
require.NoError(t, err)
|
||||
|
||||
basicSetting, err := protojson.Marshal(&storepb.InstanceBasicSetting{SchemaVersion: "0.31.2"})
|
||||
require.NoError(t, err)
|
||||
insertSetting := "INSERT INTO system_setting (name, value, description) VALUES (?, ?, '')"
|
||||
insertMemo := "INSERT INTO memo (id, uid) VALUES (?, ?)"
|
||||
insertReaction := "INSERT INTO reaction (id, creator_id, content_id, reaction_type) VALUES (?, ?, ?, ?)"
|
||||
if driver == "postgres" {
|
||||
insertSetting = "INSERT INTO system_setting (name, value, description) VALUES ($1, $2, '')"
|
||||
insertMemo = "INSERT INTO memo (id, uid) VALUES ($1, $2)"
|
||||
insertReaction = "INSERT INTO reaction (id, creator_id, content_id, reaction_type) VALUES ($1, $2, $3, $4)"
|
||||
}
|
||||
_, err = db.ExecContext(ctx, insertSetting, "BASIC", string(basicSetting))
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, insertMemo, 42, "reaction-migration-target")
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, insertReaction, 100, 7, "memos/reaction-migration-target", "valid")
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, insertReaction, 101, 7, "memos/missing-target", "orphan")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
ts := NewTestingStoreWithDSN(ctx, t, driver, dsn)
|
||||
require.NoError(t, ts.Migrate(ctx))
|
||||
defer ts.Close()
|
||||
|
||||
var reactionID, creatorID, memoID int32
|
||||
var reactionType string
|
||||
err = ts.GetDriver().GetDB().QueryRowContext(
|
||||
ctx,
|
||||
"SELECT id, creator_id, memo_id, reaction_type FROM reaction",
|
||||
).Scan(&reactionID, &creatorID, &memoID, &reactionType)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(100), reactionID)
|
||||
require.Equal(t, int32(7), creatorID)
|
||||
require.Equal(t, int32(42), memoID)
|
||||
require.Equal(t, "valid", reactionType)
|
||||
|
||||
var reactionCount int
|
||||
err = ts.GetDriver().GetDB().QueryRowContext(ctx, "SELECT COUNT(*) FROM reaction").Scan(&reactionCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, reactionCount, "orphaned reactions must be discarded")
|
||||
}
|
||||
|
||||
// TestMigrationLegacyS3AttachmentMinIO verifies the storage upgrade path for an
|
||||
// attachment created before storage IDs existed. The migration must bind the
|
||||
// payload to the migrated registry entry, after which the production resolver
|
||||
@@ -550,41 +604,7 @@ func TestMigrationCopiesInstanceTagsToUserSettings(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.ExecContext(ctx, `
|
||||
CREATE TABLE system_setting (
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(name)
|
||||
);
|
||||
CREATE TABLE user (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'USER',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE user_setting (
|
||||
user_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
UNIQUE(user_id, key)
|
||||
);
|
||||
CREATE TABLE memo (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
);
|
||||
CREATE TABLE attachment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
storage_type TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
`)
|
||||
_, err = db.ExecContext(ctx, legacySchemaFixture("sqlite"))
|
||||
require.NoError(t, err)
|
||||
|
||||
basicSettingBytes, err := protojson.Marshal(&storepb.InstanceBasicSetting{SchemaVersion: "0.29.1"})
|
||||
@@ -729,7 +749,18 @@ func legacySchemaFixture(driver string) string {
|
||||
value LONGTEXT NOT NULL,
|
||||
UNIQUE(user_id, ` + "`key`" + `)
|
||||
);
|
||||
CREATE TABLE memo (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY);
|
||||
CREATE TABLE memo (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
uid VARCHAR(256) NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE reaction (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
created_ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
creator_id INT NOT NULL,
|
||||
content_id VARCHAR(256) NOT NULL,
|
||||
reaction_type VARCHAR(256) NOT NULL,
|
||||
UNIQUE(creator_id, content_id, reaction_type)
|
||||
);
|
||||
CREATE TABLE attachment (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
uid VARCHAR(256) NOT NULL UNIQUE,
|
||||
@@ -771,7 +802,18 @@ func legacySchemaFixture(driver string) string {
|
||||
value TEXT NOT NULL,
|
||||
UNIQUE(user_id, key)
|
||||
);
|
||||
CREATE TABLE memo (id SERIAL PRIMARY KEY);
|
||||
CREATE TABLE memo (
|
||||
id SERIAL PRIMARY KEY,
|
||||
uid TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE reaction (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
|
||||
creator_id INTEGER NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
reaction_type TEXT NOT NULL,
|
||||
UNIQUE(creator_id, content_id, reaction_type)
|
||||
);
|
||||
CREATE TABLE attachment (
|
||||
id SERIAL PRIMARY KEY,
|
||||
uid TEXT NOT NULL UNIQUE,
|
||||
@@ -815,7 +857,18 @@ func legacySchemaFixture(driver string) string {
|
||||
value TEXT NOT NULL,
|
||||
UNIQUE(user_id, key)
|
||||
);
|
||||
CREATE TABLE memo (id INTEGER PRIMARY KEY AUTOINCREMENT);
|
||||
CREATE TABLE memo (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE reaction (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
creator_id INTEGER NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
reaction_type TEXT NOT NULL,
|
||||
UNIQUE(creator_id, content_id, reaction_type)
|
||||
);
|
||||
CREATE TABLE attachment (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT NOT NULL UNIQUE,
|
||||
|
||||
+330
-26
@@ -3,10 +3,12 @@ package test
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/usememos/memos/store"
|
||||
"github.com/usememos/memos/store/db"
|
||||
)
|
||||
|
||||
func TestReactionStore(t *testing.T) {
|
||||
@@ -16,11 +18,11 @@ func TestReactionStore(t *testing.T) {
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-store")
|
||||
|
||||
contentID := "test_content_id"
|
||||
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: contentID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "💗",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -28,7 +30,7 @@ func TestReactionStore(t *testing.T) {
|
||||
require.NotEmpty(t, reaction.ID)
|
||||
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &contentID,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reactions, 1)
|
||||
@@ -42,7 +44,7 @@ func TestReactionStore(t *testing.T) {
|
||||
require.NotNil(t, gotReaction)
|
||||
require.Equal(t, reaction.ID, gotReaction.ID)
|
||||
require.Equal(t, reaction.CreatorID, gotReaction.CreatorID)
|
||||
require.Equal(t, reaction.ContentID, gotReaction.ContentID)
|
||||
require.Equal(t, reaction.MemoID, gotReaction.MemoID)
|
||||
require.Equal(t, reaction.ReactionType, gotReaction.ReactionType)
|
||||
|
||||
// Test GetReaction with non-existent ID.
|
||||
@@ -53,13 +55,19 @@ func TestReactionStore(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, notFoundReaction)
|
||||
|
||||
// An empty delete filter must not remove every reaction.
|
||||
require.NoError(t, ts.DeleteReaction(ctx, &store.DeleteReaction{}))
|
||||
reactions, err = ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reactions, 1)
|
||||
|
||||
err = ts.DeleteReaction(ctx, &store.DeleteReaction{
|
||||
ID: reaction.ID,
|
||||
ID: &reaction.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
reactions, err = ts.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &contentID,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reactions, 0)
|
||||
@@ -67,6 +75,69 @@ func TestReactionStore(t *testing.T) {
|
||||
ts.Close()
|
||||
}
|
||||
|
||||
func TestReactionDeleteByMemoID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
memo1 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-delete-memo-1")
|
||||
memo2 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-delete-memo-2")
|
||||
|
||||
for _, reaction := range []*store.Reaction{
|
||||
{CreatorID: user.ID, MemoID: memo1.ID, ReactionType: "👍"},
|
||||
{CreatorID: user.ID, MemoID: memo1.ID, ReactionType: "❤️"},
|
||||
{CreatorID: user.ID, MemoID: memo2.ID, ReactionType: "👍"},
|
||||
} {
|
||||
_, err := ts.UpsertReaction(ctx, reaction)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NoError(t, ts.DeleteReaction(ctx, &store.DeleteReaction{MemoID: &memo1.ID}))
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reactions, 1)
|
||||
require.Equal(t, memo2.ID, reactions[0].MemoID)
|
||||
|
||||
ts.Close()
|
||||
}
|
||||
|
||||
func TestReactionRejectsDeletedMemo(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-deleted-memo")
|
||||
require.NoError(t, ts.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}))
|
||||
|
||||
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.ErrorIs(t, err, store.ErrReactionMemoNotFound)
|
||||
require.Nil(t, reaction)
|
||||
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, reactions)
|
||||
|
||||
existingMemo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-existing-after-rejection")
|
||||
reaction, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: existingMemo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reaction)
|
||||
require.Equal(t, existingMemo.ID, reaction.MemoID)
|
||||
|
||||
ts.Close()
|
||||
}
|
||||
|
||||
func TestReactionListByCreatorID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
@@ -77,13 +148,12 @@ func TestReactionListByCreatorID(t *testing.T) {
|
||||
|
||||
user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
contentID := "shared_content"
|
||||
memo := createReactionTestMemo(ctx, t, ts, user1.ID, "reaction-list-by-creator")
|
||||
|
||||
// User 1 creates reaction
|
||||
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user1.ID,
|
||||
ContentID: contentID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -91,14 +161,14 @@ func TestReactionListByCreatorID(t *testing.T) {
|
||||
// User 2 creates reaction
|
||||
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user2.ID,
|
||||
ContentID: contentID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "❤️",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// List all reactions for content
|
||||
// List all reactions for the memo.
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &contentID,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reactions, 2)
|
||||
@@ -114,38 +184,50 @@ func TestReactionListByCreatorID(t *testing.T) {
|
||||
ts.Close()
|
||||
}
|
||||
|
||||
func TestReactionMultipleContentIDs(t *testing.T) {
|
||||
func TestReactionMultipleMemoIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
memo1 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-memo-1")
|
||||
memo2 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-memo-2")
|
||||
memo3 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-memo-3")
|
||||
|
||||
contentID1 := "content_1"
|
||||
contentID2 := "content_2"
|
||||
|
||||
// Create reactions for different contents
|
||||
// Create reactions for different memos.
|
||||
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: contentID1,
|
||||
MemoID: memo1.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: contentID2,
|
||||
MemoID: memo2.ID,
|
||||
ReactionType: "❤️",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: memo3.ID,
|
||||
ReactionType: "🔥",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// List by content ID list
|
||||
// List by memo ID list, excluding the third memo.
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
||||
ContentIDList: []string{contentID1, contentID2},
|
||||
MemoIDList: []int32{memo1.ID, memo2.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reactions, 2)
|
||||
require.ElementsMatch(t, []int32{memo1.ID, memo2.ID}, []int32{reactions[0].MemoID, reactions[1].MemoID})
|
||||
|
||||
reaction, err := ts.GetReaction(ctx, &store.FindReaction{MemoIDList: []int32{memo2.ID}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reaction)
|
||||
require.Equal(t, memo2.ID, reaction.MemoID)
|
||||
|
||||
ts.Close()
|
||||
}
|
||||
@@ -157,13 +239,12 @@ func TestReactionUpsertDifferentTypes(t *testing.T) {
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
|
||||
contentID := "test_content"
|
||||
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-different-types")
|
||||
|
||||
// Create first reaction
|
||||
reaction1, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: contentID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -171,7 +252,7 @@ func TestReactionUpsertDifferentTypes(t *testing.T) {
|
||||
// Create second reaction with different type (should create new, not update)
|
||||
reaction2, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: contentID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "❤️",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -180,10 +261,233 @@ func TestReactionUpsertDifferentTypes(t *testing.T) {
|
||||
require.NotEqual(t, reaction1.ID, reaction2.ID)
|
||||
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
||||
ContentID: &contentID,
|
||||
MemoID: &memo.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reactions, 2)
|
||||
|
||||
ts.Close()
|
||||
}
|
||||
|
||||
func TestReactionDeletedWithMemo(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-delete-with-memo")
|
||||
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, ts.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}))
|
||||
got, err := ts.GetReaction(ctx, &store.FindReaction{ID: &reaction.ID})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, got)
|
||||
|
||||
ts.Close()
|
||||
}
|
||||
|
||||
func TestReactionInsertedAfterMemoCleanupStartsIsRemoved(t *testing.T) {
|
||||
setupContext := context.Background()
|
||||
barrierDriver := &memoDeleteBarrierDriver{
|
||||
deleteStarted: make(chan struct{}),
|
||||
continueDelete: make(chan struct{}),
|
||||
}
|
||||
ts := newReactionTestingStoreWithDriver(setupContext, t, func(driver store.Driver) store.Driver {
|
||||
barrierDriver.Driver = driver
|
||||
return barrierDriver
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(setupContext, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-insert-during-delete")
|
||||
|
||||
deleteResult := make(chan error, 1)
|
||||
go func() {
|
||||
deleteResult <- ts.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID})
|
||||
}()
|
||||
deleteReleased := false
|
||||
defer func() {
|
||||
if !deleteReleased {
|
||||
close(barrierDriver.continueDelete)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-barrierDriver.deleteStarted:
|
||||
case err := <-deleteResult:
|
||||
require.FailNowf(t, "memo delete returned before reaching barrier", "error: %v", err)
|
||||
case <-ctx.Done():
|
||||
require.FailNow(t, "timed out waiting for memo delete barrier")
|
||||
}
|
||||
|
||||
// Store.DeleteMemo has completed its application-managed relation and
|
||||
// attachment cleanup, but the physical memo delete has not started. This
|
||||
// recreates the old window where a prior reaction cleanup could be bypassed.
|
||||
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reaction)
|
||||
|
||||
close(barrierDriver.continueDelete)
|
||||
deleteReleased = true
|
||||
select {
|
||||
case err := <-deleteResult:
|
||||
require.NoError(t, err)
|
||||
case <-ctx.Done():
|
||||
require.FailNow(t, "timed out waiting for memo delete")
|
||||
}
|
||||
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, reactions)
|
||||
}
|
||||
|
||||
func TestReactionUpsertBlocksWhileMemoDeleteIsUncommitted(t *testing.T) {
|
||||
setupContext := context.Background()
|
||||
ts := NewTestingStore(setupContext, t)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ts.Close())
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(setupContext, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
user, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-waits-for-delete")
|
||||
|
||||
dbTx, err := ts.GetDriver().GetDB().BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
txOpen := true
|
||||
defer func() {
|
||||
if txOpen {
|
||||
_ = dbTx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
deleteMemoQuery := "DELETE FROM memo WHERE id = ?"
|
||||
if getDriverFromEnv() == "postgres" {
|
||||
deleteMemoQuery = "DELETE FROM memo WHERE id = $1"
|
||||
}
|
||||
_, err = dbTx.ExecContext(ctx, deleteMemoQuery, memo.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var reaction *store.Reaction
|
||||
var upsertErr error
|
||||
if getDriverFromEnv() == "sqlite" {
|
||||
type upsertResult struct {
|
||||
reaction *store.Reaction
|
||||
err error
|
||||
}
|
||||
resultChannel := make(chan upsertResult, 1)
|
||||
go func() {
|
||||
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
resultChannel <- upsertResult{reaction: reaction, err: err}
|
||||
}()
|
||||
|
||||
poll := time.NewTicker(time.Millisecond)
|
||||
defer poll.Stop()
|
||||
for ts.GetDriver().GetDB().Stats().InUse < 2 {
|
||||
select {
|
||||
case result := <-resultChannel:
|
||||
require.FailNowf(t, "reaction upsert completed before memo delete committed", "reaction: %+v, error: %v", result.reaction, result.err)
|
||||
case <-poll.C:
|
||||
case <-ctx.Done():
|
||||
require.FailNow(t, "timed out waiting for reaction upsert to reach SQLite")
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, dbTx.Commit())
|
||||
txOpen = false
|
||||
select {
|
||||
case result := <-resultChannel:
|
||||
reaction, upsertErr = result.reaction, result.err
|
||||
case <-ctx.Done():
|
||||
require.FailNow(t, "timed out waiting for SQLite reaction upsert")
|
||||
}
|
||||
} else {
|
||||
// Both client/server database engines must wait for the uncommitted
|
||||
// parent deletion before deciding whether the memo still exists.
|
||||
lockWaitContext, lockWaitCancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
||||
startTime := time.Now()
|
||||
reaction, upsertErr = ts.UpsertReaction(lockWaitContext, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
lockWaitCancel()
|
||||
require.Error(t, upsertErr)
|
||||
require.Nil(t, reaction)
|
||||
require.GreaterOrEqual(t, time.Since(startTime), 450*time.Millisecond, "reaction upsert must wait for the memo delete transaction")
|
||||
|
||||
require.NoError(t, dbTx.Commit())
|
||||
txOpen = false
|
||||
reaction, upsertErr = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
MemoID: memo.ID,
|
||||
ReactionType: "👍",
|
||||
})
|
||||
}
|
||||
require.ErrorIs(t, upsertErr, store.ErrReactionMemoNotFound)
|
||||
require.Nil(t, reaction)
|
||||
|
||||
reactions, err := ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, reactions)
|
||||
}
|
||||
|
||||
type memoDeleteBarrierDriver struct {
|
||||
store.Driver
|
||||
deleteStarted chan struct{}
|
||||
continueDelete chan struct{}
|
||||
}
|
||||
|
||||
func (d *memoDeleteBarrierDriver) DeleteMemo(ctx context.Context, delete *store.DeleteMemo) error {
|
||||
close(d.deleteStarted)
|
||||
select {
|
||||
case <-d.continueDelete:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return d.Driver.DeleteMemo(ctx, delete)
|
||||
}
|
||||
|
||||
func newReactionTestingStoreWithDriver(ctx context.Context, t *testing.T, wrap func(store.Driver) store.Driver) *store.Store {
|
||||
t.Helper()
|
||||
testingProfile := getTestingProfileForDriver(t, getDriverFromEnv())
|
||||
databaseDriver, err := db.NewDBDriver(testingProfile)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts := store.New(wrap(databaseDriver), testingProfile)
|
||||
require.NoError(t, ts.Migrate(ctx))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, ts.Close())
|
||||
})
|
||||
return ts
|
||||
}
|
||||
|
||||
func createReactionTestMemo(ctx context.Context, t *testing.T, ts *store.Store, creatorID int32, uid string) *store.Memo {
|
||||
t.Helper()
|
||||
memo, err := ts.CreateMemo(ctx, &store.Memo{
|
||||
UID: uid,
|
||||
CreatorID: creatorID,
|
||||
Content: uid,
|
||||
Visibility: store.Private,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return memo
|
||||
}
|
||||
|
||||
@@ -96,19 +96,19 @@ func TestDeleteUserCleansRelatedData(t *testing.T) {
|
||||
|
||||
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: "memos/" + ownMemo.UID,
|
||||
MemoID: ownMemo.ID,
|
||||
ReactionType: "thumbs-up",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: user.ID,
|
||||
ContentID: "memos/" + peerMemo.UID,
|
||||
MemoID: peerMemo.ID,
|
||||
ReactionType: "heart",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
peerReactionToKeep, err := ts.UpsertReaction(ctx, &store.Reaction{
|
||||
CreatorID: peer.ID,
|
||||
ContentID: "memos/" + peerMemo.UID,
|
||||
MemoID: peerMemo.ID,
|
||||
ReactionType: "sparkle",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -213,8 +213,7 @@ func TestDeleteUserCleansRelatedData(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, deletedMemoRelations)
|
||||
|
||||
peerMemoContentID := "memos/" + peerMemo.UID
|
||||
keptReactions, err := ts.ListReactions(ctx, &store.FindReaction{ContentID: &peerMemoContentID})
|
||||
keptReactions, err := ts.ListReactions(ctx, &store.FindReaction{MemoID: &peerMemo.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keptReactions, 1)
|
||||
require.Equal(t, peerReactionToKeep.ID, keptReactions[0].ID)
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptio
|
||||
} else {
|
||||
await memoServiceClient.upsertMemoReaction({
|
||||
name: memo.name,
|
||||
reaction: { contentId: memo.name, reactionType },
|
||||
reaction: { reactionType },
|
||||
});
|
||||
}
|
||||
// Refetch the memo to get updated reactions and invalidate cache
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user