oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package store
2
3import (
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.
24type 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).
38type 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.
51const 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.
59func 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.
117func 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.
130func 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