oficina / sheets Public

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
Paul Campbell pushed by paul@keymail.dev b79c311c4e4360083783f7d148c410986e82a2ff parent 8f7b4a0
8 files changed, +693 −0
  • internal/sheets/app.go +6 −0
  • internal/sheets/handlers_history.go +228 −0
  • internal/sheets/handlers_history_test.go +216 −0
  • internal/sheets/static/grid.js +22 −0
  • internal/sheets/store/history.go +147 −0
  • internal/sheets/templates/history.html +72 −0
  • internal/sheets/templates/sheet.html +1 −0
  • internal/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>