fix: third stabilization pass — template children as nodes, atomicity, fs_path validation, sync_apply compat, smoke test
This commit is contained in:
+95
-21
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -450,29 +451,86 @@ func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
|
||||
}
|
||||
|
||||
// DeleteNodeAndChildren soft-deletes a node and all descendants,
|
||||
// moving vault files to trash.
|
||||
// moving vault files to trash. All DB updates happen inside a transaction
|
||||
// so that partial failure does not leave an inconsistent DB state.
|
||||
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
|
||||
children, _ := s.nodes.ListChildren(nodeID, false)
|
||||
for i := range children {
|
||||
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
|
||||
return err
|
||||
// Collect all nodes to delete bottom-up (children before parent).
|
||||
var toDelete []*nodes.Node
|
||||
var collect func(id string)
|
||||
collect = func(id string) {
|
||||
children, err := s.nodes.ListChildren(id, false)
|
||||
if err != nil {
|
||||
children = nil
|
||||
}
|
||||
for i := range children {
|
||||
collect(children[i].ID)
|
||||
}
|
||||
n, err := s.nodes.GetActive(id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
toDelete = append(toDelete, n)
|
||||
}
|
||||
collect(nodeID)
|
||||
|
||||
// Phase 1: FS trash moves (best-effort, collect errors).
|
||||
var trashErrors []string
|
||||
var movedTrash []struct{ src, dst string }
|
||||
for _, n := range toDelete {
|
||||
_ = s.deleteFileRecords(n.ID)
|
||||
if n.FsPath == "" {
|
||||
continue
|
||||
}
|
||||
src, err := s.vaultPath(n.FsPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info, statErr := os.Stat(src); statErr != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
continue
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
_ = s.deleteFileRecords(nodeID)
|
||||
n, err := s.nodes.GetActive(nodeID)
|
||||
if err == nil && n.FsPath != "" {
|
||||
src, vaultErr := s.vaultPath(n.FsPath)
|
||||
if vaultErr != nil {
|
||||
src = filepath.Join(s.vaultRoot, n.FsPath)
|
||||
}
|
||||
if info, statErr := os.Stat(src); statErr == nil && info.IsDir() {
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
os.MkdirAll(trashDir, 0o750)
|
||||
trashPath := filepath.Join(trashDir, n.ID+"_"+templates.SafeDisplayNameToPathSegment(n.Title))
|
||||
os.Rename(src, trashPath)
|
||||
|
||||
// Phase 2: DB soft-deletes in a single transaction.
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, n := range toDelete {
|
||||
t := now()
|
||||
_, err := tx.Exec(
|
||||
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
t, t, n.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("soft-delete %s: %w", n.ID, err)
|
||||
}
|
||||
}
|
||||
return s.nodes.SoftDelete(nodeID)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Service) deleteFileRecords(nodeID string) error {
|
||||
@@ -480,8 +538,14 @@ func (s *Service) deleteFileRecords(nodeID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []string
|
||||
for _, r := range records {
|
||||
_ = s.DeleteToTrash(r.ID)
|
||||
if err := s.DeleteToTrash(r.ID); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("file %s: %v", r.ID, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("trash errors: %v", errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -698,6 +762,10 @@ func openWithSystem(path string) error {
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func now() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// --- scanning helpers ---
|
||||
|
||||
type scanFace interface {
|
||||
@@ -706,11 +774,11 @@ type scanFace interface {
|
||||
|
||||
func scanRecord(s scanFace) (*Record, error) {
|
||||
var r Record
|
||||
var lastSeen sql.NullString
|
||||
var lastSeen, sha256, mime sql.NullString
|
||||
var createdStr, updatedStr string
|
||||
err := s.Scan(
|
||||
&r.ID, &r.NodeID, &r.Filename, &r.Path, &r.StorageMode,
|
||||
&r.Size, &r.SHA256, &r.MIME,
|
||||
&r.Size, &sha256, &mime,
|
||||
&createdStr, &updatedStr, &lastSeen, &r.Missing)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
@@ -718,6 +786,12 @@ func scanRecord(s scanFace) (*Record, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sha256.Valid {
|
||||
r.SHA256 = sha256.String
|
||||
}
|
||||
if mime.Valid {
|
||||
r.MIME = mime.String
|
||||
}
|
||||
r.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
r.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
if lastSeen.Valid {
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestMVPSmoke(t *testing.T) {
|
||||
notePath := ""
|
||||
if len(noteFileRecs) > 0 {
|
||||
notePath = noteFileRecs[0].Path
|
||||
if _, err := os.Stat(notePath); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, notePath)); os.IsNotExist(err) {
|
||||
t.Errorf("note file not on disk: %s", notePath)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user