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

This commit is contained in:
amblued
2026-08-22 00:12:35 +08:00
committed by GitHub
parent 79baf2eab7
commit fe9a9bdf64
43 changed files with 1036 additions and 539 deletions
+21
View File
@@ -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
+4 -9
View File
@@ -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)
+5 -17
View File
@@ -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" +
+2 -4
View File
@@ -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
View File
@@ -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: {}
+8 -11
View File
@@ -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")
}
+7 -14
View File
@@ -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
+1 -5
View File
@@ -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 {
+1 -2
View File
@@ -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")
+28 -26
View File
@@ -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")
}
+2 -2
View File
@@ -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),
-2
View File
@@ -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)
-1
View File
@@ -156,7 +156,6 @@ func TestBuildToolFromOperationTailorsRequestBodySchemas(t *testing.T) {
"memo": "memos/abc123",
"body": map[string]any{
"reaction": map[string]any{
"contentId": "memos/abc123",
"reactionType": "👍",
},
},
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+44 -63
View File
@@ -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 {
+13 -5
View File
@@ -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
}
+34 -13
View File
@@ -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
}
+31 -50
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+31 -50
View File
@@ -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`;
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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;
+3 -3
View File
@@ -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
View File
@@ -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) {
+35 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+4 -5
View File
@@ -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