| 1 | package store |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | |
| 7 | "gorm.io/gorm" |
| 8 | |
| 9 | "amadan.net/oficina/sheets/internal/sheets/seal" |
| 10 | ) |
| 11 | |
| 12 | // InputRecord is one edit, unsealed. |
| 13 | // |
| 14 | // It carries what the person typed, before and after. It does NOT carry |
| 15 | // what the edit computed to, anywhere: derived values are not logged |
| 16 | // (ruling 16), because logging them makes the log grow with the fan-out |
| 17 | // of every edit rather than with the number of edits — in a SQLite file |
| 18 | // Litestream ships in full, continuously. |
| 19 | // |
| 20 | // The accepted cost is written into the spec and shows up here: showing |
| 21 | // or restoring history means replaying inputs and recomputing, which is |
| 22 | // slower than reading stored values and is the only version that stays |
| 23 | // honest at scale. |
| 24 | type InputRecord struct { |
| 25 | ID uint64 |
| 26 | TabID uint64 |
| 27 | RowID, ColID uint64 |
| 28 | ActorID uint64 |
| 29 | OldRaw string |
| 30 | NewRaw string |
| 31 | CreatedAt int64 // unix seconds; the caller formats |
| 32 | } |
| 33 | |
| 34 | // HistoryQuery narrows a history read. |
| 35 | // |
| 36 | // A zero RowID/ColID pair means the whole tab. ActorID narrows to one |
| 37 | // person's edits, which is what own-edits-only undo needs (spec §7). |
| 38 | type HistoryQuery struct { |
| 39 | TabID uint64 |
| 40 | RowID, ColID uint64 |
| 41 | ActorID uint64 |
| 42 | Limit int |
| 43 | } |
| 44 | |
| 45 | // maxHistoryLimit caps a page of history. |
| 46 | // |
| 47 | // The log grows with every edit and is never truncated inside the |
| 48 | // retention horizon, so an unbounded read is an unbounded page. This is |
| 49 | // the same reasoning as the grid's window, applied to time instead of |
| 50 | // space. |
| 51 | const maxHistoryLimit = 200 |
| 52 | |
| 53 | // History reads input events, newest first, unsealing each payload. |
| 54 | // |
| 55 | // Recalc markers are excluded by the Kind filter rather than skipped |
| 56 | // after loading: they carry no payload to unseal, and a history view |
| 57 | // that listed them would show one row per pass saying nothing a person |
| 58 | // can act on. |
| 59 | func History(db *gorm.DB, s *seal.Sealer, q HistoryQuery) ([]InputRecord, error) { |
| 60 | if db == nil || s == nil { |
| 61 | return nil, errors.New("store: history needs a database and a sealer") |
| 62 | } |
| 63 | limit := q.Limit |
| 64 | if limit <= 0 || limit > maxHistoryLimit { |
| 65 | limit = maxHistoryLimit |
| 66 | } |
| 67 | |
| 68 | tx := db.Where("kind = ? AND tab_id = ?", KindInput, q.TabID) |
| 69 | if q.ActorID != 0 { |
| 70 | tx = tx.Where("actor_id = ?", q.ActorID) |
| 71 | } |
| 72 | |
| 73 | // Row and column are inside the SEALED payload, so they cannot be a |
| 74 | // WHERE clause — the database cannot see them, which is the whole |
| 75 | // point of sealing. Narrowing to one cell therefore happens after |
| 76 | // unsealing, and the query over-fetches to compensate. The |
| 77 | // alternative, an unsealed coordinate column, would be a second |
| 78 | // source of truth for where an event points and a plaintext |
| 79 | // shadow of the payload. |
| 80 | fetch := limit |
| 81 | if q.RowID != 0 || q.ColID != 0 { |
| 82 | fetch = maxHistoryLimit |
| 83 | } |
| 84 | |
| 85 | var rows []Event |
| 86 | if err := tx.Order("id DESC").Limit(fetch).Find(&rows).Error; err != nil { |
| 87 | return nil, err |
| 88 | } |
| 89 | |
| 90 | out := make([]InputRecord, 0, len(rows)) |
| 91 | for _, e := range rows { |
| 92 | rec, ok := openInput(s, e) |
| 93 | if !ok { |
| 94 | // An event this key cannot open is skipped, not fatal: one |
| 95 | // unreadable row must not take the whole history with it. |
| 96 | continue |
| 97 | } |
| 98 | if q.RowID != 0 && rec.RowID != q.RowID { |
| 99 | continue |
| 100 | } |
| 101 | if q.ColID != 0 && rec.ColID != q.ColID { |
| 102 | continue |
| 103 | } |
| 104 | out = append(out, rec) |
| 105 | if len(out) == limit { |
| 106 | break |
| 107 | } |
| 108 | } |
| 109 | return out, nil |
| 110 | } |
| 111 | |
| 112 | // EventByID reads one input event, unsealed, scoped to a tab. |
| 113 | // |
| 114 | // Scoped deliberately: the id comes from a URL, and without the tab in |
| 115 | // the WHERE clause a caller could name any event on the instance and |
| 116 | // have it applied to a sheet they can reach. |
| 117 | func EventByID(db *gorm.DB, s *seal.Sealer, tabID, id uint64) (InputRecord, bool) { |
| 118 | if db == nil || s == nil { |
| 119 | return InputRecord{}, false |
| 120 | } |
| 121 | var e Event |
| 122 | if err := db.Where("id = ? AND tab_id = ? AND kind = ?", id, tabID, KindInput). |
| 123 | First(&e).Error; err != nil { |
| 124 | return InputRecord{}, false |
| 125 | } |
| 126 | return openInput(s, e) |
| 127 | } |
| 128 | |
| 129 | // openInput unseals one event's payload. |
| 130 | func openInput(s *seal.Sealer, e Event) (InputRecord, bool) { |
| 131 | body, err := s.Open("event.payload", e.PayloadSealed) |
| 132 | if err != nil { |
| 133 | return InputRecord{}, false |
| 134 | } |
| 135 | var p inputPayload |
| 136 | if err := json.Unmarshal(body, &p); err != nil { |
| 137 | return InputRecord{}, false |
| 138 | } |
| 139 | return InputRecord{ |
| 140 | ID: e.ID, TabID: e.TabID, |
| 141 | RowID: p.RowID, ColID: p.ColID, |
| 142 | ActorID: e.ActorID, |
| 143 | OldRaw: p.OldRaw, |
| 144 | NewRaw: p.NewRaw, |
| 145 | CreatedAt: e.CreatedAt.Unix(), |
| 146 | }, true |
| 147 | } |
| 148 | |