fix: note/file move ops, rename/move atomicity, importDir folder creation

- importDir: create physical folder for imported directories
- RenameNode/MoveNode: os.Rename before DB updates (atomicity)
- RenameNode note/file: fail if physical file missing
- MoveNode: file renames before DB updates
- applyRemoteNoteOp: handle OpMove for notes/files
- applyRemoteNodeMove: handle notes/files with empty FsPath
- MoveNode sync payload: no fs_path for notes/files
- Add 7 tests covering all fixes
This commit is contained in:
mirivlad 2026-06-03 01:32:47 +08:00
parent 20a05569ac
commit 7b2a1da529
4 changed files with 769 additions and 108 deletions

View File

@ -185,22 +185,30 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
newFsPath = rel
oldTitle := n.Title
// Check source exists before filesystem rename
if _, err := os.Stat(oldPhysPath); err != nil {
return fmt.Errorf("source folder not found: %w", err)
}
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("rename folder: %w", err)
}
// Update DB only after successful filesystem rename
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
_ = a.nodes.UpdateTitle(nodeID, oldTitle)
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
if _, err := os.Stat(oldPhysPath); err == nil {
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("rename folder: %w", err)
}
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
@ -215,10 +223,6 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
}
// Note/file node: rename the physical file, update file record
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return err
}
// Collect file records first to avoid connection deadlock (SetMaxOpenConns=1)
type fileRec struct {
id, path, filename string
@ -236,6 +240,15 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
rows.Close()
}
// Collect rename operations without modifying anything yet
type renameOp struct {
id string
oldFilename string
oldAbs string
newFilename string
newRelPath string
}
var renameOps []renameOp
for _, r := range records {
if r.path == "" {
continue
@ -250,7 +263,6 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
}
newFilename = seg + ext
} else {
// File node: title is the full filename
newFilename = newTitle
}
newRelPath := filepath.Join(dir, newFilename)
@ -276,12 +288,47 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
}
}
// Rename physical file
if _, err := os.Stat(oldAbs); err == nil {
if err := os.Rename(oldAbs, newAbs); err == nil {
_, _ = a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
newFilename, newRelPath, r.id)
// Check source file exists
if _, err := os.Stat(oldAbs); err != nil {
return fmt.Errorf("source file not found for rename: %w", err)
}
renameOps = append(renameOps, renameOp{
id: r.id,
oldFilename: r.filename,
oldAbs: oldAbs,
newFilename: newFilename,
newRelPath: newRelPath,
})
}
// Perform all physical renames first
for _, rop := range renameOps {
newAbs := filepath.Join(a.vault, rop.newRelPath)
if err := os.Rename(rop.oldAbs, newAbs); err != nil {
// Rollback completed renames
for _, prev := range renameOps {
if prev.id == rop.id {
break
}
_ = os.Rename(filepath.Join(a.vault, prev.newRelPath), prev.oldAbs)
}
return fmt.Errorf("rename file: %w", err)
}
}
// All renames succeeded — update DB
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
// Rollback filesystem
for _, rop := range renameOps {
_ = os.Rename(filepath.Join(a.vault, rop.newRelPath), rop.oldAbs)
}
return err
}
for _, rop := range renameOps {
if _, err := a.db.Exec(`UPDATE files SET filename=?, path=? WHERE id=?`,
rop.newFilename, rop.newRelPath, rop.id); err != nil {
return err
}
}
@ -364,77 +411,88 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
rel, _ := filepath.Rel(a.vault, newPhysPath)
newFsPath = rel
// Update parent_id (use nil for root move)
// Check source exists and do filesystem rename first
if _, err := os.Stat(oldPhysPath); err != nil {
return fmt.Errorf("source folder not found: %w", err)
}
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("move folder: %w", err)
}
// Update DB only after successful filesystem rename
if newParentID == "" {
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
} else {
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
}
if err := a.nodes.UpdateFsPath(nodeID, newFsPath); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
if err := a.nodes.UpdateFsPathRecursive(nodeID, newFsPath); err != nil {
_ = os.Rename(newPhysPath, oldPhysPath)
return err
}
if _, err := os.Stat(oldPhysPath); err == nil {
if err := os.Rename(oldPhysPath, newPhysPath); err != nil {
return fmt.Errorf("move folder: %w", err)
}
}
node.FsPath = newFsPath
} else {
// Note/file node: update parent_id and move physical file
if newParentID == "" {
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
return err
}
} else {
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
return err
}
}
// Update file record path to reflect new parent
type fileMove struct {
id, path string
}
var fileMoves []fileMove
// Note/file node: move physical file first, then update DB
var fileMoves []fileMoveInfo
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, nodeID)
if ferr == nil {
for frows.Next() {
var fm fileMove
if err := frows.Scan(&fm.id, &fm.path); err != nil {
var fm fileMoveInfo
if err := frows.Scan(&fm.id, &fm.oldPath); err != nil {
continue
}
if fm.oldPath == "" {
continue
}
filename := filepath.Base(fm.oldPath)
fm.newRelPath = filename
if parent != nil && parent.FsPath != "" {
fm.newRelPath = filepath.Join(parent.FsPath, filename)
}
fileMoves = append(fileMoves, fm)
}
frows.Close()
}
// Perform filesystem moves first
for _, fm := range fileMoves {
if fm.path == "" {
continue
oldAbs := filepath.Join(a.vault, fm.oldPath)
newAbs := filepath.Join(a.vault, fm.newRelPath)
if _, err := os.Stat(oldAbs); err != nil {
return fmt.Errorf("source file not found for move: %w", err)
}
filename := filepath.Base(fm.path)
newRelPath := filename
if parent != nil && parent.FsPath != "" {
newRelPath = filepath.Join(parent.FsPath, filename)
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
if err := os.Rename(oldAbs, newAbs); err != nil {
return fmt.Errorf("move file: %w", err)
}
oldAbs := filepath.Join(a.vault, fm.path)
newAbs := filepath.Join(a.vault, newRelPath)
}
if _, err := os.Stat(oldAbs); err == nil {
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
if err := os.Rename(oldAbs, newAbs); err == nil {
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
newRelPath, fm.id)
}
// Update DB only after successful filesystem renames
if newParentID == "" {
if err := a.nodes.Move(nodeID, nil, 0); err != nil {
_ = a.rollbackFileMoves(fileMoves)
return err
}
} else {
if err := a.nodes.Move(nodeID, &newParentID, 0); err != nil {
_ = a.rollbackFileMoves(fileMoves)
return err
}
}
for _, fm := range fileMoves {
if _, err := a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
fm.newRelPath, fm.id); err != nil {
return err
}
}
}
@ -460,15 +518,14 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
syncEntity = syncsvc.EntityFile
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, node.Title, `{"to":"`+newParentID+`"}`)
var opFsPath string
if isFolderLike && node.FsPath != "" {
opFsPath = node.FsPath
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, map[string]interface{}{
opPayload := map[string]interface{}{
"parent_id": newParentID,
"fs_path": opFsPath,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
}
if isFolderLike && node.FsPath != "" {
opPayload["fs_path"] = node.FsPath
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpMove, opPayload)
return nil
}
@ -486,6 +543,19 @@ func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
return result, nil
}
type fileMoveInfo struct {
id, oldPath, newRelPath string
}
func (a *App) rollbackFileMoves(moves []fileMoveInfo) error {
for _, fm := range moves {
oldAbs := filepath.Join(a.vault, fm.oldPath)
newAbs := filepath.Join(a.vault, fm.newRelPath)
_ = os.Rename(newAbs, oldAbs)
}
return nil
}
func (a *App) OpenNodeFolder(nodeID string) (string, error) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {

View File

@ -260,18 +260,10 @@ func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
return err
}
if payload.FsPath != "" {
oldFsPath := n.FsPath
if _, err := a.db.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
return err
}
if isFolderLike && oldFsPath != "" {
// Physically move folder
oldPhys := filepath.Join(a.vault, oldFsPath)
if isFolderLike {
// Folder-like node: update fs_path and physically move directory
if payload.FsPath != "" && n.FsPath != "" {
oldPhys := filepath.Join(a.vault, n.FsPath)
newPhys := filepath.Join(a.vault, payload.FsPath)
if _, err := os.Stat(oldPhys); err == nil {
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
@ -279,42 +271,15 @@ func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
log.Printf("[sync] move folder %s -> %s: %v", oldPhys, newPhys, err)
}
}
} else if !isFolderLike {
// Move file record path for notes/files
type fm struct {
id, path string
}
var fileMoves []fm
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, op.EntityID)
if ferr == nil {
for frows.Next() {
var m fm
if err := frows.Scan(&m.id, &m.path); err != nil {
continue
}
fileMoves = append(fileMoves, m)
}
frows.Close()
}
for _, m := range fileMoves {
if m.path == "" {
continue
}
filename := filepath.Base(m.path)
newRelPath := filepath.Join(payload.FsPath, filename)
oldAbs := filepath.Join(a.vault, m.path)
newAbs := filepath.Join(a.vault, newRelPath)
if _, err := os.Stat(oldAbs); err == nil {
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
if err := os.Rename(oldAbs, newAbs); err == nil {
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
newRelPath, m.id)
}
}
if _, err := a.db.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
return err
}
}
} else {
// Note/file: move physical file to new parent's directory
return a.moveNodeFiles(n, payload.ParentID)
}
return nil
@ -334,6 +299,8 @@ func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
return a.applyRemoteNoteCreate(op)
case syncsvc.OpUpdate:
return a.applyRemoteNoteUpdate(op)
case syncsvc.OpMove:
return a.applyRemoteNoteMove(op)
case syncsvc.OpDelete:
return a.applyRemoteNodeDelete(op)
}
@ -475,6 +442,87 @@ func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
return nil
}
func (a *App) applyRemoteNoteMove(op syncsvc.Op) error {
var payload struct {
ParentID string `json:"parent_id"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
return fmt.Errorf("unmarshal note move: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.UpdatedAt != "" {
now = payload.UpdatedAt
}
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
// Update parent_id
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
if _, err := a.db.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, op.EntityID); err != nil {
return err
}
// Move physical file to new parent's directory
return a.moveNodeFiles(n, payload.ParentID)
}
func (a *App) moveNodeFiles(n *nodes.Node, newParentID string) error {
var parentFsPath string
if newParentID != "" {
parent, err := a.nodes.GetActive(newParentID)
if err == nil && parent.FsPath != "" {
parentFsPath = parent.FsPath
}
}
type fileMove struct {
id, path string
}
var fileMoves []fileMove
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, n.ID)
if ferr == nil {
for frows.Next() {
var fm fileMove
if err := frows.Scan(&fm.id, &fm.path); err != nil {
continue
}
fileMoves = append(fileMoves, fm)
}
frows.Close()
}
for _, fm := range fileMoves {
if fm.path == "" {
continue
}
filename := filepath.Base(fm.path)
newRelPath := filename
if parentFsPath != "" {
newRelPath = filepath.Join(parentFsPath, filename)
}
oldAbs := filepath.Join(a.vault, fm.path)
newAbs := filepath.Join(a.vault, newRelPath)
if _, err := os.Stat(oldAbs); err == nil {
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
if err := os.Rename(oldAbs, newAbs); err == nil {
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
newRelPath, fm.id)
}
}
}
return nil
}
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
switch op.OpType {
case syncsvc.OpCreate:

View File

@ -1,9 +1,12 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
@ -532,6 +535,541 @@ func TestVaultLayout_SyncNodeCreatePreservesFields(t *testing.T) {
}
}
func TestVaultLayout_ImportEmptyDirCreatesPhysicalFolder(t *testing.T) {
app, vault := setupTestApp(t)
// Create a parent folder
parent, err := app.CreateNodeFromTemplate("", "Parent", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
parentFsPath := parent.FsPath
// Create an empty temp directory
emptyDir := filepath.Join(vault, "emptydir")
if err := os.MkdirAll(emptyDir, 0o750); err != nil {
t.Fatalf("mkdir empty dir: %v", err)
}
// Import the empty directory
nodes, err := app.files.AddPathCopy(parent.ID, emptyDir)
if err != nil {
t.Fatalf("import empty dir: %v", err)
}
if len(nodes) == 0 {
t.Fatal("expected at least one node from import")
}
// The first node should be the imported folder
folderNode := &nodes[0]
if folderNode.Type != "folder" {
t.Errorf("expected folder type, got %q", folderNode.Type)
}
// Re-read from DB to get updated FsPath (UpdateFsPath is called after Create)
folderFromDB, err := app.nodes.Get(folderNode.ID)
if err != nil {
t.Fatalf("get folder from db: %v", err)
}
if folderFromDB.FsPath == "" {
t.Error("expected non-empty fs_path for imported folder")
}
// Verify physical folder exists on disk
expectedPath := filepath.Join(vault, parentFsPath, "emptydir")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected folder at %s", expectedPath)
}
if folderFromDB.FsPath != "" {
folderPhysPath := filepath.Join(vault, folderFromDB.FsPath)
if _, err := os.Stat(folderPhysPath); os.IsNotExist(err) {
t.Errorf("expected physical folder at %s", folderPhysPath)
}
}
}
func TestVaultLayout_RemoteNoteMoveMovesFile(t *testing.T) {
app, vault := setupTestApp(t)
// Create source parent folder
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
if err != nil {
t.Fatalf("create src parent: %v", err)
}
// Create dest parent folder
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
if err != nil {
t.Fatalf("create dst parent: %v", err)
}
// Create a note in src parent directly (simulating remote create)
noteNode, fileRec, err := app.notes.Create(srcParent.ID, "RemoteNote", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
// Verify note file exists in source
srcFilePath := filepath.Join(vault, fileRec.Path)
if _, err := os.Stat(srcFilePath); os.IsNotExist(err) {
t.Fatalf("expected note file at %s", srcFilePath)
}
// Simulate remote move op for EntityNote
op := syncsvc.Op{
EntityType: syncsvc.EntityNote,
EntityID: noteNode.ID,
OpType: syncsvc.OpMove,
PayloadJSON: fmt.Sprintf(`{"parent_id":"%s","updated_at":"%s"}`, dstParent.ID, time.Now().UTC().Format(time.RFC3339)),
}
if err := app.applyRemoteOp(op); err != nil {
t.Fatalf("apply remote note move: %v", err)
}
// Verify node parent_id changed
n, err := app.nodes.GetActive(noteNode.ID)
if err != nil {
t.Fatalf("get moved note: %v", err)
}
if n.ParentID == nil || *n.ParentID != dstParent.ID {
t.Errorf("expected parent_id %s, got %v", dstParent.ID, n.ParentID)
}
// Verify file was moved to destination folder
records, _ := app.files.ListByNode(noteNode.ID)
if len(records) > 0 {
expectedNewPath := filepath.Join(dstParent.FsPath, filepath.Base(records[0].Path))
if records[0].Path != expectedNewPath {
t.Errorf("expected file path %q, got %q", expectedNewPath, records[0].Path)
}
destFilePath := filepath.Join(vault, records[0].Path)
if _, err := os.Stat(destFilePath); os.IsNotExist(err) {
t.Errorf("expected file at destination %s", destFilePath)
}
}
// Verify old file no longer exists at source
if _, err := os.Stat(srcFilePath); !os.IsNotExist(err) {
t.Error("expected old file to not exist after move")
}
}
func TestVaultLayout_RemoteFileMoveMovesPhysicalFile(t *testing.T) {
app, vault := setupTestApp(t)
// Create source and destination folders
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
if err != nil {
t.Fatalf("create src parent: %v", err)
}
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
if err != nil {
t.Fatalf("create dst parent: %v", err)
}
// Create a file node in source parent
fileNode, err := app.nodes.Create(&srcParent.ID, "file", "testfile.txt", 0, "", "")
if err != nil {
t.Fatalf("create file node: %v", err)
}
srcFile := filepath.Join(vault, "src.txt")
if err := os.WriteFile(srcFile, []byte("test data"), 0o640); err != nil {
t.Fatalf("write source: %v", err)
}
if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, srcParent.FsPath); err != nil {
t.Fatalf("copy into vault: %v", err)
}
// Verify file exists in source
records, _ := app.files.ListByNode(fileNode.ID)
if len(records) == 0 {
t.Fatal("expected file records")
}
oldFilePath := filepath.Join(vault, records[0].Path)
if _, err := os.Stat(oldFilePath); os.IsNotExist(err) {
t.Fatalf("expected file at %s", oldFilePath)
}
// Simulate remote move op for EntityFile
op := syncsvc.Op{
EntityType: syncsvc.EntityFile,
EntityID: fileNode.ID,
OpType: syncsvc.OpMove,
PayloadJSON: fmt.Sprintf(`{"parent_id":"%s","updated_at":"%s"}`, dstParent.ID, time.Now().UTC().Format(time.RFC3339)),
}
if err := app.applyRemoteOp(op); err != nil {
t.Fatalf("apply remote file move: %v", err)
}
// Verify node parent_id changed
n, err := app.nodes.GetActive(fileNode.ID)
if err != nil {
t.Fatalf("get moved file: %v", err)
}
if n.ParentID == nil || *n.ParentID != dstParent.ID {
t.Errorf("expected parent_id %s, got %v", dstParent.ID, n.ParentID)
}
// Verify file was moved to destination
records2, _ := app.files.ListByNode(fileNode.ID)
if len(records2) > 0 {
expectedNewPath := filepath.Join(dstParent.FsPath, filepath.Base(records2[0].Path))
if records2[0].Path != expectedNewPath {
t.Errorf("expected file path %q, got %q", expectedNewPath, records2[0].Path)
}
destFilePath := filepath.Join(vault, records2[0].Path)
if _, err := os.Stat(destFilePath); os.IsNotExist(err) {
t.Errorf("expected file at destination %s", destFilePath)
}
}
// Verify old file no longer exists
if _, err := os.Stat(oldFilePath); !os.IsNotExist(err) {
t.Error("expected old file to not exist after move")
}
}
func TestVaultLayout_LocalNoteMoveSyncPayloadCanBeAppliedRemotely(t *testing.T) {
app, vault := setupTestApp(t)
// Create source and destination parents
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
if err != nil {
t.Fatalf("create src parent: %v", err)
}
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
if err != nil {
t.Fatalf("create dst parent: %v", err)
}
// Create a note
noteNode, _, err := app.notes.Create(srcParent.ID, "MovedNote", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
// Move the note via MoveNode
if err := app.MoveNode(noteNode.ID, dstParent.ID); err != nil {
t.Fatalf("move note: %v", err)
}
// Verify local move produced correct sync op
ops, err := app.sync.GetUnpushedOps()
if err != nil {
t.Fatalf("get ops: %v", err)
}
var moveOp *syncsvc.Op
for i := range ops {
if ops[i].EntityID == noteNode.ID && ops[i].OpType == syncsvc.OpMove {
moveOp = &ops[i]
break
}
}
if moveOp == nil {
t.Fatal("expected move sync op for note")
}
if moveOp.EntityType != syncsvc.EntityNote {
t.Errorf("expected entity type 'note', got %q", moveOp.EntityType)
}
// Verify payload has parent_id but no fs_path
var payload map[string]interface{}
if err := json.Unmarshal([]byte(moveOp.PayloadJSON), &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if pid, ok := payload["parent_id"]; !ok || pid != dstParent.ID {
t.Errorf("expected parent_id %q in payload, got %v", dstParent.ID, pid)
}
if _, ok := payload["fs_path"]; ok {
t.Error("expected no fs_path in note move payload")
}
// Verify note file is at destination
noteAtDst := filepath.Join(vault, dstParent.FsPath, "MovedNote.md")
if _, err := os.Stat(noteAtDst); os.IsNotExist(err) {
t.Errorf("expected note at %s", noteAtDst)
}
// Now simulate receiving this same op on another device
app2, _ := setupTestApp(t)
defer app2.db.Close()
defer os.RemoveAll(app2.vault)
// First create the same nodes on app2 (simulating prior sync)
// Create src and dst parents
_, err = app2.CreateNodeFromTemplate("", "SrcParent", "folder.default")
if err != nil {
t.Fatalf("app2 create src: %v", err)
}
_, err = app2.CreateNodeFromTemplate("", "DstParent", "folder.default")
if err != nil {
t.Fatalf("app2 create dst: %v", err)
}
// Create the same note via remote create
createOp := syncsvc.Op{
EntityType: syncsvc.EntityNote,
EntityID: noteNode.ID,
OpType: syncsvc.OpCreate,
PayloadJSON: moveOp.PayloadJSON, // approximate
}
// We need proper create payload
app2NoteCreatePayload := fmt.Sprintf(
`{"node_id":"%s","file_id":"%s","format":"markdown","content":"# MovedNote\n\n","filename":"MovedNote.md","path":"%s","created_at":"%s","updated_at":"%s"}`,
noteNode.ID, "test-file-id-1", filepath.Join(srcParent.FsPath, "MovedNote.md"),
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
createOp.PayloadJSON = app2NoteCreatePayload
if err := app2.applyRemoteOp(createOp); err != nil {
t.Fatalf("app2 apply remote create: %v", err)
}
// Now apply the move op on app2
if err := app2.applyRemoteOp(*moveOp); err != nil {
t.Fatalf("app2 apply remote move: %v", err)
}
// Verify node moved on app2
app2Node, err := app2.nodes.GetActive(noteNode.ID)
if err != nil {
t.Fatalf("app2 get moved node: %v", err)
}
if app2Node.ParentID == nil || *app2Node.ParentID != dstParent.ID {
t.Errorf("app2: expected parent_id %s, got %v", dstParent.ID, app2Node.ParentID)
}
// Verify file moved on app2
app2Records, _ := app2.files.ListByNode(noteNode.ID)
if len(app2Records) > 0 {
destPath := filepath.Join(app2.vault, app2Records[0].Path)
if _, err := os.Stat(destPath); os.IsNotExist(err) {
t.Errorf("app2: expected file at %s", destPath)
}
}
}
func TestVaultLayout_LocalFileMoveSyncPayloadCanBeAppliedRemotely(t *testing.T) {
app, vault := setupTestApp(t)
// Create source and destination parents
srcParent, err := app.CreateNodeFromTemplate("", "SrcParent", "folder.default")
if err != nil {
t.Fatalf("create src parent: %v", err)
}
dstParent, err := app.CreateNodeFromTemplate("", "DstParent", "folder.default")
if err != nil {
t.Fatalf("create dst parent: %v", err)
}
// Create a file node
fileNode, err := app.nodes.Create(&srcParent.ID, "file", "movable.txt", 0, "", "")
if err != nil {
t.Fatalf("create file node: %v", err)
}
srcFile := filepath.Join(vault, "src_movable.txt")
if err := os.WriteFile(srcFile, []byte("movable data"), 0o640); err != nil {
t.Fatalf("write source: %v", err)
}
if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, srcParent.FsPath); err != nil {
t.Fatalf("copy into vault: %v", err)
}
// Move the file via MoveNode
if err := app.MoveNode(fileNode.ID, dstParent.ID); err != nil {
t.Fatalf("move file: %v", err)
}
// Verify local move produced correct sync op
ops, err := app.sync.GetUnpushedOps()
if err != nil {
t.Fatalf("get ops: %v", err)
}
var moveOp *syncsvc.Op
for i := range ops {
if ops[i].EntityID == fileNode.ID && ops[i].OpType == syncsvc.OpMove {
moveOp = &ops[i]
break
}
}
if moveOp == nil {
t.Fatal("expected move sync op for file")
}
if moveOp.EntityType != syncsvc.EntityFile {
t.Errorf("expected entity type 'file', got %q", moveOp.EntityType)
}
// Verify payload
var payload map[string]interface{}
if err := json.Unmarshal([]byte(moveOp.PayloadJSON), &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if _, ok := payload["fs_path"]; ok {
t.Error("expected no fs_path in file move payload")
}
// Now simulate receiving this op on another device
app2, vault2 := setupTestApp(t)
defer app2.db.Close()
defer os.RemoveAll(vault2)
// Create same nodes on app2
_, err = app2.CreateNodeFromTemplate("", "SrcParent", "folder.default")
if err != nil {
t.Fatalf("app2 create src: %v", err)
}
_, err = app2.CreateNodeFromTemplate("", "DstParent", "folder.default")
if err != nil {
t.Fatalf("app2 create dst: %v", err)
}
// Create the file node via remote create
app2CreatePayload := fmt.Sprintf(
`{"node_id":"%s","type":"file","title":"movable.txt","slug":"movable-txt","parent_id":"%s","filename":"movable.txt","path":"%s","storage_mode":"vault","size":12,"file_id":"test-file-id-2","created_at":"%s","updated_at":"%s"}`,
fileNode.ID, srcParent.ID, filepath.Join(srcParent.FsPath, "movable.txt"),
time.Now().UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339))
createOp := syncsvc.Op{
EntityType: syncsvc.EntityFile,
EntityID: fileNode.ID,
OpType: syncsvc.OpCreate,
PayloadJSON: app2CreatePayload,
}
if err := app2.applyRemoteOp(createOp); err != nil {
t.Fatalf("app2 apply remote create: %v", err)
}
// Create the physical file on app2
os.MkdirAll(filepath.Join(vault2, srcParent.FsPath), 0o750)
os.WriteFile(filepath.Join(vault2, srcParent.FsPath, "movable.txt"), []byte("movable data"), 0o640)
// Apply move op on app2
if err := app2.applyRemoteOp(*moveOp); err != nil {
t.Fatalf("app2 apply remote move: %v", err)
}
// Verify node moved on app2
app2Node, err := app2.nodes.GetActive(fileNode.ID)
if err != nil {
t.Fatalf("app2 get moved node: %v", err)
}
if app2Node.ParentID == nil || *app2Node.ParentID != dstParent.ID {
t.Errorf("app2: expected parent_id %s, got %v", dstParent.ID, app2Node.ParentID)
}
// Verify file moved on app2
app2Records, _ := app2.files.ListByNode(fileNode.ID)
if len(app2Records) > 0 {
destPath := filepath.Join(vault2, app2Records[0].Path)
if _, err := os.Stat(destPath); os.IsNotExist(err) {
t.Errorf("app2: expected file at %s", destPath)
}
}
}
func TestVaultLayout_RenameNoteFileReturnsErrorIfPhysicalFileMissing(t *testing.T) {
app, _ := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Parent", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
// Create a note
noteNode, fileRec, err := app.notes.Create(parent.ID, "TestNote", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
// Delete the physical file
physPath := filepath.Join(app.vault, fileRec.Path)
if err := os.Remove(physPath); err != nil {
t.Fatalf("remove note file: %v", err)
}
// Rename should fail because physical file is missing
if err := app.RenameNode(noteNode.ID, "RenamedNote"); err == nil {
t.Error("expected error when renaming note with missing physical file")
}
// Create a file node
fileNode, err := app.nodes.Create(&parent.ID, "file", "testfile.txt", 0, "", "")
if err != nil {
t.Fatalf("create file node: %v", err)
}
srcFile := filepath.Join(app.vault, "src_for_test.txt")
if err := os.WriteFile(srcFile, []byte("data"), 0o640); err != nil {
t.Fatalf("write source: %v", err)
}
if _, err := app.files.CopyIntoVault(fileNode.ID, srcFile, parent.FsPath); err != nil {
t.Fatalf("copy into vault: %v", err)
}
// Delete the physical file
fileRecs, _ := app.files.ListByNode(fileNode.ID)
if len(fileRecs) > 0 {
filePhysPath := filepath.Join(app.vault, fileRecs[0].Path)
if err := os.Remove(filePhysPath); err != nil {
t.Fatalf("remove file: %v", err)
}
}
// Rename should fail
if err := app.RenameNode(fileNode.ID, "renamed.txt"); err == nil {
t.Error("expected error when renaming file with missing physical file")
}
}
func TestVaultLayout_FolderRenameDoesNotUpdateDBIfOsRenameFails(t *testing.T) {
app, vault := setupTestApp(t)
// Create a parent folder that we'll make read-only
parent, err := app.CreateNodeFromTemplate("", "TestRoot", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
// Create a child folder inside TestRoot
child, err := app.CreateNodeFromTemplate(parent.ID, "OriginalName", "folder.default")
if err != nil {
t.Fatalf("create child: %v", err)
}
oldTitle := child.Title
oldFsPath := child.FsPath
// Verify physical folder exists
if _, err := os.Stat(filepath.Join(vault, oldFsPath)); os.IsNotExist(err) {
t.Fatalf("expected child folder at %s", oldFsPath)
}
// Make TestRoot read-only to cause os.Rename to fail
parentPhysPath := filepath.Join(vault, parent.FsPath)
if err := os.Chmod(parentPhysPath, 0o555); err != nil {
t.Fatalf("chmod parent: %v", err)
}
// Restore permissions on cleanup
defer os.Chmod(parentPhysPath, 0o755)
// Rename should fail because parent is read-only
if err := app.RenameNode(child.ID, "RenamedName"); err == nil {
t.Error("expected error when renaming folder in read-only directory")
}
// Verify DB was NOT updated
n, err := app.nodes.GetActive(child.ID)
if err != nil {
t.Fatalf("get child: %v", err)
}
if n.Title != oldTitle {
t.Errorf("expected title %q unchanged, got %q", oldTitle, n.Title)
}
if n.FsPath != oldFsPath {
t.Errorf("expected fs_path %q unchanged, got %q", oldFsPath, n.FsPath)
}
// Verify physical folder still at old location
if _, err := os.Stat(filepath.Join(vault, oldFsPath)); os.IsNotExist(err) {
t.Error("expected original folder to still exist")
}
}
// --- helpers ---
func listNames(entries []os.DirEntry) []string {

View File

@ -526,6 +526,11 @@ func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool)
}
_ = s.nodes.UpdateFsPath(folderNode.ID, folderFsPath)
physPath := filepath.Join(s.vaultRoot, folderFsPath)
if err := os.MkdirAll(physPath, 0o750); err != nil {
return nil, fmt.Errorf("create folder for imported dir: %w", err)
}
entries, err := os.ReadDir(sourcePath)
if err != nil {
return nil, err