fix: third stabilization pass — template children as nodes, atomicity, fs_path validation, sync_apply compat, smoke test

This commit is contained in:
2026-06-03 02:05:53 +08:00
parent 49c0fda61c
commit a31f5fd702
9 changed files with 1111 additions and 37 deletions
+95 -21
View File
@@ -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 {