Add version history with rollback and per-note created/synced info
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user