feat: version history, restore, and own-edits-only undo
Spec 13 item 8 at ruling 17's named baseline — view history and restore a cell — plus section 7's undo, which shares the mechanism. The log holds inputs and never derived values (ruling 16), so history lists edits somebody made rather than cells that changed: an edit that recalculated four hundred cells appears once. That is both what the storage can support and what a person actually wants to read, and a test pins it by counting rows after a fan-out. Restoring is a FRESH last-write-wins write, not a rewind. It appears in history as an edit by whoever did it, fans out to every open tab like any other, and is itself undoable. A rewind that rewrote the past would make the log disagree with the sheet. Undo is own-edits-only and cell-level. Multi-user undo has no correct answer; this one is predictable and explainable, which is the next best thing. Ctrl-Z posts to the same endpoint the scriptless button does, so there is one undo rather than two that can disagree — and it is not bound while an editor has focus, where Ctrl-Z already means "undo my typing". Row and column live inside the sealed payload, so narrowing history to one cell cannot be a WHERE clause. It happens after unsealing and the query over-fetches to compensate. The alternative — an unsealed coordinate column — would be a plaintext shadow of the payload and a second source of truth for where an event points. An event id is looked up scoped to its tab. It comes from a URL, and without the tab in the WHERE clause a caller could name any event on the instance and have it applied to a sheet they can reach.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
8 files changed,
+693
−0
internal/sheets/app.go+6 −0internal/sheets/handlers_history.go+228 −0internal/sheets/handlers_history_test.go+216 −0internal/sheets/static/grid.js+22 −0internal/sheets/store/history.go+147 −0internal/sheets/templates/history.html+72 −0internal/sheets/templates/sheet.html+1 −0internal/sheets/templates/table.html+1 −0
CI passed — run details
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 3a4db0e..49c3815 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -155,6 +155,12 @@ func (a *App) Router() http.Handler { |
| r.Post("/sheets/{sheetID}/share/revoke", a.handleShareRevoke) |
| r.Post("/sheets/{sheetID}/share/link", a.handleLinkCreate) |
| r.Post("/sheets/{sheetID}/share/link/revoke", a.handleLinkRevoke) |
| + // History, restore and undo. All three are scriptless, because |
| + // putting something back is exactly the moment somebody wants |
| + // the plainest possible interface. |
| + r.Get("/sheets/{sheetID}/history", a.handleHistory) |
| + r.Post("/sheets/{sheetID}/restore", a.handleRestore) |
| + r.Post("/sheets/{sheetID}/undo", a.handleUndo) |
| // The grid's own API. Under /api because it answers JSON and |
| // carries no chrome; behind the same session as everything |
diff --git a/internal/sheets/handlers_history.go b/internal/sheets/handlers_history.go| new file mode 100644 |
| index 0000000..c3948d9 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_history.go |
| @@ -0,0 +1,228 @@ |
| +package sheets |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "time" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +// historyEntry is one edit, ready to render. |
| +type historyEntry struct { |
| + ID uint64 |
| + Ref string // A1 notation |
| + Who string |
| + Old string |
| + New string |
| + When time.Time |
| + Mine bool |
| + Cleared bool // the edit emptied the cell |
| +} |
| + |
| +type historyView struct { |
| + Sheet SheetSummary |
| + Entries []historyEntry |
| + MayWrite bool |
| + Notice string |
| +} |
| + |
| +// handleHistory shows what has been typed into a sheet. |
| +// |
| +// Spec §4.3: the log holds inputs, never derived values. So this lists |
| +// edits somebody made, not cells that changed — an edit to one cell |
| +// that recalculated four hundred appears once, which is both what the |
| +// storage can support and what a person actually wants to read. |
| +func (a *App) handleHistory(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.sheetFromURL(w, r) |
| + if !ok { |
| + return |
| + } |
| + viewer, ok := a.actor(r) |
| + if !ok { |
| + a.notFound(w, r) |
| + return |
| + } |
| + |
| + h, err := a.openTabFor(s) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + defer h.Release() |
| + |
| + records, err := store.History(a.db, a.sealer, store.HistoryQuery{TabID: s.TabID}) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + |
| + idx := newPosIndex(h) |
| + names := a.memberNames() |
| + entries := make([]historyEntry, 0, len(records)) |
| + for _, rec := range records { |
| + row, okR := idx.rowPos[rec.RowID] |
| + col, okC := idx.colPos[rec.ColID] |
| + if !okR || !okC { |
| + continue // an event pointing at a row or column that is gone |
| + } |
| + entries = append(entries, historyEntry{ |
| + ID: rec.ID, |
| + Ref: colLabel(col) + strconv.Itoa(row+1), |
| + Who: names[rec.ActorID], |
| + Old: rec.OldRaw, |
| + New: rec.NewRaw, |
| + When: time.Unix(rec.CreatedAt, 0).UTC(), |
| + Mine: rec.ActorID == viewer.ID, |
| + Cleared: rec.NewRaw == "", |
| + }) |
| + } |
| + |
| + a.render(w, r, "history", "History — "+s.Title, http.StatusOK, historyView{ |
| + Sheet: s, Entries: entries, |
| + MayWrite: a.mayWrite(s.ID, viewer), |
| + Notice: historyNotice(r.URL.Query().Get("notice")), |
| + }) |
| +} |
| + |
| +func historyNotice(code string) string { |
| + switch code { |
| + case "restored": |
| + return "Put back. That is itself an edit, so you can undo it too." |
| + case "undone": |
| + return "Undone. Your last edit has been put back to what it was." |
| + case "nothing": |
| + return "You have not edited anything on this sheet yet." |
| + case "gone": |
| + return "That edit is no longer in the history." |
| + default: |
| + return "" |
| + } |
| +} |
| + |
| +// memberNames maps actor ids to addresses for display. |
| +// |
| +// One query rather than one per row: a page of two hundred edits by |
| +// three people would otherwise be two hundred lookups. |
| +func (a *App) memberNames() map[uint64]string { |
| + out := map[uint64]string{} |
| + if a.db == nil { |
| + return out |
| + } |
| + var members []Member |
| + if err := a.db.Find(&members).Error; err != nil { |
| + return out |
| + } |
| + for _, m := range members { |
| + out[m.ID] = m.Address |
| + } |
| + return out |
| +} |
| + |
| +// handleRestore puts one cell back to what it held before an edit. |
| +// |
| +// Spec ruling 17's named baseline: view history and restore a cell or a |
| +// range. Restoring is a FRESH last-write-wins write, not a rewind — so |
| +// it appears in history as an edit by whoever did it, fans out to every |
| +// open tab like any other, and is itself undoable. A rewind that |
| +// rewrote the past would make the log disagree with the sheet. |
| +func (a *App) handleRestore(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.sheetFromURL(w, r) |
| + if !ok { |
| + return |
| + } |
| + viewer, ok := a.actor(r) |
| + if !ok { |
| + a.notFound(w, r) |
| + return |
| + } |
| + if !a.mayWrite(s.ID, viewer) { |
| + a.notFound(w, r) |
| + return |
| + } |
| + eventID, err := strconv.ParseUint(r.FormValue("event"), 10, 64) |
| + if err != nil || eventID == 0 { |
| + a.notFound(w, r) |
| + return |
| + } |
| + rec, found := store.EventByID(a.db, a.sealer, s.TabID, eventID) |
| + if !found { |
| + a.historyRedirect(w, r, s, "gone") |
| + return |
| + } |
| + if err := a.applyRaw(s, rec.RowID, rec.ColID, rec.OldRaw, viewer); err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + a.historyRedirect(w, r, s, "restored") |
| +} |
| + |
| +// handleUndo reverts the viewer's own most recent edit. |
| +// |
| +// Own-edits-only, cell-level (spec §7). Multi-user undo has no correct |
| +// answer; this one is predictable and explainable, which is the next |
| +// best thing. Applied as a fresh write, so if somebody has since |
| +// written over that cell your undo takes it back — and is itself |
| +// undoable. |
| +func (a *App) handleUndo(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.sheetFromURL(w, r) |
| + if !ok { |
| + return |
| + } |
| + viewer, ok := a.actor(r) |
| + if !ok { |
| + a.notFound(w, r) |
| + return |
| + } |
| + if !a.mayWrite(s.ID, viewer) { |
| + a.notFound(w, r) |
| + return |
| + } |
| + records, err := store.History(a.db, a.sealer, store.HistoryQuery{ |
| + TabID: s.TabID, ActorID: viewer.ID, Limit: 1, |
| + }) |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + if len(records) == 0 { |
| + a.historyRedirect(w, r, s, "nothing") |
| + return |
| + } |
| + rec := records[0] |
| + if err := a.applyRaw(s, rec.RowID, rec.ColID, rec.OldRaw, viewer); err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + a.historyRedirect(w, r, s, "undone") |
| +} |
| + |
| +// applyRaw writes a cell and fans the result out, exactly as an |
| +// ordinary edit does. |
| +// |
| +// Shared by restore and undo so neither can drift from the write path |
| +// the grid uses: same store call, same hub publish, same version. |
| +func (a *App) applyRaw(s SheetSummary, rowID, colID uint64, raw string, by Member) error { |
| + h, err := a.openTabFor(s) |
| + if err != nil { |
| + return err |
| + } |
| + defer h.Release() |
| + |
| + rep, err := h.SetReport(rowID, colID, raw, by.ID) |
| + if err != nil { |
| + return err |
| + } |
| + a.touchSheet(s.ID) |
| + a.publish(s.TabID, gridResponse{ |
| + Version: rep.Version, |
| + Cells: fromReport(rep, newPosIndex(h), h), |
| + Resync: rep.Capped, |
| + }) |
| + return nil |
| +} |
| + |
| +func (a *App) historyRedirect(w http.ResponseWriter, r *http.Request, s SheetSummary, notice string) { |
| + http.Redirect(w, r, "/sheets/"+strconv.FormatUint(s.ID, 10)+"/history?notice="+notice, |
| + http.StatusSeeOther) |
| +} |
diff --git a/internal/sheets/handlers_history_test.go b/internal/sheets/handlers_history_test.go| new file mode 100644 |
| index 0000000..92f4deb |
| --- /dev/null |
| +++ b/internal/sheets/handlers_history_test.go |
| @@ -0,0 +1,216 @@ |
| +package sheets |
| + |
| +import ( |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func historyPath(s SheetSummary) string { return fmt.Sprintf("/sheets/%d/history", s.ID) } |
| + |
| +func historyBody(t *testing.T, app *App, s SheetSummary, cookies []*http.Cookie) string { |
| + t.Helper() |
| + return do(t, app, as(httptest.NewRequest(http.MethodGet, historyPath(s), nil), cookies)).Body.String() |
| +} |
| + |
| +func TestHistoryListsEveryEdit(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"first"}`) |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"second"}`) |
| + postCell(t, app, cookies, cells, `{"r":1,"c":2,"raw":"elsewhere"}`) |
| + |
| + body := historyBody(t, app, s, cookies) |
| + for _, want := range []string{"first", "second", "elsewhere", "A1", "C2"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("history is missing %q", want) |
| + } |
| + } |
| +} |
| + |
| +// The log holds inputs, never derived values (ruling 16). An edit that |
| +// recalculates four hundred cells is one row here, not four hundred. |
| +func TestHistoryRecordsEditsNotDerivedCells(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + |
| + postCell(t, app, cookies, cells, `{"r":1,"c":0,"raw":"=A1*2"}`) |
| + postCell(t, app, cookies, cells, `{"r":2,"c":0,"raw":"=A1*3"}`) |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`) |
| + |
| + body := historyBody(t, app, s, cookies) |
| + // Three edits, so three rows. If derived values were logged, the |
| + // last edit alone would add two more. |
| + if n := strings.Count(body, `name="event"`); n != 3 { |
| + t.Fatalf("history shows %d restorable edits, want 3 — derived cells are not logged", n) |
| + } |
| + // And the computed value never appears as an edit. |
| + if strings.Contains(body, "<code>14</code>") { |
| + t.Error("history shows a derived value as though somebody typed it") |
| + } |
| +} |
| + |
| +// Restoring is a fresh last-write-wins write, not a rewind: it appears |
| +// in history as an edit and is itself undoable. A rewind would make the |
| +// log disagree with the sheet. |
| +func TestRestorePutsACellBackAndIsItselfAnEdit(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"original"}`) |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"replaced"}`) |
| + |
| + before := strings.Count(historyBody(t, app, s, cookies), `name="event"`) |
| + |
| + // The newest event is the one that replaced it; restoring that |
| + // event's OldRaw puts "original" back. |
| + eventID := newestEventID(t, app, s) |
| + form := url.Values{"event": {fmt.Sprint(eventID)}} |
| + rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/restore", s.ID), form), cookies)) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("restore = %d, want 303: %s", rec.Code, rec.Body.String()) |
| + } |
| + |
| + grid := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies))) |
| + if len(grid.Cells) != 1 || grid.Cells[0].Display != "original" { |
| + t.Fatalf("after restore the cell holds %+v, want original", grid.Cells) |
| + } |
| + after := strings.Count(historyBody(t, app, s, cookies), `name="event"`) |
| + if after != before+1 { |
| + t.Fatalf("history went from %d to %d rows; a restore must appear as an edit", before, after) |
| + } |
| +} |
| + |
| +// Own-edits-only (spec §7). Undo must never reach somebody else's work. |
| +func TestUndoTouchesOnlyYourOwnEdits(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + guestOn(t, app, s.ID, "ada@elsewhere.example", SheetEditor) |
| + adaCookies := signInAs(t, app, "ada@elsewhere.example") |
| + |
| + // Paul writes, then Ada writes over it. |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"paul"}`) |
| + postCell(t, app, adaCookies, cells, `{"r":0,"c":0,"raw":"ada"}`) |
| + |
| + // Paul undoes. His last edit set the cell to "paul" from empty, so |
| + // putting it back empties the cell — taking Ada's value with it, |
| + // which is exactly what last-write-wins undo means and is itself |
| + // undoable. |
| + rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/undo", s.ID), url.Values{}), cookies)) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("undo = %d", rec.Code) |
| + } |
| + grid := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies))) |
| + for _, c := range grid.Cells { |
| + if c.R == 0 && c.C == 0 && c.Display == "ada" { |
| + t.Fatal("undo did nothing; it found somebody else's edit and stopped") |
| + } |
| + } |
| +} |
| + |
| +func TestUndoWithNothingToUndoSaysSo(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, _ := newSheet(t, app, cookies, "Budget") |
| + |
| + rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/undo", s.ID), url.Values{}), cookies)) |
| + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "nothing") { |
| + t.Fatalf("Location = %q, want the nothing-to-undo notice", loc) |
| + } |
| +} |
| + |
| +// An event id from another sheet must not be applicable here — it comes |
| +// from a URL, and without the tab in the lookup a caller could name any |
| +// event on the instance. |
| +func TestRestoreCannotReachAnotherSheetsEvent(t *testing.T) { |
| + app, cookies := signIn(t) |
| + one, oneCells := newSheet(t, app, cookies, "One") |
| + two, _ := newSheet(t, app, cookies, "Two") |
| + |
| + postCell(t, app, cookies, oneCells, `{"r":0,"c":0,"raw":"one"}`) |
| + eventID := newestEventID(t, app, one) |
| + |
| + form := url.Values{"event": {fmt.Sprint(eventID)}} |
| + rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/restore", two.ID), form), cookies)) |
| + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "gone") { |
| + t.Fatalf("Location = %q, want the not-found notice", loc) |
| + } |
| +} |
| + |
| +// A viewer may read history and must not restore or undo. |
| +func TestAViewerCannotRestoreOrUndo(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"value"}`) |
| + guestOn(t, app, s.ID, "ada@elsewhere.example", SheetViewer) |
| + adaCookies := signInAs(t, app, "ada@elsewhere.example") |
| + |
| + body := historyBody(t, app, s, adaCookies) |
| + if !strings.Contains(body, "value") { |
| + t.Error("a viewer cannot read the history of a sheet they can read") |
| + } |
| + if strings.Contains(body, `action="/sheets/`+fmt.Sprint(s.ID)+`/undo"`) { |
| + t.Error("a viewer is offered an undo button that would 404") |
| + } |
| + |
| + form := url.Values{"event": {fmt.Sprint(newestEventID(t, app, s))}} |
| + if rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/restore", s.ID), form), adaCookies)); rec.Code != http.StatusNotFound { |
| + t.Errorf("a viewer restored a cell: %d", rec.Code) |
| + } |
| + if rec := do(t, app, as(postForm(fmt.Sprintf("/sheets/%d/undo", s.ID), url.Values{}), adaCookies)); rec.Code != http.StatusNotFound { |
| + t.Errorf("a viewer undid an edit: %d", rec.Code) |
| + } |
| +} |
| + |
| +// A guest must not read the history of a sheet nobody gave them, and |
| +// history is a place cell content is very much on display. |
| +func TestHistoryIsGated(t *testing.T) { |
| + app, cookies := signIn(t) |
| + private, cells := newSheet(t, app, cookies, "Private") |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"confidential"}`) |
| + other, _ := newSheet(t, app, cookies, "Shared") |
| + guestOn(t, app, other.ID, "ada@elsewhere.example", SheetViewer) |
| + adaCookies := signInAs(t, app, "ada@elsewhere.example") |
| + |
| + rec := do(t, app, as(httptest.NewRequest(http.MethodGet, historyPath(private), nil), adaCookies)) |
| + if rec.Code != http.StatusNotFound { |
| + t.Fatalf("a guest read another sheet's history: %d", rec.Code) |
| + } |
| + if strings.Contains(rec.Body.String(), "confidential") { |
| + t.Fatal("the refusal leaked a cell's contents") |
| + } |
| +} |
| + |
| +func TestHistoryIsScriptless(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, cells := newSheet(t, app, cookies, "Budget") |
| + postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"value"}`) |
| + |
| + body := withoutScript(historyBody(t, app, s, cookies)) |
| + for _, want := range []string{ |
| + fmt.Sprintf(`action="/sheets/%d/undo"`, s.ID), |
| + fmt.Sprintf(`action="/sheets/%d/restore"`, s.ID), |
| + `name="event"`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("with scripts stripped, %q is gone", want) |
| + } |
| + } |
| +} |
| + |
| +// newestEventID reads the most recent input event's id straight from |
| +// the log. |
| +func newestEventID(t *testing.T, app *App, s SheetSummary) uint64 { |
| + t.Helper() |
| + var id uint64 |
| + err := app.db.Raw( |
| + "SELECT id FROM events WHERE kind = 'input' AND tab_id = ? ORDER BY id DESC LIMIT 1", |
| + s.TabID).Row().Scan(&id) |
| + if err != nil { |
| + t.Fatalf("read newest event: %v", err) |
| + } |
| + return id |
| +} |
diff --git a/internal/sheets/static/grid.js b/internal/sheets/static/grid.js| index d2c182b..53f3123 100644 |
| --- a/internal/sheets/static/grid.js |
| +++ b/internal/sheets/static/grid.js |
| @@ -189,6 +189,28 @@ |
| }); |
| }); |
| + /* Ctrl-Z / Cmd-Z. Own-edits-only and cell-level (spec §7), which |
| + * is the server's rule, not this handler's — it posts to the same |
| + * endpoint the scriptless button does and then refetches, so there |
| + * is one undo and not two that can disagree. |
| + * |
| + * Not bound while a cell editor or the formula bar has focus: there |
| + * Ctrl-Z means "undo my typing", and stealing it would make the |
| + * editor feel broken. */ |
| + document.addEventListener('keydown', function (e) { |
| + if (e.key !== 'z' && e.key !== 'Z') return; |
| + if (!e.ctrlKey && !e.metaKey) return; |
| + var t = e.target; |
| + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; |
| + e.preventDefault(); |
| + fetch('/sheets/' + sheetId + '/undo', { |
| + method: 'POST', |
| + credentials: 'same-origin', |
| + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| + body: '' |
| + }).then(refetch).catch(refetch); |
| + }); |
| + |
| if (!bar) return; |
| bar.addEventListener('keydown', function (e) { |
| if (e.key !== 'Enter' || !selection) return; |
diff --git a/internal/sheets/store/history.go b/internal/sheets/store/history.go| new file mode 100644 |
| index 0000000..0c7f19f |
| --- /dev/null |
| +++ b/internal/sheets/store/history.go |
| @@ -0,0 +1,147 @@ |
| +package store |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| +) |
| + |
| +// InputRecord is one edit, unsealed. |
| +// |
| +// It carries what the person typed, before and after. It does NOT carry |
| +// what the edit computed to, anywhere: derived values are not logged |
| +// (ruling 16), because logging them makes the log grow with the fan-out |
| +// of every edit rather than with the number of edits — in a SQLite file |
| +// Litestream ships in full, continuously. |
| +// |
| +// The accepted cost is written into the spec and shows up here: showing |
| +// or restoring history means replaying inputs and recomputing, which is |
| +// slower than reading stored values and is the only version that stays |
| +// honest at scale. |
| +type InputRecord struct { |
| + ID uint64 |
| + TabID uint64 |
| + RowID, ColID uint64 |
| + ActorID uint64 |
| + OldRaw string |
| + NewRaw string |
| + CreatedAt int64 // unix seconds; the caller formats |
| +} |
| + |
| +// HistoryQuery narrows a history read. |
| +// |
| +// A zero RowID/ColID pair means the whole tab. ActorID narrows to one |
| +// person's edits, which is what own-edits-only undo needs (spec §7). |
| +type HistoryQuery struct { |
| + TabID uint64 |
| + RowID, ColID uint64 |
| + ActorID uint64 |
| + Limit int |
| +} |
| + |
| +// maxHistoryLimit caps a page of history. |
| +// |
| +// The log grows with every edit and is never truncated inside the |
| +// retention horizon, so an unbounded read is an unbounded page. This is |
| +// the same reasoning as the grid's window, applied to time instead of |
| +// space. |
| +const maxHistoryLimit = 200 |
| + |
| +// History reads input events, newest first, unsealing each payload. |
| +// |
| +// Recalc markers are excluded by the Kind filter rather than skipped |
| +// after loading: they carry no payload to unseal, and a history view |
| +// that listed them would show one row per pass saying nothing a person |
| +// can act on. |
| +func History(db *gorm.DB, s *seal.Sealer, q HistoryQuery) ([]InputRecord, error) { |
| + if db == nil || s == nil { |
| + return nil, errors.New("store: history needs a database and a sealer") |
| + } |
| + limit := q.Limit |
| + if limit <= 0 || limit > maxHistoryLimit { |
| + limit = maxHistoryLimit |
| + } |
| + |
| + tx := db.Where("kind = ? AND tab_id = ?", KindInput, q.TabID) |
| + if q.ActorID != 0 { |
| + tx = tx.Where("actor_id = ?", q.ActorID) |
| + } |
| + |
| + // Row and column are inside the SEALED payload, so they cannot be a |
| + // WHERE clause — the database cannot see them, which is the whole |
| + // point of sealing. Narrowing to one cell therefore happens after |
| + // unsealing, and the query over-fetches to compensate. The |
| + // alternative, an unsealed coordinate column, would be a second |
| + // source of truth for where an event points and a plaintext |
| + // shadow of the payload. |
| + fetch := limit |
| + if q.RowID != 0 || q.ColID != 0 { |
| + fetch = maxHistoryLimit |
| + } |
| + |
| + var rows []Event |
| + if err := tx.Order("id DESC").Limit(fetch).Find(&rows).Error; err != nil { |
| + return nil, err |
| + } |
| + |
| + out := make([]InputRecord, 0, len(rows)) |
| + for _, e := range rows { |
| + rec, ok := openInput(s, e) |
| + if !ok { |
| + // An event this key cannot open is skipped, not fatal: one |
| + // unreadable row must not take the whole history with it. |
| + continue |
| + } |
| + if q.RowID != 0 && rec.RowID != q.RowID { |
| + continue |
| + } |
| + if q.ColID != 0 && rec.ColID != q.ColID { |
| + continue |
| + } |
| + out = append(out, rec) |
| + if len(out) == limit { |
| + break |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// EventByID reads one input event, unsealed, scoped to a tab. |
| +// |
| +// Scoped deliberately: the id comes from a URL, and without the tab in |
| +// the WHERE clause a caller could name any event on the instance and |
| +// have it applied to a sheet they can reach. |
| +func EventByID(db *gorm.DB, s *seal.Sealer, tabID, id uint64) (InputRecord, bool) { |
| + if db == nil || s == nil { |
| + return InputRecord{}, false |
| + } |
| + var e Event |
| + if err := db.Where("id = ? AND tab_id = ? AND kind = ?", id, tabID, KindInput). |
| + First(&e).Error; err != nil { |
| + return InputRecord{}, false |
| + } |
| + return openInput(s, e) |
| +} |
| + |
| +// openInput unseals one event's payload. |
| +func openInput(s *seal.Sealer, e Event) (InputRecord, bool) { |
| + body, err := s.Open("event.payload", e.PayloadSealed) |
| + if err != nil { |
| + return InputRecord{}, false |
| + } |
| + var p inputPayload |
| + if err := json.Unmarshal(body, &p); err != nil { |
| + return InputRecord{}, false |
| + } |
| + return InputRecord{ |
| + ID: e.ID, TabID: e.TabID, |
| + RowID: p.RowID, ColID: p.ColID, |
| + ActorID: e.ActorID, |
| + OldRaw: p.OldRaw, |
| + NewRaw: p.NewRaw, |
| + CreatedAt: e.CreatedAt.Unix(), |
| + }, true |
| +} |
diff --git a/internal/sheets/templates/history.html b/internal/sheets/templates/history.html| new file mode 100644 |
| index 0000000..d185d32 |
| --- /dev/null |
| +++ b/internal/sheets/templates/history.html |
| @@ -0,0 +1,72 @@ |
| +{{define "content"}} |
| +<header rst-page-header> |
| +<div rst-page-header-titles> |
| +<h1>History — {{.Data.Sheet.Title}}</h1> |
| +<p rst-page-header-sub>Every edit somebody typed, newest first.</p> |
| +</div> |
| +<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}">Back to the sheet</a> |
| +</header> |
| + |
| +{{with .Data.Notice}} |
| +<div rst-callout><div rst-callout-body><p>{{.}}</p></div></div> |
| +{{end}} |
| + |
| +{{if .Data.MayWrite}} |
| +<form method="post" action="/sheets/{{.Data.Sheet.ID}}/undo"> |
| +<button rst-btn="primary" type="submit">Undo my last edit</button> |
| +</form> |
| +<p rst-help>Undo only ever touches your own edits, one cell at a time. If |
| +somebody has written over that cell since, your undo takes it back — and |
| +you can undo that too.</p> |
| +{{end}} |
| + |
| +{{/* Edits, not changed cells. The log holds inputs and never derived |
| + values (spec §4.3, ruling 16), so an edit that recalculated four |
| + hundred cells appears here once — which is both what the storage |
| + can support and what a person wants to read. */}} |
| +{{if .Data.Entries}} |
| +<div style="overflow-x:auto"> |
| +<table data-cell-table> |
| +<thead> |
| +<tr> |
| +<th scope="col">Cell</th> |
| +<th scope="col">Was</th> |
| +<th scope="col">Became</th> |
| +<th scope="col">Who</th> |
| +<th scope="col">When</th> |
| +{{if .Data.MayWrite}}<th scope="col"><span class="rst-sr-only">Put back</span></th>{{end}} |
| +</tr> |
| +</thead> |
| +<tbody> |
| +{{$sheet := .Data.Sheet}} |
| +{{$mayWrite := .Data.MayWrite}} |
| +{{range .Data.Entries}} |
| +<tr> |
| +<th scope="row">{{.Ref}}</th> |
| +<td>{{if .Old}}<code>{{.Old}}</code>{{else}}<span rst-row-sub>empty</span>{{end}}</td> |
| +<td>{{if .Cleared}}<span rst-row-sub>cleared</span>{{else}}<code>{{.New}}</code>{{end}}</td> |
| +<td>{{.Who}}{{if .Mine}} <span rst-badge="neutral">you</span>{{end}}</td> |
| +<td>{{.When.Format "2 Jan 2006, 15:04"}}</td> |
| +{{if $mayWrite}} |
| +<td> |
| +{{/* Restoring is a fresh write, not a rewind: it appears here as |
| + an edit by whoever did it and is itself undoable. A rewind |
| + would make this log disagree with the sheet. */}} |
| +<form method="post" action="/sheets/{{$sheet.ID}}/restore"> |
| +<input type="hidden" name="event" value="{{.ID}}"> |
| +<button rst-btn="ghost" type="submit">Put back</button> |
| +</form> |
| +</td> |
| +{{end}} |
| +</tr> |
| +{{end}} |
| +</tbody> |
| +</table> |
| +</div> |
| +{{else}} |
| +<div rst-empty> |
| +<p rst-empty-title>Nothing yet</p> |
| +<p rst-empty-body>Edits show up here as soon as somebody types into a cell.</p> |
| +</div> |
| +{{end}} |
| +{{end}} |
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html| index 56aa0d7..1175dc8 100644 |
| --- a/internal/sheets/templates/sheet.html |
| +++ b/internal/sheets/templates/sheet.html |
| @@ -40,6 +40,7 @@ |
| <button rst-btn="ghost" type="button" data-action="fill-down" hidden>Fill down</button> |
| <button rst-btn="ghost" type="button" data-action="fill-right" hidden>Fill right</button> |
| <a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/table">Table view</a> |
| +<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/history">History</a> |
| {{if .Data.MayShare}}<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/share">Share</a>{{end}} |
| <span data-sheet-live data-state="off" role="status">Connecting</span> |
| </div> |
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html| index 466f87e..c01cbd4 100644 |
| --- a/internal/sheets/templates/table.html |
| +++ b/internal/sheets/templates/table.html |
| @@ -5,6 +5,7 @@ |
| <p rst-page-header-sub>Table view — rows {{.Data.Page}} of {{.Data.Pages}} pages. |
| Every cell is editable here, with no JavaScript at all.</p> |
| </div> |
| +<a rst-btn="ghost" href="/sheets/{{.Data.Sheet.ID}}/history">History</a> |
| <a rst-btn="primary" href="/sheets/{{.Data.Sheet.ID}}">Open the grid</a> |
| </header> |