memos/store/memo_attachment.go

121 lines
4.8 KiB
Go

package store
import (
"context"
"errors"
"slices"
)
// ErrMemoMutationConflict indicates that memo or attachment state changed
// after an API request prepared its mutation.
var ErrMemoMutationConflict = errors.New("memo state changed")
// MemoAttachmentBinding describes one attachment that should be bound to a
// memo. WasBoundToMemo distinguishes an existing binding from a new one so the
// driver can reject ownership transfers while preserving legacy rows already
// attached to the memo.
type MemoAttachmentBinding struct {
ID int32
UID string
UpdatedTs int64
WasBoundToMemo bool
}
// MemoMutation atomically updates a memo, its attachment bindings, and its
// reference relations. Removed attachment rows are deleted in the same
// transaction; callers may clean up external objects after the commit.
type MemoMutation struct {
// MemoCreate requests that the memo row be created in this same transaction
// before attachments and relations are applied. CommentContextMemoID creates
// one immutable COMMENT relation from the new memo to the named context memo.
MemoCreate *Memo
CommentContextMemoID *int32
MemoID int32
MemoCreatorID int32
ExpectedMemoContent string
MemoUpdate *UpdateMemo
Bindings []*MemoAttachmentBinding
RemovedAttachmentIDs []int32
RequiredAttachmentIDs []int32
ReplaceReferenceRelations bool
ReferenceRelations []*MemoRelation
Policy *MemoWritePolicy
}
// ApplyMemoMutation atomically applies memo fields, attachment bindings, and
// reference relations after rechecking the state used by API validation.
func (s *Store) ApplyMemoMutation(ctx context.Context, mutation *MemoMutation) error {
if mutation == nil {
return errors.New("memo mutation is required")
}
if mutation.MemoCreate != nil {
if mutation.MemoUpdate != nil || mutation.Policy != nil || len(mutation.RemovedAttachmentIDs) != 0 {
return errors.New("memo creation mutation cannot update or remove existing state")
}
create := mutation.MemoCreate
if create.CreatorID <= 0 {
return errors.New("memo creation mutation requires creator")
}
if err := validateMemoCreate(create); err != nil {
return err
}
if mutation.CommentContextMemoID != nil && *mutation.CommentContextMemoID <= 0 {
return errors.New("comment creation mutation requires a valid context memo")
}
mutation.MemoCreatorID = create.CreatorID
mutation.ExpectedMemoContent = create.Content
}
if err := validateMemoWritePolicy(mutation.Policy); err != nil {
return err
}
mutation.RemovedAttachmentIDs = slices.Clone(mutation.RemovedAttachmentIDs)
slices.Sort(mutation.RemovedAttachmentIDs)
removedAttachmentIDs := make(map[int32]struct{}, len(mutation.RemovedAttachmentIDs))
for index, attachmentID := range mutation.RemovedAttachmentIDs {
if attachmentID <= 0 {
return errors.New("removed attachment IDs must be positive")
}
if index > 0 && mutation.RemovedAttachmentIDs[index-1] == attachmentID {
return errors.New("duplicate removed attachment ID")
}
removedAttachmentIDs[attachmentID] = struct{}{}
}
for _, binding := range mutation.Bindings {
if binding != nil {
if _, removed := removedAttachmentIDs[binding.ID]; removed {
return errors.New("attachment cannot be both bound and removed")
}
}
}
for _, attachmentID := range mutation.RequiredAttachmentIDs {
if _, removed := removedAttachmentIDs[attachmentID]; removed {
return errors.New("attachment cannot be both required and removed")
}
}
if mutation.Policy != nil && mutation.Policy.LifecycleOnly &&
(len(mutation.Bindings) != 0 || len(mutation.RemovedAttachmentIDs) != 0 || len(mutation.RequiredAttachmentIDs) != 0 || mutation.ReplaceReferenceRelations) {
return ErrMemoSpaceMembershipRequired
}
if mutation.ReplaceReferenceRelations {
if mutation.MemoCreatorID <= 0 {
return errors.New("reference relation mutation requires an actor")
}
seenRelatedMemoIDs := make(map[int32]struct{}, len(mutation.ReferenceRelations))
for _, relation := range mutation.ReferenceRelations {
if relation == nil || relation.RelatedMemoID <= 0 || relation.Type != MemoRelationReference {
return errors.New("only REFERENCE memo relations may be mutated")
}
if mutation.MemoCreate == nil && relation.MemoID != mutation.MemoID {
return errors.New("reference relation source does not match memo mutation")
}
if mutation.MemoID > 0 && relation.RelatedMemoID == mutation.MemoID {
return errors.New("reflexive memo relations are not allowed")
}
if _, ok := seenRelatedMemoIDs[relation.RelatedMemoID]; ok {
return errors.New("duplicate memo reference relation")
}
seenRelatedMemoIDs[relation.RelatedMemoID] = struct{}{}
}
}
return s.driver.ApplyMemoMutation(ctx, mutation)
}