diff --git a/docs/sync-conflicts.md b/docs/sync-conflicts.md new file mode 100644 index 0000000..651db92 --- /dev/null +++ b/docs/sync-conflicts.md @@ -0,0 +1,37 @@ +# Sync conflict resolution + +## Goals +- Keep local notes safe during sync. +- Prefer the most recent write. +- Preserve conflicting edits in deterministic copies. + +## Conflict detection +1. Each note stores its last modified time from the filesystem. +2. During sync, compare the incoming remote modified time to the local modified time. +3. A conflict exists when **both sides changed** since the last sync checkpoint. + +## Resolution policy (last-write-wins + conflict copies) +1. If only one side changed, accept that change. +2. If both sides changed: + - The version with the newest modified time becomes the primary note (last-write-wins). + - The losing version is written as a conflict copy alongside the primary note. + +## Conflict copy naming +Conflict copies are deterministic and derived from the losing version metadata: + +``` +.conflict-- +``` + +Example: + +``` +journal.txt.conflict-20240506T070809Z-device-01 +``` + +Where: +- `UTC timestamp` is formatted as `YYYYMMDDTHHMMSSZ` from the losing write's modified time. +- `actor id` is the sync client identifier that performed the losing write (defaults to `unknown` when not available). + +## File placement +Conflict copies are created in the same directory as the original note so users can discover and resolve them locally. diff --git a/sync_conflicts.go b/sync_conflicts.go new file mode 100644 index 0000000..31052a1 --- /dev/null +++ b/sync_conflicts.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "path/filepath" + "time" +) + +func conflictCopyPath(originalPath string, conflictAt time.Time, actorID string) string { + if actorID == "" { + actorID = "unknown" + } + timestamp := conflictAt.UTC().Format("20060102T150405Z") + filename := fmt.Sprintf("%s.conflict-%s-%s", filepath.Base(originalPath), timestamp, actorID) + return filepath.Join(filepath.Dir(originalPath), filename) +} diff --git a/sync_conflicts_test.go b/sync_conflicts_test.go new file mode 100644 index 0000000..d52ada3 --- /dev/null +++ b/sync_conflicts_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "path/filepath" + "testing" + "time" +) + +func TestConflictCopyPath(t *testing.T) { + original := filepath.Join("notes", "entry.txt") + at := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC) + + got := conflictCopyPath(original, at, "device-01") + expected := filepath.Join("notes", "entry.txt.conflict-20240506T070809Z-device-01") + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} + +func TestConflictCopyPathDefaultsActor(t *testing.T) { + original := filepath.Join("notes", "entry.txt") + at := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC) + + got := conflictCopyPath(original, at, "") + expected := filepath.Join("notes", "entry.txt.conflict-20240506T070809Z-unknown") + if got != expected { + t.Fatalf("expected %q, got %q", expected, got) + } +} diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..d36cc2d --- /dev/null +++ b/watcher.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "sort" + "time" +) + +type EventType string + +const ( + EventCreate EventType = "create" + EventModify EventType = "modify" + EventDelete EventType = "delete" +) + +type FileEvent struct { + Path string + Type EventType +} + +type fileState struct { + modTime time.Time + size int64 +} + +type Watcher struct { + dir string + state map[string]fileState +} + +func NewWatcher(dir string) (*Watcher, error) { + state, err := snapshotDir(dir) + if err != nil { + return nil, err + } + + return &Watcher{dir: dir, state: state}, nil +} + +func (w *Watcher) Poll() ([]FileEvent, error) { + current, err := snapshotDir(w.dir) + if err != nil { + return nil, err + } + + var events []FileEvent + for path, cur := range current { + prev, ok := w.state[path] + if !ok { + events = append(events, FileEvent{Path: path, Type: EventCreate}) + continue + } + if cur.modTime != prev.modTime || cur.size != prev.size { + events = append(events, FileEvent{Path: path, Type: EventModify}) + } + } + + for path := range w.state { + if _, ok := current[path]; !ok { + events = append(events, FileEvent{Path: path, Type: EventDelete}) + } + } + + sort.Slice(events, func(i, j int) bool { + if events[i].Path == events[j].Path { + return events[i].Type < events[j].Type + } + return events[i].Path < events[j].Path + }) + + w.state = current + return events, nil +} + +func (w *Watcher) Start(ctx context.Context, interval time.Duration) (<-chan FileEvent, <-chan error) { + events := make(chan FileEvent) + errs := make(chan error, 1) + + go func() { + defer close(events) + defer close(errs) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + batch, err := w.Poll() + if err != nil { + errs <- err + return + } + for _, event := range batch { + select { + case <-ctx.Done(): + return + case events <- event: + } + } + } + } + }() + + return events, errs +} + +func snapshotDir(dir string) (map[string]fileState, error) { + state := make(map[string]fileState) + err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + if !entry.Type().IsRegular() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + state[path] = fileState{modTime: info.ModTime(), size: info.Size()} + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return state, nil + } + return nil, err + } + return state, nil +} diff --git a/watcher_test.go b/watcher_test.go new file mode 100644 index 0000000..7df98e3 --- /dev/null +++ b/watcher_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWatcherDetectsCreateModifyDelete(t *testing.T) { + dir := t.TempDir() + watcher, err := NewWatcher(dir) + if err != nil { + t.Fatalf("NewWatcher returned error: %v", err) + } + + events, err := watcher.Poll() + if err != nil { + t.Fatalf("Poll returned error: %v", err) + } + if len(events) != 0 { + t.Fatalf("expected no events, got %v", events) + } + + notePath := filepath.Join(dir, "note.txt") + if err := os.WriteFile(notePath, []byte("first"), 0o600); err != nil { + t.Fatalf("write failed: %v", err) + } + + events, err = watcher.Poll() + if err != nil { + t.Fatalf("Poll returned error after create: %v", err) + } + if len(events) != 1 || events[0].Type != EventCreate || events[0].Path != notePath { + t.Fatalf("expected create event for %s, got %v", notePath, events) + } + + if err := os.WriteFile(notePath, []byte("second"), 0o600); err != nil { + t.Fatalf("rewrite failed: %v", err) + } + modTime := time.Now().Add(2 * time.Second) + if err := os.Chtimes(notePath, modTime, modTime); err != nil { + t.Fatalf("chtimes failed: %v", err) + } + + events, err = watcher.Poll() + if err != nil { + t.Fatalf("Poll returned error after modify: %v", err) + } + if len(events) != 1 || events[0].Type != EventModify || events[0].Path != notePath { + t.Fatalf("expected modify event for %s, got %v", notePath, events) + } + + if err := os.Remove(notePath); err != nil { + t.Fatalf("remove failed: %v", err) + } + + events, err = watcher.Poll() + if err != nil { + t.Fatalf("Poll returned error after delete: %v", err) + } + if len(events) != 1 || events[0].Type != EventDelete || events[0].Path != notePath { + t.Fatalf("expected delete event for %s, got %v", notePath, events) + } +}