fix(hub): sort project history by time, not Lamport clock (BEA-9) (#59)

This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-27 20:49:22 +09:00
committed by GitHub
parent 65d4df5a84
commit e629ae4ab0
2 changed files with 109 additions and 5 deletions
+25 -5
View File
@@ -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})
}
+84
View File
@@ -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)