Add version history with rollback and per-note created/synced info
All checks were successful
ci / test (push) Successful in 3m8s
ci / release (push) Successful in 11m21s

Server (schema v2, auto-migrates): every accepted overwrite snapshots the
superseded revision into note_history (capped at 50 per note); new
GET /api/v1/notes/{id}/history endpoint; compact purges orphaned history.

Client: notes get a synced_at stamp on every confirmed server exchange;
an info footer under the editor and a Ctrl/Cmd+I panel show created/
modified/last-synced plus the revision list. Restoring a revision applies
it as a normal edit through the sync path, so rollback is non-destructive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 22:20:19 +02:00
parent bd2b8e6241
commit c389bab46e
12 changed files with 563 additions and 9 deletions

View File

@@ -1,6 +1,7 @@
package store
import (
"fmt"
"path/filepath"
"strings"
"testing"
@@ -159,6 +160,104 @@ func TestChangesPaging(t *testing.T) {
}
}
func TestHistoryRecordsSupersededRevisions(t *testing.T) {
s := newTestStore(t)
r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", Tags: []string{"t"}, ModifiedAt: 1})
if revs, _ := s.History("a"); len(revs) != 0 {
t.Fatalf("insert must not create history, got %d", len(revs))
}
r2 := push(t, s, IncomingNote{ID: "a", Content: "v2", ModifiedAt: 2, BaseVersion: r1.Version})
r3 := push(t, s, IncomingNote{ID: "a", Content: "v3", ModifiedAt: 3, BaseVersion: r2.Version})
revs, err := s.History("a")
if err != nil {
t.Fatal(err)
}
if len(revs) != 2 {
t.Fatalf("want 2 revisions, got %d", len(revs))
}
// Newest first: the v2 state (superseded by the v3 push), then v1.
if revs[0].Content != "v2" || revs[0].Version != r2.Version {
t.Fatalf("revs[0] = %+v", revs[0])
}
if revs[1].Content != "v1" || revs[1].Version != r1.Version || !containsStr(revs[1].Tags, "t") {
t.Fatalf("revs[1] = %+v", revs[1])
}
if revs[0].ReplacedAt == 0 {
t.Fatal("replaced_at not stamped")
}
_ = r3
}
func TestHistoryRecordedOnDeleteAndResurrect(t *testing.T) {
s := newTestStore(t)
r1 := push(t, s, IncomingNote{ID: "a", Content: "precious", ModifiedAt: 1})
r2 := push(t, s, IncomingNote{ID: "a", Content: "precious", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version})
// Stale edit overwrites the tombstone (rule 4b) — tombstone state goes to history.
push(t, s, IncomingNote{ID: "a", Content: "resurrected", ModifiedAt: 3, BaseVersion: r1.Version})
revs, err := s.History("a")
if err != nil {
t.Fatal(err)
}
if len(revs) != 2 || revs[0].Version != r2.Version || revs[1].Content != "precious" {
t.Fatalf("history after delete+resurrect: %+v", revs)
}
}
func TestHistoryNotRecordedOnConflict(t *testing.T) {
s := newTestStore(t)
r1 := push(t, s, IncomingNote{ID: "a", Content: "base", ModifiedAt: 1})
push(t, s, IncomingNote{ID: "a", Content: "B's edit", ModifiedAt: 2, BaseVersion: r1.Version})
// Stale edit → conflict copy; server note untouched, so no new history entry.
push(t, s, IncomingNote{ID: "a", Content: "A's edit", ModifiedAt: 3, BaseVersion: r1.Version})
revs, _ := s.History("a")
if len(revs) != 1 || revs[0].Content != "base" {
t.Fatalf("conflict must not record history: %+v", revs)
}
}
func TestHistoryPrunedToCap(t *testing.T) {
s := newTestStore(t)
r := push(t, s, IncomingNote{ID: "a", Content: "v0", ModifiedAt: 0})
for i := 1; i <= historyKeep+10; i++ {
r = push(t, s, IncomingNote{ID: "a", Content: fmt.Sprintf("v%d", i), ModifiedAt: int64(i), BaseVersion: r.Version})
}
revs, err := s.History("a")
if err != nil {
t.Fatal(err)
}
if len(revs) != historyKeep {
t.Fatalf("want %d revisions after prune, got %d", historyKeep, len(revs))
}
if revs[0].Content != fmt.Sprintf("v%d", historyKeep+9) {
t.Fatalf("newest revision wrong: %q", revs[0].Content)
}
}
func TestCompactPurgesOrphanHistory(t *testing.T) {
s := newTestStore(t)
old := time.Now().AddDate(0, 0, -200).UnixMilli()
r1 := push(t, s, IncomingNote{ID: "a", Content: "x", ModifiedAt: old})
push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: old, BaseVersion: r1.Version})
if _, err := s.Compact(90); err != nil {
t.Fatal(err)
}
revs, err := s.History("a")
if err != nil {
t.Fatal(err)
}
if len(revs) != 0 {
t.Fatalf("history must be purged with the note, got %d", len(revs))
}
}
func TestTokenRoundtrip(t *testing.T) {
s := newTestStore(t)
tok, err := s.EnsureToken()