feature: move docs
This commit is contained in:
@@ -51,6 +51,14 @@ type PersistDocumentInput struct {
|
||||
Tags []string
|
||||
}
|
||||
|
||||
type MoveDocumentInput struct {
|
||||
ID string
|
||||
OldPath string
|
||||
NewPath string
|
||||
ExpectedHash string
|
||||
Title string
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
@@ -109,6 +117,81 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat
|
||||
return r.getDocumentByPath(ctx, path, true)
|
||||
}
|
||||
|
||||
func (r *Repository) GetDocumentByPathOrAlias(ctx context.Context, path string) (*DocumentRecord, error) {
|
||||
record, err := r.GetDocumentByPath(ctx, path)
|
||||
if err == nil || !errors.Is(err, sql.ErrNoRows) {
|
||||
return record, err
|
||||
}
|
||||
|
||||
var documentID string
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT document_id FROM document_aliases WHERE path = ?
|
||||
`, path).Scan(&documentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.GetDocumentByID(ctx, documentID)
|
||||
}
|
||||
|
||||
func (r *Repository) MoveDocument(ctx context.Context, input MoveDocumentInput) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin document move: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var aliasDocumentID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT document_id FROM document_aliases WHERE path = ?`, input.NewPath).Scan(&aliasDocumentID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("check destination alias: %w", err)
|
||||
}
|
||||
if err == nil && aliasDocumentID != input.ID {
|
||||
return fmt.Errorf("destination path is reserved by another document")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM document_aliases WHERE path = ? AND document_id = ?`, input.NewPath, input.ID); err != nil {
|
||||
return fmt.Errorf("remove destination alias: %w", err)
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE documents
|
||||
SET path = ?, title = ?, updated_at = ?
|
||||
WHERE id = ? AND path = ? AND current_hash = ? AND archived_at IS NULL
|
||||
`, input.NewPath, input.Title, time.Now().UTC().Format(time.RFC3339), input.ID, input.OldPath, input.ExpectedHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update document path: %w", err)
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("document move rows affected: %w", err)
|
||||
}
|
||||
if changed != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE attachments SET document_path = ? WHERE document_path = ?
|
||||
`, input.NewPath, input.OldPath); err != nil {
|
||||
return fmt.Errorf("move attachment metadata: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE permissions SET resource_id = ?
|
||||
WHERE resource_type = 'document' AND resource_id = ?
|
||||
`, input.NewPath, input.OldPath); err != nil {
|
||||
return fmt.Errorf("move document permissions: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO document_aliases (path, document_id, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET document_id = excluded.document_id, created_at = excluded.created_at
|
||||
`, input.OldPath, input.ID, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return fmt.Errorf("create document alias: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit document move: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) {
|
||||
var (
|
||||
record DocumentRecord
|
||||
|
||||
@@ -27,12 +27,25 @@ type Service struct {
|
||||
}
|
||||
|
||||
type DocumentChange struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
DocumentID string `json:"documentId"`
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"oldPath,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Hash string `json:"hash"`
|
||||
PreviousHash string `json:"previousHash,omitempty"`
|
||||
}
|
||||
|
||||
type DocumentMoveConflictError struct {
|
||||
OldPath string
|
||||
NewPath string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *DocumentMoveConflictError) Error() string {
|
||||
return fmt.Sprintf("cannot move %s to %s: %s", e.OldPath, e.NewPath, e.Reason)
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Path string
|
||||
Title string
|
||||
@@ -146,9 +159,10 @@ func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
|
||||
|
||||
if s.onChange != nil {
|
||||
s.onChange(DocumentChange{
|
||||
Path: record.Path,
|
||||
Title: record.Title,
|
||||
Hash: record.CurrentHash,
|
||||
DocumentID: record.ID,
|
||||
Path: record.Path,
|
||||
Title: record.Title,
|
||||
Hash: record.CurrentHash,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,6 +184,96 @@ func (s *Service) RestoreDocument(ctx context.Context, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MoveDocument(ctx context.Context, requestPath, destinationPath, expectedHash string) (*DocumentRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, err := s.syncSourceDirLocked(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldPath, err := NormalizeDocumentPath(requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newPath, err := NormalizeDocumentPath(destinationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oldPath == newPath {
|
||||
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"}
|
||||
}
|
||||
|
||||
record, err := s.repo.GetDocumentByPath(ctx, oldPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expectedHash != "" && record.CurrentHash != expectedHash {
|
||||
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "document changed since the page loaded"}
|
||||
}
|
||||
if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, newPath); err == nil {
|
||||
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists"}
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if aliased, err := s.repo.GetDocumentByPathOrAlias(ctx, newPath); err == nil && aliased.ID != record.ID {
|
||||
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is reserved by another document"}
|
||||
} else if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(oldPath))
|
||||
newFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(newPath))
|
||||
if _, err := os.Stat(newFullPath); err == nil {
|
||||
return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists"}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("check destination file: %w", err)
|
||||
}
|
||||
content, err := os.ReadFile(oldFullPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read source document: %w", err)
|
||||
}
|
||||
rendered, err := s.renderer.RenderPath(content, newPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render moved document: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newFullPath), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create destination folder: %w", err)
|
||||
}
|
||||
if err := os.Rename(oldFullPath, newFullPath); err != nil {
|
||||
return nil, fmt.Errorf("move document file: %w", err)
|
||||
}
|
||||
|
||||
moveErr := s.repo.MoveDocument(ctx, MoveDocumentInput{
|
||||
ID: record.ID,
|
||||
OldPath: oldPath,
|
||||
NewPath: newPath,
|
||||
ExpectedHash: record.CurrentHash,
|
||||
Title: rendered.Title,
|
||||
})
|
||||
if moveErr != nil {
|
||||
if rollbackErr := os.Rename(newFullPath, oldFullPath); rollbackErr != nil {
|
||||
return nil, fmt.Errorf("move database update failed: %v; filesystem rollback failed: %w", moveErr, rollbackErr)
|
||||
}
|
||||
return nil, moveErr
|
||||
}
|
||||
|
||||
updated, err := s.repo.GetDocumentByID(ctx, record.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.onChange != nil {
|
||||
s.onChange(DocumentChange{
|
||||
Type: "move",
|
||||
DocumentID: updated.ID,
|
||||
Path: updated.Path,
|
||||
OldPath: oldPath,
|
||||
Title: updated.Title,
|
||||
Hash: updated.CurrentHash,
|
||||
})
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
|
||||
if _, err := s.SyncSourceDir(ctx); err != nil {
|
||||
return nil, err
|
||||
@@ -301,10 +405,10 @@ func (s *Service) resolveRecord(ctx context.Context, requestPath string) (*Docum
|
||||
lastErr error
|
||||
)
|
||||
for _, candidate := range normalizeRequestPathCandidates(requestPath) {
|
||||
found, err := s.repo.GetDocumentByPath(ctx, candidate)
|
||||
found, err := s.repo.GetDocumentByPathOrAlias(ctx, candidate)
|
||||
if err == nil {
|
||||
record = found
|
||||
normalized = candidate
|
||||
normalized = found.Path
|
||||
break
|
||||
}
|
||||
lastErr = err
|
||||
@@ -342,6 +446,10 @@ func normalizeWritableDocumentPath(path string) (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func NormalizeDocumentPath(path string) (string, error) {
|
||||
return normalizeWritableDocumentPath(path)
|
||||
}
|
||||
|
||||
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -400,6 +508,7 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e
|
||||
|
||||
s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
|
||||
return &DocumentChange{
|
||||
DocumentID: documentID,
|
||||
Path: relative,
|
||||
Title: rendered.Title,
|
||||
Hash: record.Hash,
|
||||
|
||||
@@ -182,6 +182,127 @@ func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveDocumentPreservesIdentityAndHistory(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
page, err := service.LoadSourcePage(ctx, "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSourcePage() error = %v", err)
|
||||
}
|
||||
before, err := service.repo.GetDocumentByPath(ctx, "hello.md")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByPath() error = %v", err)
|
||||
}
|
||||
var versionsBefore int
|
||||
if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsBefore); err != nil {
|
||||
t.Fatalf("count versions before move: %v", err)
|
||||
}
|
||||
if err := service.repo.SaveAttachment(ctx, AttachmentRecord{
|
||||
Hash: "attachment-hash", OriginalName: "diagram.png", ContentType: "image/png",
|
||||
SizeBytes: 10, CreatedAt: time.Now().UTC(), DocumentPath: before.Path,
|
||||
}); err != nil {
|
||||
t.Fatalf("save attachment: %v", err)
|
||||
}
|
||||
if _, err := service.repo.db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, email, display_name, created_at) VALUES ('user-move', 'move@example.com', 'Mover', ?)
|
||||
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
t.Fatalf("create permission user: %v", err)
|
||||
}
|
||||
if _, err := service.repo.db.ExecContext(ctx, `
|
||||
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
|
||||
VALUES ('permission-move', 'user-move', 'document', ?, 'write', ?)
|
||||
`, before.Path, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
t.Fatalf("create document permission: %v", err)
|
||||
}
|
||||
var moveChange DocumentChange
|
||||
service.OnChange(func(change DocumentChange) {
|
||||
moveChange = change
|
||||
})
|
||||
|
||||
moved, err := service.MoveDocument(ctx, "hello.md", "guides/welcome.md", page.Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveDocument() error = %v", err)
|
||||
}
|
||||
if moved.ID != before.ID {
|
||||
t.Fatalf("moved ID = %q, want %q", moved.ID, before.ID)
|
||||
}
|
||||
if moved.Path != "guides/welcome.md" {
|
||||
t.Fatalf("moved path = %q, want guides/welcome.md", moved.Path)
|
||||
}
|
||||
if moveChange.Type != "move" || moveChange.DocumentID != moved.ID || moveChange.OldPath != "hello.md" || moveChange.Path != moved.Path {
|
||||
t.Fatalf("move change = %#v", moveChange)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("old file still exists or stat failed: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(sourceDir, "guides", "welcome.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read moved file: %v", err)
|
||||
}
|
||||
if string(content) != page.Content {
|
||||
t.Fatalf("moved content = %q, want %q", content, page.Content)
|
||||
}
|
||||
|
||||
alias, err := service.repo.GetDocumentByPathOrAlias(ctx, "hello.md")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve old path alias: %v", err)
|
||||
}
|
||||
if alias.ID != before.ID || alias.Path != moved.Path {
|
||||
t.Fatalf("alias resolved to %#v, want ID %q at %q", alias, before.ID, moved.Path)
|
||||
}
|
||||
var versionsAfter int
|
||||
if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsAfter); err != nil {
|
||||
t.Fatalf("count versions after move: %v", err)
|
||||
}
|
||||
if versionsAfter != versionsBefore {
|
||||
t.Fatalf("versions after move = %d, want %d", versionsAfter, versionsBefore)
|
||||
}
|
||||
attachment, err := service.repo.GetAttachment(ctx, "attachment-hash")
|
||||
if err != nil {
|
||||
t.Fatalf("get moved attachment metadata: %v", err)
|
||||
}
|
||||
if attachment.DocumentPath != moved.Path {
|
||||
t.Fatalf("attachment document path = %q, want %q", attachment.DocumentPath, moved.Path)
|
||||
}
|
||||
var permissionPath string
|
||||
if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-move'`).Scan(&permissionPath); err != nil {
|
||||
t.Fatalf("get moved permission: %v", err)
|
||||
}
|
||||
if permissionPath != moved.Path {
|
||||
t.Fatalf("permission resource path = %q, want %q", permissionPath, moved.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveDocumentRejectsStaleHashAndOccupiedDestination(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
if _, err := service.LoadSourcePage(ctx, "hello"); err != nil {
|
||||
t.Fatalf("LoadSourcePage() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := service.MoveDocument(ctx, "hello.md", "moved.md", "stale")
|
||||
var conflict *DocumentMoveConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("stale MoveDocument() error = %v, want DocumentMoveConflictError", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); err != nil {
|
||||
t.Fatalf("source file changed after stale move: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "occupied.md"), []byte("# Occupied\n"), 0o644); err != nil {
|
||||
t.Fatalf("create occupied destination: %v", err)
|
||||
}
|
||||
page, err := service.LoadSourcePage(ctx, "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("reload source page: %v", err)
|
||||
}
|
||||
_, err = service.MoveDocument(ctx, "hello.md", "occupied.md", page.Hash)
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("occupied MoveDocument() error = %v, want DocumentMoveConflictError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
|
||||
service, _ := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user