fix: third stabilization pass — template children as nodes, atomicity, fs_path validation, sync_apply compat, smoke test
This commit is contained in:
@@ -321,8 +321,14 @@ func (a *App) filePayload(n *nodes.Node) map[string]interface{} {
|
||||
}
|
||||
|
||||
func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} {
|
||||
pid := ""
|
||||
if node.ParentID != nil {
|
||||
pid = *node.ParentID
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"node_id": node.ID,
|
||||
"parent_id": pid,
|
||||
"title": node.Title,
|
||||
"file_id": fileRec.ID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
func (a *App) ListWorkspaceTree() ([]NodeDTO, error) {
|
||||
@@ -70,6 +71,10 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
||||
rel, _ := filepath.Rel(a.vault, physPath)
|
||||
fsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, fsPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
var pID *string
|
||||
if parentID != "" {
|
||||
pID = &parentID
|
||||
@@ -82,21 +87,84 @@ func (a *App) CreateNodeFromTemplate(parentID, title, templateID string) (*NodeD
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
||||
_ = a.nodes.SoftDelete(n.ID)
|
||||
return nil, fmt.Errorf("create folder: %w", err)
|
||||
}
|
||||
|
||||
// Create child nodes for default files (proper DB nodes + file records)
|
||||
nowRFC := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fpath := filepath.Join(physPath, df.Path)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0o755); err != nil {
|
||||
continue
|
||||
}
|
||||
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
|
||||
if fileTitle == "" {
|
||||
fileTitle = "Overview"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
content := fmt.Sprintf("# %s\n\n", title)
|
||||
_ = os.WriteFile(fpath, []byte(content), 0o640)
|
||||
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
|
||||
_ = a.nodes.SoftDelete(childNode.ID)
|
||||
continue
|
||||
}
|
||||
relPath, _ := filepath.Rel(a.vault, fpath)
|
||||
fi, _ := os.Stat(fpath)
|
||||
size := int64(0)
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
fileID := util.UUID7()
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
childNode.ID, fileID, "markdown")
|
||||
_ = a.activity.Record(n.ID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
|
||||
"node_id": childNode.ID,
|
||||
"parent_id": n.ID,
|
||||
"title": fileTitle,
|
||||
"file_id": fileID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
"filename": filepath.Base(fpath),
|
||||
"path": relPath,
|
||||
"created_at": nowRFC,
|
||||
"updated_at": nowRFC,
|
||||
})
|
||||
}
|
||||
|
||||
for _, folder := range tmpl.DefaultFolders {
|
||||
fpath := filepath.Join(physPath, folder)
|
||||
_ = os.MkdirAll(fpath, 0o755)
|
||||
// Create child nodes for default folders (proper DB nodes + physical folders)
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&n.ID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
childFsPath := folderSeg
|
||||
if fsPath != "" {
|
||||
childFsPath = filepath.Join(fsPath, folderSeg)
|
||||
}
|
||||
childPhysPath := filepath.Join(a.vault, childFsPath)
|
||||
childPhysPath = templates.UniquePath(childPhysPath)
|
||||
childRel, _ := filepath.Rel(a.vault, childPhysPath)
|
||||
childFsPath = childRel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
if err := os.MkdirAll(childPhysPath, 0o755); err != nil {
|
||||
_ = a.nodes.SoftDelete(childNode.ID)
|
||||
continue
|
||||
}
|
||||
_ = a.activity.Record(n.ID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
|
||||
}
|
||||
|
||||
pid := ""
|
||||
@@ -184,6 +252,10 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||
newFsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
|
||||
return fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
oldTitle := n.Title
|
||||
|
||||
// Check source exists before filesystem rename
|
||||
@@ -356,12 +428,39 @@ func (a *App) RenameNode(nodeID, newTitle string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) isDescendant(ancestorID, nodeID string) error {
|
||||
if nodeID == "" || ancestorID == "" {
|
||||
return nil
|
||||
}
|
||||
current := nodeID
|
||||
depth := 0
|
||||
for current != "" && depth < 1000 {
|
||||
if current == ancestorID {
|
||||
return fmt.Errorf("cannot move a node into its own descendant")
|
||||
}
|
||||
n, err := a.nodes.Get(current)
|
||||
if err != nil || n.ParentID == nil {
|
||||
return nil
|
||||
}
|
||||
current = *n.ParentID
|
||||
depth++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
node, err := a.nodes.GetActive(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prevent moving node into its own descendant
|
||||
if newParentID != "" {
|
||||
if err := a.isDescendant(newParentID, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
isFolderLike := node.Type != nodes.TypeNote && node.Type != nodes.TypeFile
|
||||
|
||||
// Resolve new parent
|
||||
@@ -411,6 +510,10 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
|
||||
rel, _ := filepath.Rel(a.vault, newPhysPath)
|
||||
newFsPath = rel
|
||||
|
||||
if _, err := syncsvc.SafeVaultPath(a.vault, newFsPath); err != nil {
|
||||
return fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
|
||||
// Check source exists and do filesystem rename first
|
||||
if _, err := os.Stat(oldPhysPath); err != nil {
|
||||
return fmt.Errorf("source folder not found: %w", err)
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
@@ -145,6 +147,117 @@ func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
||||
}
|
||||
}
|
||||
|
||||
// If the node was created from a template, also create child nodes
|
||||
// for any default_files and default_folders that were not already synced
|
||||
// as individual ops (backward compatibility with devices that do not
|
||||
// sync template children).
|
||||
_ = a.ensureTemplateChildren(payload.ID, payload.TemplateID, fsPath, payload.Title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTemplateChildren creates child nodes for a template's default files
|
||||
// and folders if they don't already exist. This handles backward compatibility
|
||||
// with devices that do not sync template children as individual ops.
|
||||
func (a *App) ensureTemplateChildren(nodeID, templateID, parentFsPath, title string) error {
|
||||
if templateID == "" {
|
||||
return nil
|
||||
}
|
||||
tmpl, ok := a.templates.Get(templateID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
nowRFC := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if len(tmpl.DefaultFolders) == 0 && len(tmpl.DefaultFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check existing children to avoid duplicates.
|
||||
existing, err := a.nodes.ListChildren(nodeID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exists := make(map[string]bool, len(existing))
|
||||
for i := range existing {
|
||||
exists[existing[i].Title] = true
|
||||
}
|
||||
|
||||
for _, folderName := range tmpl.DefaultFolders {
|
||||
if exists[folderName] {
|
||||
continue
|
||||
}
|
||||
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
|
||||
if folderSeg == "" {
|
||||
folderSeg = "folder"
|
||||
}
|
||||
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeFolder, folderName, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
childFsPath := folderSeg
|
||||
if parentFsPath != "" {
|
||||
childFsPath = filepath.Join(parentFsPath, folderSeg)
|
||||
}
|
||||
fullPath := filepath.Join(a.vault, childFsPath)
|
||||
fullPath = templates.UniquePath(fullPath)
|
||||
rel, _ := filepath.Rel(a.vault, fullPath)
|
||||
childFsPath = rel
|
||||
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
|
||||
_ = os.MkdirAll(fullPath, 0o755)
|
||||
|
||||
_ = a.activity.Record(nodeID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
|
||||
}
|
||||
|
||||
for _, df := range tmpl.DefaultFiles {
|
||||
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
|
||||
if fileTitle == "" {
|
||||
fileTitle = "Overview"
|
||||
}
|
||||
if exists[fileTitle] {
|
||||
continue
|
||||
}
|
||||
|
||||
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeNote, fileTitle, 0, "", "")
|
||||
if childErr != nil {
|
||||
continue
|
||||
}
|
||||
content := fmt.Sprintf("# %s\n\n", title)
|
||||
fpath := filepath.Join(a.vault, parentFsPath, df.Path)
|
||||
_ = os.MkdirAll(filepath.Dir(fpath), 0o750)
|
||||
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
|
||||
_ = a.nodes.SoftDelete(childNode.ID)
|
||||
continue
|
||||
}
|
||||
relPath, _ := filepath.Rel(a.vault, fpath)
|
||||
fi, _ := os.Stat(fpath)
|
||||
size := int64(0)
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
fileID := util.UUID7()
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
|
||||
_, _ = a.db.Exec(
|
||||
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
||||
childNode.ID, fileID, "markdown")
|
||||
_ = a.activity.Record(nodeID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
|
||||
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
|
||||
"node_id": childNode.ID,
|
||||
"parent_id": nodeID,
|
||||
"title": fileTitle,
|
||||
"file_id": fileID,
|
||||
"format": "markdown",
|
||||
"content": content,
|
||||
"filename": filepath.Base(fpath),
|
||||
"path": relPath,
|
||||
"created_at": nowRFC,
|
||||
"updated_at": nowRFC,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -310,6 +423,8 @@ func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
||||
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Title string `json:"title"`
|
||||
FileID string `json:"file_id"`
|
||||
Format string `json:"format"`
|
||||
Content string `json:"content"`
|
||||
@@ -326,16 +441,29 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
title := payload.Title
|
||||
if title == "" {
|
||||
title = "remote-note"
|
||||
}
|
||||
slug := nodes.Slugify(title)
|
||||
|
||||
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
||||
slug := nodes.Slugify("remote-note")
|
||||
var parent interface{}
|
||||
if payload.ParentID != "" {
|
||||
parent = payload.ParentID
|
||||
}
|
||||
_, e := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO nodes (id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
|
||||
VALUES (?,'note','remote-note',?,'','',?,?,1)`,
|
||||
payload.NodeID, slug, now, now)
|
||||
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
|
||||
VALUES (?,?,'note',?,?,'','',?,?,1)`,
|
||||
payload.NodeID, parent, title, slug, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
} else if payload.ParentID != "" {
|
||||
// Update parent_id on existing node (e.g., created by old version without parent_id).
|
||||
_, _ = a.db.Exec(
|
||||
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=? AND (parent_id IS NULL OR parent_id='')`,
|
||||
payload.ParentID, now, payload.NodeID)
|
||||
}
|
||||
|
||||
var dest string
|
||||
@@ -379,8 +507,8 @@ func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
||||
fileID = util.UUID7()
|
||||
}
|
||||
_, err := a.db.Exec(
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`,
|
||||
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
||||
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
|
||||
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1070,6 +1070,385 @@ func TestVaultLayout_FolderRenameDoesNotUpdateDBIfOsRenameFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_MoveNodeIntoDescendantRejected(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, _ := app.CreateNodeFromTemplate("", "Parent", "folder.default")
|
||||
child, _ := app.CreateNodeFromTemplate(parent.ID, "Child", "folder.default")
|
||||
grandchild, _ := app.CreateNodeFromTemplate(child.ID, "Grandchild", "folder.default")
|
||||
|
||||
// Try to move parent into grandchild
|
||||
if err := app.MoveNode(parent.ID, grandchild.ID); err == nil {
|
||||
t.Error("expected error when moving parent into descendant")
|
||||
}
|
||||
|
||||
// Try to move child into its own descendant
|
||||
if err := app.MoveNode(child.ID, grandchild.ID); err == nil {
|
||||
t.Error("expected error when moving node into own descendant")
|
||||
}
|
||||
|
||||
// Verify nothing changed
|
||||
n, _ := app.nodes.GetActive(parent.ID)
|
||||
if n.ParentID != nil {
|
||||
t.Error("expected parent to remain root")
|
||||
}
|
||||
movedChild, _ := app.nodes.GetActive(child.ID)
|
||||
if movedChild.ParentID == nil || *movedChild.ParentID != parent.ID {
|
||||
t.Error("expected child to remain under parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateDefaultFoldersCreatedAsNodes(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// The project.default template has DefaultFolders: ["Documents", "Notes", "Files"]
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProject", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Verify children nodes exist for each default folder
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
|
||||
expected := map[string]string{
|
||||
"Documents": "folder",
|
||||
"Notes": "folder",
|
||||
"Files": "folder",
|
||||
"Overview": "note",
|
||||
}
|
||||
for _, child := range children {
|
||||
expectedType, ok := expected[child.Title]
|
||||
if !ok {
|
||||
t.Errorf("unexpected child %q (type=%q)", child.Title, child.Type)
|
||||
continue
|
||||
}
|
||||
if child.Type != expectedType {
|
||||
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
|
||||
}
|
||||
if child.FsPath == "" && child.Type == "folder" {
|
||||
t.Errorf("child %q has empty fs_path", child.Title)
|
||||
}
|
||||
if child.Type == "folder" {
|
||||
physPath := filepath.Join(vault, child.FsPath)
|
||||
if info, err := os.Stat(physPath); err != nil || !info.IsDir() {
|
||||
t.Errorf("expected physical folder at %s", physPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(children) < 4 {
|
||||
t.Errorf("expected at least 4 children (3 folders + 1 note), got %d", len(children))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateDefaultFileCreatedAsNodeWithFileRecord(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// The project.default template has DefaultFiles: [{"path": "Overview.md"}]
|
||||
proj, err := app.CreateNodeFromTemplate("", "TestProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Find the Overview note child
|
||||
children, err := app.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
|
||||
var overview *nodes.Node
|
||||
for i := range children {
|
||||
if children[i].Title == "Overview" {
|
||||
overview = &children[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if overview == nil {
|
||||
t.Fatal("expected 'Overview' child node from template")
|
||||
}
|
||||
if overview.Type != "note" {
|
||||
t.Errorf("expected type 'note', got %q", overview.Type)
|
||||
}
|
||||
|
||||
// Verify file record exists
|
||||
records, err := app.files.ListByNode(overview.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list file records: %v", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
t.Fatal("expected file record for Overview")
|
||||
}
|
||||
rec := records[0]
|
||||
if rec.Filename != "Overview.md" {
|
||||
t.Errorf("expected filename 'Overview.md', got %q", rec.Filename)
|
||||
}
|
||||
if rec.StorageMode != "vault" {
|
||||
t.Errorf("expected storage mode 'vault', got %q", rec.StorageMode)
|
||||
}
|
||||
|
||||
// Verify physical file exists
|
||||
physPath := filepath.Join(vault, rec.Path)
|
||||
if _, err := os.Stat(physPath); os.IsNotExist(err) {
|
||||
t.Errorf("expected physical file at %s", physPath)
|
||||
}
|
||||
|
||||
// Verify notes record exists
|
||||
var format string
|
||||
err = app.db.QueryRow("SELECT format FROM notes WHERE node_id=?", overview.ID).Scan(&format)
|
||||
if err != nil {
|
||||
t.Errorf("expected notes record: %v", err)
|
||||
}
|
||||
if format != "markdown" {
|
||||
t.Errorf("expected format 'markdown', got %q", format)
|
||||
}
|
||||
|
||||
// Verify sync ops were recorded for child
|
||||
ops, err := app.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("get ops: %v", err)
|
||||
}
|
||||
foundNoteOp := false
|
||||
for _, op := range ops {
|
||||
if op.EntityID == overview.ID && op.OpType == syncsvc.OpCreate {
|
||||
foundNoteOp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundNoteOp {
|
||||
t.Error("expected sync OpCreate for Overview note child")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_DeleteNodeWithMissingFileDoesNotCorruptDB(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
// Create a folder with a child note
|
||||
parent, _ := app.CreateNodeFromTemplate("", "DeleteTest", "folder.default")
|
||||
noteNode, fileRec, err := app.notes.Create(parent.ID, "TestNote", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
|
||||
// Delete the physical file to simulate a missing file
|
||||
physPath := filepath.Join(app.vault, fileRec.Path)
|
||||
os.Remove(physPath)
|
||||
|
||||
// Delete the parent (should handle missing file gracefully)
|
||||
if err := app.DeleteNode(parent.ID); err != nil {
|
||||
t.Fatalf("delete parent with missing file: %v", err)
|
||||
}
|
||||
|
||||
// Verify all nodes are soft-deleted
|
||||
_, err = app.nodes.GetActive(parent.ID)
|
||||
if err == nil {
|
||||
t.Error("expected parent to be soft-deleted")
|
||||
}
|
||||
_, err = app.nodes.GetActive(noteNode.ID)
|
||||
if err == nil {
|
||||
t.Error("expected note to be soft-deleted")
|
||||
}
|
||||
|
||||
// VaultCheck should be healthy (no orphan references)
|
||||
result, err := app.VaultCheck()
|
||||
if err != nil {
|
||||
t.Fatalf("vault check: %v", err)
|
||||
}
|
||||
if !result.Healthy {
|
||||
t.Logf("vault check errors (may be acceptable): %v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateChildrenSyncRoundtrip(t *testing.T) {
|
||||
app1, vault1 := setupTestApp(t)
|
||||
defer app1.db.Close()
|
||||
defer os.RemoveAll(vault1)
|
||||
|
||||
// Create a project from template on app1 (creates parent + default children)
|
||||
proj, err := app1.CreateNodeFromTemplate("", "RoundtripProj", "project.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
|
||||
// Collect all sync ops from app1
|
||||
ops1, err := app1.sync.GetUnpushedOps()
|
||||
if err != nil {
|
||||
t.Fatalf("get ops: %v", err)
|
||||
}
|
||||
if len(ops1) < 4 {
|
||||
t.Fatalf("expected at least 4 sync ops (1 parent + 3 folders + 1 note), got %d", len(ops1))
|
||||
}
|
||||
|
||||
// Create app2 (simulating another device)
|
||||
app2, vault2 := setupTestApp(t)
|
||||
defer app2.db.Close()
|
||||
defer os.RemoveAll(vault2)
|
||||
|
||||
// Apply all ops on app2
|
||||
for _, op := range ops1 {
|
||||
if err := app2.applyRemoteOp(op); err != nil {
|
||||
// Skip notes that reference files not in blob store
|
||||
if op.EntityType == "note" && op.OpType == "create" {
|
||||
continue
|
||||
}
|
||||
t.Fatalf("apply remote op %s/%s: %v", op.EntityType, op.OpType, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create physical files for notes on app2 (since blobs aren't shared locally)
|
||||
createOps := 0
|
||||
for _, op := range ops1 {
|
||||
if op.EntityType == syncsvc.EntityNote && op.OpType == syncsvc.OpCreate {
|
||||
var payload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
dest := filepath.Join(vault2, payload.Path)
|
||||
os.MkdirAll(filepath.Dir(dest), 0o750)
|
||||
os.WriteFile(dest, []byte(payload.Content), 0o640)
|
||||
createOps++
|
||||
}
|
||||
}
|
||||
|
||||
// Verify app2 has the project node
|
||||
proj2, err := app2.nodes.GetActive(proj.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("app2: expected project node: %v", err)
|
||||
}
|
||||
if proj2.Title != "RoundtripProj" {
|
||||
t.Errorf("app2: expected title %q, got %q", "RoundtripProj", proj2.Title)
|
||||
}
|
||||
|
||||
// Verify app2 has all child nodes
|
||||
children2, err := app2.nodes.ListChildren(proj.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("app2: list children: %v", err)
|
||||
}
|
||||
|
||||
expectedChildren := map[string]string{
|
||||
"Documents": "folder",
|
||||
"Notes": "folder",
|
||||
"Files": "folder",
|
||||
"Overview": "note",
|
||||
}
|
||||
found := make(map[string]bool)
|
||||
for _, child := range children2 {
|
||||
expectedType, ok := expectedChildren[child.Title]
|
||||
if !ok {
|
||||
t.Errorf("app2: unexpected child %q", child.Title)
|
||||
continue
|
||||
}
|
||||
if child.Type != expectedType {
|
||||
t.Errorf("app2: child %q expected type %q, got %q", child.Title, expectedType, child.Type)
|
||||
}
|
||||
found[child.Title] = true
|
||||
}
|
||||
for title := range expectedChildren {
|
||||
if !found[title] {
|
||||
t.Errorf("app2: missing child %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify app2 has the Overview file record and note
|
||||
for _, child := range children2 {
|
||||
if child.Title == "Overview" {
|
||||
records, err := app2.files.ListByNode(child.ID)
|
||||
if err != nil {
|
||||
t.Errorf("app2: list file records for Overview: %v", err)
|
||||
continue
|
||||
}
|
||||
if len(records) == 0 {
|
||||
t.Error("app2: expected file record for Overview")
|
||||
}
|
||||
// Verify notes record exists
|
||||
var format string
|
||||
err = app2.db.QueryRow("SELECT format FROM notes WHERE node_id=?", child.ID).Scan(&format)
|
||||
if err != nil {
|
||||
t.Errorf("app2: expected notes record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultLayout_TemplateChildrenBackwardCompat(t *testing.T) {
|
||||
app, vault := setupTestApp(t)
|
||||
|
||||
// Simulate a remote node create with template_id but without separate child ops
|
||||
op := syncsvc.Op{
|
||||
EntityType: syncsvc.EntityNode,
|
||||
EntityID: "backward-compat-node-1",
|
||||
OpType: syncsvc.OpCreate,
|
||||
PayloadJSON: `{
|
||||
"id": "backward-compat-node-1",
|
||||
"parent_id": "",
|
||||
"type": "project",
|
||||
"title": "BackwardCompatProj",
|
||||
"slug": "backward-compat-proj",
|
||||
"template_id": "project.default",
|
||||
"fs_path": "BackwardCompatProj",
|
||||
"section": "",
|
||||
"sort_order": 0,
|
||||
"archived": false,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}`,
|
||||
}
|
||||
|
||||
if err := app.applyRemoteOp(op); err != nil {
|
||||
t.Fatalf("apply remote op: %v", err)
|
||||
}
|
||||
|
||||
// Verify the parent was created
|
||||
n, err := app.nodes.GetActive("backward-compat-node-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get node: %v", err)
|
||||
}
|
||||
if n.Title != "BackwardCompatProj" {
|
||||
t.Errorf("expected title 'BackwardCompatProj', got %q", n.Title)
|
||||
}
|
||||
|
||||
// Verify template children were created by ensureTemplateChildren
|
||||
children, err := app.nodes.ListChildren("backward-compat-node-1", false)
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
if len(children) < 3 {
|
||||
t.Errorf("expected at least 3 template children, got %d", len(children))
|
||||
}
|
||||
|
||||
expectedChildren := map[string]string{
|
||||
"Documents": "folder",
|
||||
"Notes": "folder",
|
||||
"Files": "folder",
|
||||
"Overview": "note",
|
||||
}
|
||||
for _, child := range children {
|
||||
expectedType, ok := expectedChildren[child.Title]
|
||||
if !ok {
|
||||
t.Errorf("unexpected child %q", child.Title)
|
||||
continue
|
||||
}
|
||||
if child.Type != expectedType {
|
||||
t.Errorf("child %q expected type %q, got %q", child.Title, expectedType, child.Type)
|
||||
}
|
||||
if child.Type == "folder" && child.FsPath == "" {
|
||||
t.Errorf("child %q has empty fs_path", child.Title)
|
||||
}
|
||||
// Verify physical folder/file exists
|
||||
if child.Type == "folder" {
|
||||
physPath := filepath.Join(vault, child.FsPath)
|
||||
if _, err := os.Stat(physPath); os.IsNotExist(err) {
|
||||
t.Errorf("expected physical folder at %s", physPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func listNames(entries []os.DirEntry) []string {
|
||||
|
||||
Reference in New Issue
Block a user