sync_apply FS-first rewrite; CreateNodeFromTemplate rollback; DeleteNodeAndChildren fail on trash errors; PLAN.md update

- applyRemoteNodeUpdate: FS-first with SafeVaultPath validation, must-fail os.Rename
- applyRemoteNodeMove: FS-first for folders and notes/files
- moveNodeFiles: rewritten FS-first with atomic DB transaction
- applyRemoteNoteMove: delegates to moveNodeFiles
- CreateNodeFromTemplate: rollbackChildren on any child creation failure
- DeleteToTrash: skip rename if source file already missing
- DeleteNodeAndChildren: fail on deleteFileRecords errors and trash move failures
- docs/PLAN.md: update step 14 status with known gaps
This commit is contained in:
2026-06-03 02:22:49 +08:00
parent 7e38ffed7b
commit d285f9ad8b
4 changed files with 244 additions and 123 deletions
+24 -12
View File
@@ -245,7 +245,9 @@ func (s *Service) DeleteToTrash(id string) error {
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
return err
}
if err := os.Rename(src, dest); err != nil {
if _, statErr := os.Stat(src); os.IsNotExist(statErr) {
// File already gone — just remove the DB record.
} else if err := os.Rename(src, dest); err != nil {
return fmt.Errorf("move to trash: %w", err)
}
}
@@ -473,11 +475,17 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error {
}
collect(nodeID)
// Phase 1: FS trash moves (best-effort, collect errors).
var trashErrors []string
var movedTrash []struct{ src, dst string }
// Phase 1: FS trash moves (with rollback on partial failure).
type trashMove struct{ src, dst string }
var movedTrash []trashMove
for _, n := range toDelete {
_ = s.deleteFileRecords(n.ID)
if err := s.deleteFileRecords(n.ID); err != nil {
// Undo completed trash moves.
for _, mt := range movedTrash {
_ = os.Rename(mt.dst, mt.src)
}
return fmt.Errorf("delete file records for %s: %w", n.ID, err)
}
if n.FsPath == "" {
continue
}
@@ -494,15 +502,20 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error {
}
dst := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
if err := os.Rename(src, dst); err != nil {
trashErrors = append(trashErrors, fmt.Sprintf("node %s: %v", n.ID, err))
} else {
movedTrash = append(movedTrash, struct{ src, dst string }{src, dst})
for _, mt := range movedTrash {
_ = os.Rename(mt.dst, mt.src)
}
return fmt.Errorf("trash move node %s: %w", n.ID, err)
}
movedTrash = append(movedTrash, trashMove{src, dst})
}
// Phase 2: DB soft-deletes in a single transaction.
tx, err := s.db.Begin()
if err != nil {
for _, mt := range movedTrash {
_ = os.Rename(mt.dst, mt.src)
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
@@ -513,12 +526,14 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error {
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
t, t, n.ID)
if err != nil {
for _, mt := range movedTrash {
_ = os.Rename(mt.dst, mt.src)
}
return fmt.Errorf("soft-delete %s: %w", n.ID, err)
}
}
if err := tx.Commit(); err != nil {
// Rollback trash moves (best-effort).
for _, mt := range movedTrash {
if rerr := os.Rename(mt.dst, mt.src); rerr != nil {
log.Printf("rollback trash move failed: %v", rerr)
@@ -527,9 +542,6 @@ func (s *Service) DeleteNodeAndChildren(nodeID string) error {
return fmt.Errorf("commit tx: %w", err)
}
if len(trashErrors) > 0 {
log.Printf("warn: trash errors during delete (DB was updated): %v", trashErrors)
}
return nil
}