diff --git a/internal/webapp/history.go b/internal/webapp/history.go index 1350460..e5934cd 100644 --- a/internal/webapp/history.go +++ b/internal/webapp/history.go @@ -4,8 +4,10 @@ import ( "fmt" "io" "net/http" + "sort" "strconv" "strings" + "time" "github.com/runbear-io/beardrive/internal/journal" ) @@ -33,7 +35,7 @@ type HistoryEntry struct { // handleHistory serves ?path= (one file's versions) or // ?prefix= (everything underneath, "" = the whole project), -// newest first, at most ?n= entries (default 100). +// newest first by wall-clock time, at most ?n= entries (default 100). func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request) { rs := storeSource(v, w) if rs == nil { @@ -77,8 +79,12 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request exists[op.Path] = true } } - entries := make([]HistoryEntry, 0, n) - for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first + type timed struct { + entry HistoryEntry + at time.Time + } + matched := make([]timed, 0, len(all)) + for i := len(all) - 1; i >= 0; i-- { // descending journal (Lamport) order op := all[i] switch { case path != "" && op.Path != path: @@ -90,12 +96,26 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request if dev.ID == "" { dev = DeviceInfo{ID: op.Device, Name: op.DeviceName} } - entries = append(entries, HistoryEntry{ + matched = append(matched, timed{HistoryEntry{ Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i], Path: op.Path, Size: op.Size, Blob: op.Blob, User: op.User, UserName: op.UserName, Author: op.Author, Device: dev, Note: op.Note, - }) + }, op.Time}) + } + // Journal order is causal, not chronological: a device that was offline can + // write at a later wall-clock time yet carry a lower Lamport clock, so + // reverse-journal order is not "newest first". Sort the response by time, + // and truncate to ?n= AFTER that — truncating during the walk above would + // pick the n highest-Lamport entries and merely display them in time order. + // Stable + strict After keeps equal timestamps in descending-Lamport order. + sort.SliceStable(matched, func(a, b int) bool { return matched[a].at.After(matched[b].at) }) + if len(matched) > n { + matched = matched[:n] + } + entries := make([]HistoryEntry, len(matched)) + for i, m := range matched { + entries[i] = m.entry } writeJSON(w, map[string]any{"entries": entries}) } diff --git a/internal/webapp/history_test.go b/internal/webapp/history_test.go index 570997d..4c559cd 100644 --- a/internal/webapp/history_test.go +++ b/internal/webapp/history_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "testing" "time" @@ -31,6 +32,25 @@ func (f *fakeRemote) putAs(dev, user, userName, path, content string) { writeFileT(f.t, p, data) } +// putAt writes a put whose wall-clock time is `at`. The Lamport clock still +// advances in call order, so a test can make causal and chronological order +// disagree — exactly what an offline device produces. +func (f *fakeRemote) putAt(dev, path, content string, at time.Time) { + f.t.Helper() + f.put(dev, path, content) + p := filepath.Join(f.dir, "journal", dev+".jsonl") + ops, err := journal.ReadFile(p) + if err != nil || len(ops) == 0 { + f.t.Fatal(err) + } + ops[len(ops)-1].Time = at + data, err := journal.Marshal(ops) + if err != nil { + f.t.Fatal(err) + } + writeFileT(f.t, p, data) +} + func writeFileT(t *testing.T, path string, data []byte) { t.Helper() if err := os.WriteFile(path, data, 0o644); err != nil { @@ -130,6 +150,70 @@ func TestHistoryAPI(t *testing.T) { } } +// History is newest-first by wall-clock time, not by Lamport clock: a device +// that was offline writes later in real time but carries a lower clock, so +// reverse-journal order would bury the most recent change. +func TestHistoryOrderedByTimeNotLamport(t *testing.T) { + srv, p, root := newHub(t, false, nil) + f := newFakeRemoteAt(t, filepath.Join(root, p.ID)) + early := time.Date(2026, 7, 26, 0, 9, 17, 0, time.UTC) + late := time.Date(2026, 7, 26, 22, 9, 17, 0, time.UTC) + // lamport 1 but newest by the clock — the offline device + f.putAt("offline", "notes/late.md", "written offline", late) + // lamport 2..4, all at the same earlier timestamp + f.putAt("online", "notes/a.md", "a", early) + f.putAt("online", "notes/b.md", "b", early) + f.putAt("online", "notes/c.md", "c", early) + + h := srv.Handler() + base := "/api/p/" + p.ID + "/" + paths := func(url string) []string { + t.Helper() + rec := do(t, h, "GET", url, nil) + if rec.Code != 200 { + t.Fatalf("history: %d %s", rec.Code, rec.Body) + } + var out struct { + Entries []HistoryEntry `json:"entries"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + var got []string + for _, e := range out.Entries { + if e.Kind != "add" { // first version of each path, whatever the display order + t.Fatalf("kind = %q for %s, want add", e.Kind, e.Path) + } + got = append(got, e.Path+"@"+e.Time) + } + return got + } + + // newest by time first, then the equal-timestamp rows in descending Lamport + want := []string{ + "notes/late.md@2026-07-26T22:09:17Z", + "notes/c.md@2026-07-26T00:09:17Z", + "notes/b.md@2026-07-26T00:09:17Z", + "notes/a.md@2026-07-26T00:09:17Z", + } + got := paths(base + "history") + if !slices.Equal(got, want) { + t.Fatalf("order = %v, want %v", got, want) + } + // ?n= selects the n most recent BY TIME, not the n highest-Lamport + if got := paths(base + "history?n=1"); !slices.Equal(got, want[:1]) { + t.Fatalf("n=1 = %v, want %v", got, want[:1]) + } + // equal timestamps come back in a stable order across requests + if again := paths(base + "history"); !slices.Equal(again, want) { + t.Fatalf("repeat = %v, want %v", again, want) + } + // a filtered view sorts the same way + if got := paths(base + "history?prefix=notes/"); !slices.Equal(got, want) { + t.Fatalf("prefix order = %v, want %v", got, want) + } +} + func TestDeviceRegistryObserve(t *testing.T) { path := filepath.Join(t.TempDir(), "devices.json") r, err := OpenDeviceRegistry(path)