feat(repository): refactor query/mutation ports with capability-safe
interfaces - refactor: split UserRepository into AuthUserRepository and AdminUserRepository capabilities - refactor: split SessionRepository into AuthSessionRepository and repository-owned CLI/admin methods - refactor: replace generic Repository Update/Delete with operation-specific params and ownership predicates - refactor: replace CredentialRepository generic CRUD with passkey-specific methods - refactor: replace FileRepository generic Create/Update/Delete with UploadedFileParams, DirectoryParams, and owned soft-delete - refactor: remove repository fields from WebApp struct; repositories are now composition-time wiring only - feat: add domain error kinds ErrParentNotFound, ErrParentNotDir, ErrDirectoryNotEmpty, ErrInvalidMove - feat: add CredentialTypeAppPasskey constant - feat: add testutil.SetUserAdmin for test fixture setup that bypasses production service ports - test: add architecture test banning GORM Save in repository package - test: add capability interface contract tests ensuring each service receives the minimal interface - test: add blockingStorage helper for concurrent promotion tests - test: add preserved DSN parameter test for sqliteImmediateDSN - docs: update architecture decisions with repository write rules and capability separation - docs: update roadmap to clarify atomic single-use refresh sessions - docs: add -race test target to development docs
This commit is contained in:
+226
-27
@@ -3,22 +3,54 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// UploadedFileParams contains persisted metadata for a completed upload.
|
||||
type UploadedFileParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
Size int64
|
||||
MimeType string
|
||||
StoragePath string
|
||||
Hash string
|
||||
}
|
||||
|
||||
// DirectoryParams contains the fields accepted when creating a directory.
|
||||
type DirectoryParams struct {
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
}
|
||||
|
||||
// FileMetadataUpdate contains the user-editable metadata fields.
|
||||
// Nil fields are left unchanged.
|
||||
type FileMetadataUpdate struct {
|
||||
FileID string
|
||||
UserID string
|
||||
NewName *string
|
||||
NewParentID *string
|
||||
}
|
||||
|
||||
// FileRepository provides access to file records.
|
||||
type FileRepository interface {
|
||||
Create(ctx context.Context, file *model.File) error
|
||||
CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error)
|
||||
CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error)
|
||||
FindByID(ctx context.Context, id string) (*model.File, error)
|
||||
FindByUserID(ctx context.Context, userID string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByParentID(ctx context.Context, userID string, parentID *string) ([]model.File, error)
|
||||
FindByParentIDPaginated(ctx context.Context, userID string, parentID *string, offset, limit int) ([]model.File, int64, error)
|
||||
FindByNameAndParent(ctx context.Context, userID string, parentID *string, name string) (*model.File, error)
|
||||
Update(ctx context.Context, file *model.File) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error)
|
||||
SoftDeleteOwned(ctx context.Context, userID, fileID string) error
|
||||
}
|
||||
|
||||
type fileRepository struct {
|
||||
@@ -30,15 +62,65 @@ func NewFileRepository(db *gorm.DB) FileRepository {
|
||||
return &fileRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *fileRepository) Create(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Create(file)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
func (r *fileRepository) CreateUploadedFile(ctx context.Context, params UploadedFileParams) (*model.File, error) {
|
||||
file := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Size: params.Size,
|
||||
MimeType: params.MimeType,
|
||||
StoragePath: params.StoragePath,
|
||||
Hash: params.Hash,
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
return nil
|
||||
if err := r.createEntry(ctx, file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) CreateDirectory(ctx context.Context, params DirectoryParams) (*model.File, error) {
|
||||
dir := &model.File{
|
||||
ID: params.ID,
|
||||
UserID: params.UserID,
|
||||
ParentID: params.ParentID,
|
||||
Name: params.Name,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
if err := r.createEntry(ctx, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) createEntry(ctx context.Context, file *model.File) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if file.ParentID != nil {
|
||||
locked, err := lockActiveOwnedFiles(tx, file.UserID, []string{*file.ParentID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent, ok := locked[*file.ParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Create(file)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *fileRepository) FindByID(ctx context.Context, id string) (*model.File, error) {
|
||||
@@ -106,24 +188,141 @@ func (r *fileRepository) FindByNameAndParent(ctx context.Context, userID string,
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) Update(ctx context.Context, file *model.File) error {
|
||||
result := r.db.WithContext(ctx).Save(file)
|
||||
if result.Error != nil {
|
||||
func (r *fileRepository) UpdateOwnedMetadata(ctx context.Context, params FileMetadataUpdate) (*model.File, error) {
|
||||
var updated model.File
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ids := []string{params.FileID}
|
||||
if params.NewParentID != nil {
|
||||
if *params.NewParentID == params.FileID {
|
||||
return model.ErrInvalidMove
|
||||
}
|
||||
ids = append(ids, *params.NewParentID)
|
||||
}
|
||||
|
||||
locked, err := lockActiveOwnedFiles(tx, params.UserID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, ok := locked[params.FileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
updates := make(map[string]any, 2)
|
||||
finalName := source.Name
|
||||
finalParentID := source.ParentID
|
||||
if params.NewName != nil {
|
||||
updates["name"] = *params.NewName
|
||||
finalName = *params.NewName
|
||||
}
|
||||
if params.NewParentID != nil {
|
||||
parent, ok := locked[*params.NewParentID]
|
||||
if !ok {
|
||||
return model.ErrParentNotFound
|
||||
}
|
||||
if !parent.IsDir {
|
||||
return model.ErrParentNotDir
|
||||
}
|
||||
updates["parent_id"] = *params.NewParentID
|
||||
finalParentID = params.NewParentID
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
var conflictCount int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("user_id = ? AND parent_id IS ? AND name = ? AND status = ? AND id <> ?", params.UserID, finalParentID, finalName, model.StatusActive, params.FileID).
|
||||
Count(&conflictCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if conflictCount > 0 {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", params.FileID, params.UserID, model.StatusActive).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.First(&updated, "id = ? AND user_id = ? AND status = ?", source.ID, params.UserID, model.StatusActive)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return result.Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (r *fileRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(&model.File{}).
|
||||
Where("id = ? AND status = ?", id, model.StatusActive).
|
||||
Update("status", model.StatusUserDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
func (r *fileRepository) SoftDeleteOwned(ctx context.Context, userID, fileID string) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
locked, err := lockActiveOwnedFiles(tx, userID, []string{fileID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, ok := locked[fileID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
if file.IsDir {
|
||||
var count int64
|
||||
if err := tx.Model(&model.File{}).
|
||||
Where("parent_id = ? AND status = ?", fileID, model.StatusActive).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return model.ErrDirectoryNotEmpty
|
||||
}
|
||||
}
|
||||
|
||||
result := tx.Model(&model.File{}).
|
||||
Where("id = ? AND user_id = ? AND status = ?", fileID, userID, model.StatusActive).
|
||||
Update("status", model.StatusUserDeleted)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func lockActiveOwnedFiles(tx *gorm.DB, userID string, ids []string) (map[string]model.File, error) {
|
||||
unique := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
unique[id] = struct{}{}
|
||||
}
|
||||
ordered := make([]string, 0, len(unique))
|
||||
for id := range unique {
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
|
||||
var files []model.File
|
||||
result := tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
|
||||
Where("id IN ? AND user_id = ? AND status = ?", ordered, userID, model.StatusActive).
|
||||
Order("id ASC").
|
||||
Find(&files)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
locked := make(map[string]model.File, len(files))
|
||||
for i := range files {
|
||||
locked[files[i].ID] = files[i]
|
||||
}
|
||||
return locked, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user