mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(hub): sort project history by time, not Lamport clock (BEA-9) (#59)
This commit is contained in:
@@ -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=<file> (one file's versions) or
|
||||
// ?prefix=<folder/> (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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user