oficina / sheets Public

feat: read a grid window and write a cell

The models move from package sheets to package store. store imported
sheets for those structs, so sheets could not import store back — and
every route that reads a cell is in package sheets. Putting them where
the persistence is removes the cycle rather than routing around it, and
it is where models.go's own comment on Event already said they
belonged. Member stays behind: who may hold a session is an app
concern, not a storage one.

Three store tests moved from the internal test package to the external
one, because they migrate with sheets.BootSchema and that is now a
cycle. export_test.go carries the seams they need. The seams stay
seams: what those tests prove is that the REAL code path is
crash-safe, and a test driving a parallel exported implementation would
prove nothing about the one that ships.

TestNoPlaintextValueColumns now walks sheets.Models instead of a list
of its own. Models is what `rastrillo migration check` reads, so it
cannot be quietly incomplete — a second list here could omit a new
table, which is exactly what a plaintext column would need in order to
arrive unnoticed.

The API itself: R and C on the wire are grid positions, not stable ids,
translated in one place rather than leaked into JavaScript. Raw and
Display are both sent — a client with only Display overwrites a formula
in the editor the moment somebody else's edit lands, and a client with
only Raw would have to evaluate formulas itself, which is the one thing
this design will not have it do. A too-wide window clamps rather than
400s: refusing turns a client bug into a blank page. No error quotes
what was typed.
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 8cf93630bda1855cb7fc567b7cbce1ddf83cfe4e parent 58ee7dd
27 files changed, +1024 −297
  • internal/sheets/app.go +7 −0
  • internal/sheets/grid.go +172 −0
  • internal/sheets/handlers_grid.go +187 −0
  • internal/sheets/handlers_grid_test.go +291 −0
  • internal/sheets/migrations.go +10 −16
  • internal/sheets/models.go +0 −100
  • internal/sheets/models_test.go +37 −26
  • internal/sheets/sheetsvc.go +10 −8
  • internal/sheets/store/events.go +0 −13
  • internal/sheets/store/export_test.go +52 −0
  • internal/sheets/store/handle.go +4 −5
  • internal/sheets/store/handle_blank_cap_test.go +21 −20
  • internal/sheets/store/handle_blank_crash_test.go +19 −18
  • internal/sheets/store/handle_blank_recovery_bench_test.go +24 −23
  • internal/sheets/store/handle_gate_test.go +3 −4
  • internal/sheets/store/handle_grid.go +13 −15
  • internal/sheets/store/handle_grid_race_test.go +6 −7
  • internal/sheets/store/handle_grid_test.go +4 −5
  • internal/sheets/store/handle_more_test.go +11 −12
  • internal/sheets/store/handle_open.go +3 −4
  • internal/sheets/store/handle_report.go +14 −0
  • internal/sheets/store/handle_set.go +4 −5
  • internal/sheets/store/handle_test.go +6 −7
  • internal/sheets/store/models.go +118 −0
  • internal/sheets/xlsx/format.go +5 −5
  • internal/sheets/xlsx/helpers_test.go +2 −2
  • internal/sheets/xlsx/roundtrip_test.go +1 −2

CI passed — run details

diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index 4ea351a..95f9b2a 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -130,6 +130,13 @@ func (a *App) Router() http.Handler {
r.Get("/sheets", a.handleSheetList)
r.Post("/sheets", a.handleSheetCreate)
r.Get("/sheets/{sheetID}", a.handleSheetPage)
+
+ // The grid's own API. Under /api because it answers JSON and
+ // carries no chrome; behind the same session as everything
+ // else, because a sheet is a sheet whichever content type you
+ // ask for it in.
+ r.Get("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellsGet)
+ r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellPost)
})
return r
}
diff --git a/internal/sheets/grid.go b/internal/sheets/grid.go
new file mode 100644
index 0000000..21a1376
--- /dev/null
+++ b/internal/sheets/grid.go
@@ -0,0 +1,172 @@
+package sheets
+
+import (
+ "encoding/json"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+ "amadan.net/oficina/sheets/internal/sheets/format"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// gridCell is one cell on the wire.
+//
+// R and C are grid POSITIONS, not stable ids. The client thinks in
+// coordinates — it is painting a grid — and stable ids are a storage
+// concern it has no use for. The translation happens here, in one
+// place, rather than leaking row and column ids into JavaScript where
+// they would have to be kept in sync with every insert.
+//
+// Raw and Display are both sent, always. A client with only Display
+// replaces "=A1*2" with "42" in the editor the moment somebody else's
+// edit arrives; a client with only Raw has to evaluate formulas itself,
+// which is the one thing this design will not have it do.
+type gridCell struct {
+ R int `json:"r"`
+ C int `json:"c"`
+ Raw string `json:"raw"`
+ Display string `json:"display"`
+ Derived bool `json:"derived,omitempty"`
+}
+
+// gridResponse is what both GET and POST answer, and what the SSE
+// stream carries. One shape, so the client has one function that
+// applies a batch of cells and does not care where it came from.
+//
+// Version orders paints (spec §7). Resync tells a client that this
+// response is not the whole truth and it should refetch its window —
+// set when a write's dependent set was too wide to recalculate
+// synchronously.
+//
+// Client is the id the poster sent, echoed back so a tab can skip its
+// own edit arriving over the stream. Without it, the tab you typed in
+// repaints the cell you are still editing.
+type gridResponse struct {
+ Version uint64 `json:"version"`
+ Rows int `json:"rows"`
+ Cols int `json:"cols"`
+ Cells []gridCell `json:"cells"`
+ Resync bool `json:"resync,omitempty"`
+ Client string `json:"client,omitempty"`
+}
+
+// display turns a computed value into the string the grid paints.
+//
+// The server owns every string on screen. That is spec §12's gate — at
+// least one check crossing from computed value to painted output — and
+// it is also what keeps the client from needing a number-format
+// implementation that could disagree with the exporter's.
+func display(v calc.Value, f *store.Format) string {
+ numFmt := ""
+ if f != nil {
+ numFmt = f.NumberFmt
+ }
+ return format.Display(v, numFmt)
+}
+
+// posIndex maps stable ids to grid positions, both ways round.
+//
+// Built once per request from the handle's own row and column records.
+// A map rather than a scan because a 100×26 window asks 2,600 times and
+// a linear scan per cell turns an O(n) render into O(n²).
+type posIndex struct {
+ rowPos map[uint64]int
+ colPos map[uint64]int
+ rowID map[int]uint64
+ colID map[int]uint64
+}
+
+func newPosIndex(h *store.Handle) posIndex {
+ idx := posIndex{
+ rowPos: map[uint64]int{}, colPos: map[uint64]int{},
+ rowID: map[int]uint64{}, colID: map[int]uint64{},
+ }
+ for _, r := range h.GridRows() {
+ idx.rowPos[r.ID] = r.Pos
+ idx.rowID[r.Pos] = r.ID
+ }
+ for _, c := range h.GridCols() {
+ idx.colPos[c.ID] = c.Pos
+ idx.colID[c.Pos] = c.ID
+ }
+ return idx
+}
+
+// snapshot reads every cell inside a window.
+//
+// Sparse: only cells that exist are sent. A blank cell is absent from
+// the response rather than present and empty, which is the difference
+// between a 100×26 window costing two kilobytes and costing sixty.
+func snapshot(h *store.Handle, idx posIndex, rows, cols int) ([]gridCell, error) {
+ coords, err := h.Cells()
+ if err != nil {
+ return nil, err
+ }
+ out := make([]gridCell, 0, len(coords))
+ for _, c := range coords {
+ r, ok := idx.rowPos[c.RowID]
+ if !ok || r >= rows {
+ continue
+ }
+ col, ok := idx.colPos[c.ColID]
+ if !ok || col >= cols {
+ continue
+ }
+ raw, _ := h.RawInput(c.RowID, c.ColID)
+ out = append(out, gridCell{
+ R: r, C: col,
+ Raw: raw,
+ Display: display(h.Value(c.RowID, c.ColID), h.Format(c.RowID, c.ColID)),
+ })
+ }
+ return out, nil
+}
+
+// fromReport turns a write's report into wire cells.
+//
+// A reported cell whose row or column is somehow not in the index is
+// dropped rather than sent with a wrong coordinate: a cell painted in
+// the wrong place is worse than a cell not painted, because nothing
+// later corrects it.
+func fromReport(rep store.Report, idx posIndex, h *store.Handle) []gridCell {
+ out := make([]gridCell, 0, len(rep.Cells))
+ for _, c := range rep.Cells {
+ r, ok := idx.rowPos[c.RowID]
+ if !ok {
+ continue
+ }
+ col, ok := idx.colPos[c.ColID]
+ if !ok {
+ continue
+ }
+ out = append(out, gridCell{
+ R: r, C: col,
+ Raw: c.Raw,
+ Display: display(c.Value, h.Format(c.RowID, c.ColID)),
+ Derived: c.Derived,
+ })
+ }
+ return out
+}
+
+// clamp bounds a requested window.
+//
+// Clamped, not refused: a request for a million rows is a client bug or
+// a curious operator, and answering 400 turns either into a blank page
+// where answering with 500 rows turns it into a working one.
+func clamp(want, def, max int) int {
+ if want <= 0 {
+ return def
+ }
+ if want > max {
+ return max
+ }
+ return want
+}
+
+// marshalGrid encodes a response for the wire.
+//
+// Errors are impossible for this shape — every field is a string, an
+// int or a bool — but the signature keeps the caller honest rather than
+// hiding a discarded error behind an underscore at the point where cell
+// content would be the thing lost.
+func marshalGrid(resp gridResponse) ([]byte, error) { return json.Marshal(resp) }
diff --git a/internal/sheets/handlers_grid.go b/internal/sheets/handlers_grid.go
new file mode 100644
index 0000000..6c95a96
--- /dev/null
+++ b/internal/sheets/handlers_grid.go
@@ -0,0 +1,187 @@
+package sheets
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/oficina/sheets/internal/sheets/live"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// maxRawBytes caps one cell's input.
+//
+// A formula or a label, not a document. The engine would happily parse
+// a megabyte of nesting and spend a long time doing it, and the cell
+// that holds it is one nobody can read anyway.
+const maxRawBytes = 8 << 10
+
+// cellWrite is the POST body.
+//
+// An explicit struct, decoded field by field — never bound onto a GORM
+// model (AGENTS.md), which would let a request set UpdatedBy, a version
+// or a tab id.
+type cellWrite struct {
+ R int `json:"r"`
+ C int `json:"c"`
+ Raw string `json:"raw"`
+ Client string `json:"client"`
+}
+
+// handleCellsGet serves a window of a tab.
+func (a *App) handleCellsGet(w http.ResponseWriter, r *http.Request) {
+ h, ok := a.openTab(w, r)
+ if !ok {
+ return
+ }
+ defer h.Release()
+
+ rows := clamp(intParam(r, "rows"), defaultWindowRows, maxWindowRows)
+ cols := clamp(intParam(r, "cols"), defaultWindowCols, maxWindowCols)
+
+ cells, err := snapshot(h, newPosIndex(h), rows, cols)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, gridResponse{
+ Version: h.Version(), Rows: rows, Cols: cols, Cells: cells,
+ })
+}
+
+// handleCellPost writes one cell and fans the result out.
+func (a *App) handleCellPost(w http.ResponseWriter, r *http.Request) {
+ actor, ok := a.actor(r)
+ if !ok {
+ http.Error(w, "Sign in first.", http.StatusForbidden)
+ return
+ }
+ h, ok := a.openTab(w, r)
+ if !ok {
+ return
+ }
+ defer h.Release()
+
+ var in cellWrite
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRawBytes*2)).Decode(&in); err != nil {
+ // The body is not echoed. It is a cell's contents, and an error
+ // string is a log line waiting to happen (AGENTS.md).
+ http.Error(w, "Malformed request.", http.StatusBadRequest)
+ return
+ }
+ if in.R < 0 || in.C < 0 || in.R >= maxWindowRows*10 || in.C >= maxWindowCols*10 {
+ http.Error(w, "Cell out of range.", http.StatusBadRequest)
+ return
+ }
+ if len(in.Raw) > maxRawBytes {
+ http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge)
+ return
+ }
+
+ // EnsureRow/EnsureCol so typing into a cell nothing has touched
+ // works: a tab starts with no rows or columns at all, and a grid
+ // coordinate has to become a stable id before anything can be
+ // written to it.
+ rowID, err := h.EnsureRow(in.R)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ colID, err := h.EnsureCol(in.C)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+
+ rep, err := h.SetReport(rowID, colID, in.Raw, actor.ID)
+ if err != nil {
+ // A parse failure is the caller's fault and a storage failure is
+ // ours, but neither may quote the input, so both answer the same
+ // fixed string with the coordinate the caller already knows.
+ http.Error(w, "That cell could not be written.", http.StatusBadRequest)
+ return
+ }
+
+ resp := gridResponse{
+ Version: rep.Version,
+ Cells: fromReport(rep, newPosIndex(h), h),
+ Resync: rep.Capped,
+ Client: in.Client,
+ }
+ a.touchSheet(sheetIDFrom(r))
+ a.publish(tabIDFrom(r), resp)
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// publish fans a write out to every other tab on this one.
+//
+// Marshalled once here rather than once per subscriber: with ten tabs
+// open, encoding per subscriber is ten encodings of identical bytes.
+func (a *App) publish(tabID uint64, resp gridResponse) {
+ if a.hub == nil || tabID == 0 {
+ return
+ }
+ data, err := marshalGrid(resp)
+ if err != nil {
+ return
+ }
+ a.hub.Publish(tabID, live.Message{Version: resp.Version, Data: data})
+}
+
+// openTab resolves {sheetID}/{tabID} and opens the tab's handle,
+// answering for itself if it cannot. The bool is whether the caller
+// should carry on.
+//
+// The tab is checked against the sheet, not just looked up: both ids
+// come from the URL, so without the pairing check any tab could be read
+// through any sheet, and the sheet id in the path would mean nothing.
+func (a *App) openTab(w http.ResponseWriter, r *http.Request) (*store.Handle, bool) {
+ if a.db == nil || a.sealer == nil {
+ a.fail(w, r, errNoSealer)
+ return nil, false
+ }
+ sheetID, tabID := sheetIDFrom(r), tabIDFrom(r)
+ if sheetID == 0 || tabID == 0 || !a.tabBelongsTo(sheetID, tabID) {
+ a.notFound(w, r)
+ return nil, false
+ }
+ h, err := store.Open(a.db, a.sealer, tabID)
+ if err != nil {
+ a.fail(w, r, err)
+ return nil, false
+ }
+ return h, true
+}
+
+func sheetIDFrom(r *http.Request) uint64 { return uintParam(r, "sheetID") }
+func tabIDFrom(r *http.Request) uint64 { return uintParam(r, "tabID") }
+
+// uintParam reads a path parameter. A malformed one is 0, which every
+// caller treats as "not found" — a URL that was never ours.
+func uintParam(r *http.Request, name string) uint64 {
+ v, err := strconv.ParseUint(chi.URLParam(r, name), 10, 64)
+ if err != nil {
+ return 0
+ }
+ return v
+}
+
+// intParam reads a query parameter, defaulting to 0 so clamp can apply
+// the real default in one place.
+func intParam(r *http.Request, name string) int {
+ v, err := strconv.Atoi(r.URL.Query().Get(name))
+ if err != nil {
+ return 0
+ }
+ return v
+}
+
+// writeJSON sends a response. An encode failure means the connection is
+// already gone, so there is nothing left to say about it.
+func writeJSON(w http.ResponseWriter, status int, resp gridResponse) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(resp)
+}
diff --git a/internal/sheets/handlers_grid_test.go b/internal/sheets/handlers_grid_test.go
new file mode 100644
index 0000000..8eeaa34
--- /dev/null
+++ b/internal/sheets/handlers_grid_test.go
@@ -0,0 +1,291 @@
+package sheets
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// newSheet creates a sheet through the real HTTP route and returns the
+// cells endpoint for its first tab.
+func newSheet(t *testing.T, app *App, cookies []*http.Cookie, title string) (SheetSummary, string) {
+ t.Helper()
+ rec := do(t, app, as(postForm("/sheets", map[string][]string{"title": {title}}), cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("creating a sheet = %d: %s", rec.Code, rec.Body.String())
+ }
+ list, err := app.listSheets()
+ if err != nil {
+ t.Fatalf("listSheets: %v", err)
+ }
+ if len(list) == 0 {
+ t.Fatal("no sheet was created")
+ }
+ s := list[0]
+ return s, fmt.Sprintf("/api/sheets/%d/tabs/%d/cells", s.ID, s.TabID)
+}
+
+func postCell(t *testing.T, app *App, cookies []*http.Cookie, path, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ return do(t, app, as(req, cookies))
+}
+
+func decodeGrid(t *testing.T, rec *httptest.ResponseRecorder) gridResponse {
+ t.Helper()
+ var g gridResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &g); err != nil {
+ t.Fatalf("decode grid response: %v (body %q)", err, rec.Body.String())
+ }
+ return g
+}
+
+func TestPostACellThenGetItBack(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=1+1"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("POST cell = %d: %s", rec.Code, rec.Body.String())
+ }
+ wrote := decodeGrid(t, rec)
+ if len(wrote.Cells) != 1 {
+ t.Fatalf("POST returned %d cells, want 1: %+v", len(wrote.Cells), wrote.Cells)
+ }
+ if wrote.Cells[0].Display != "2" {
+ t.Errorf("display = %q, want %q — the server owns every painted string", wrote.Cells[0].Display, "2")
+ }
+
+ read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ if len(read.Cells) != 1 {
+ t.Fatalf("GET returned %d cells, want 1", len(read.Cells))
+ }
+ // Both must survive a reload: the editor shows Raw, the grid paints
+ // Display, and a client with only one of them is broken in a way
+ // that only shows up when somebody clicks the cell.
+ if read.Cells[0].Raw != "=1+1" || read.Cells[0].Display != "2" {
+ t.Fatalf("after reload: %+v", read.Cells[0])
+ }
+}
+
+// A formula's dependents must come back with the edit that changed
+// them, or the other tab paints a stale number forever.
+func TestPostACellReturnsItsDependents(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ postCell(t, app, cookies, cells, `{"r":1,"c":0,"raw":"=A1*2"}`)
+ rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"21"}`)
+ got := decodeGrid(t, rec)
+
+ if len(got.Cells) != 2 {
+ t.Fatalf("writing a feeder returned %d cells, want 2: %+v", len(got.Cells), got.Cells)
+ }
+ var derived *gridCell
+ for i := range got.Cells {
+ if got.Cells[i].Derived {
+ derived = &got.Cells[i]
+ }
+ }
+ if derived == nil {
+ t.Fatal("no cell is flagged derived")
+ }
+ if derived.Display != "42" {
+ t.Errorf("the dependent displays %q, want %q", derived.Display, "42")
+ }
+}
+
+func TestPostACellClearsIt(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`)
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":""}`)
+
+ read := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ for _, c := range read.Cells {
+ if c.R == 0 && c.C == 0 && c.Display != "" {
+ t.Fatalf("a cleared cell still reads %q", c.Display)
+ }
+ }
+}
+
+// The version is what a client orders paints by. A snapshot taken after
+// an edit must not carry a version below that edit's, or the client
+// discards the edit it just fetched.
+func TestVersionAdvancesAcrossWrites(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ first := decodeGrid(t, postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"1"}`))
+ second := decodeGrid(t, postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"2"}`))
+ if second.Version <= first.Version {
+ t.Fatalf("version did not advance: %d then %d", first.Version, second.Version)
+ }
+ snap := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ if snap.Version <= second.Version {
+ t.Fatalf("a snapshot after the last write carries version %d, not above %d — "+
+ "a client would discard the edit it just fetched", snap.Version, second.Version)
+ }
+}
+
+// Spec §6's cap reasoning, enforced at the boundary rather than
+// trusted: a request for a million rows must clamp, not allocate.
+func TestGridWindowIsClampedNotRefused(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ rec := do(t, app, as(httptest.NewRequest(http.MethodGet, cells+"?rows=999999&cols=999999", nil), cookies))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("a too-wide window = %d, want 200 — clamping beats refusing", rec.Code)
+ }
+ g := decodeGrid(t, rec)
+ if g.Rows != maxWindowRows || g.Cols != maxWindowCols {
+ t.Fatalf("window = %dx%d, want the caps %dx%d", g.Rows, g.Cols, maxWindowRows, maxWindowCols)
+ }
+}
+
+func TestGridWindowDefaultsWhenUnasked(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+ g := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells, nil), cookies)))
+ if g.Rows != defaultWindowRows || g.Cols != defaultWindowCols {
+ t.Fatalf("window = %dx%d, want the defaults %dx%d",
+ g.Rows, g.Cols, defaultWindowRows, defaultWindowCols)
+ }
+}
+
+// A cell outside the window must not be sent. Without the filter the
+// endpoint would hand back a whole tab whenever anybody asked for a
+// corner of it.
+func TestCellsOutsideTheWindowAreNotSent(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"near"}`)
+ postCell(t, app, cookies, cells, `{"r":80,"c":0,"raw":"far"}`)
+
+ g := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, cells+"?rows=10&cols=5", nil), cookies)))
+ for _, c := range g.Cells {
+ if c.R >= 10 || c.C >= 5 {
+ t.Fatalf("a cell at (%d,%d) escaped a 10x5 window", c.R, c.C)
+ }
+ }
+ if len(g.Cells) != 1 {
+ t.Fatalf("window holds %d cells, want 1", len(g.Cells))
+ }
+}
+
+// The one rule, at the last layer that could break it: an error must
+// never quote what somebody typed.
+func TestNoCellContentInAnErrorResponse(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ const secret = "SalariesAreConfidential"
+ rec := postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=SUM(("`+secret+`"}`)
+ if rec.Code == http.StatusOK {
+ t.Fatalf("an unparseable body was accepted: %s", rec.Body.String())
+ }
+ if strings.Contains(rec.Body.String(), secret) {
+ t.Fatalf("the error response quotes the cell's contents: %s", rec.Body.String())
+ }
+}
+
+func TestUnparseableFormulaIsRefusedWithoutQuotingIt(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ const secret = "PayrollNumbers"
+ body, _ := json.Marshal(cellWrite{R: 0, C: 0, Raw: "=SUM(" + secret})
+ rec := postCell(t, app, cookies, cells, string(body))
+ if rec.Code == http.StatusOK {
+ t.Fatal("an unparseable formula was accepted")
+ }
+ if strings.Contains(rec.Body.String(), secret) {
+ t.Fatalf("the refusal quotes the formula: %s", rec.Body.String())
+ }
+}
+
+// Both ids come from the URL. Without the pairing check, any tab could
+// be read through any sheet and the sheet id would mean nothing.
+func TestATabFromAnotherSheetIs404(t *testing.T) {
+ app, cookies := signIn(t)
+ one, _ := newSheet(t, app, cookies, "One")
+ two, _ := newSheet(t, app, cookies, "Two")
+
+ path := fmt.Sprintf("/api/sheets/%d/tabs/%d/cells", one.ID, two.TabID)
+ if rec := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies)); rec.Code != http.StatusNotFound {
+ t.Fatalf("reading a tab through the wrong sheet = %d, want 404", rec.Code)
+ }
+}
+
+func TestGridAPIRequiresASession(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ if rec := do(t, app, httptest.NewRequest(http.MethodGet, cells, nil)); rec.Code == http.StatusOK {
+ t.Error("the grid API served a window with no session")
+ }
+ if rec := postCell(t, app, nil, cells, `{"r":0,"c":0,"raw":"1"}`); rec.Code == http.StatusOK {
+ t.Error("the grid API accepted a write with no session")
+ }
+}
+
+func TestOverlongCellInputIsRefused(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+
+ body, _ := json.Marshal(cellWrite{R: 0, C: 0, Raw: strings.Repeat("x", maxRawBytes+1)})
+ if rec := postCell(t, app, cookies, cells, string(body)); rec.Code == http.StatusOK {
+ t.Fatal("a cell larger than the cap was accepted")
+ }
+}
+
+func TestNegativeCoordinatesAreRefused(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+ if rec := postCell(t, app, cookies, cells, `{"r":-1,"c":0,"raw":"1"}`); rec.Code != http.StatusBadRequest {
+ t.Fatalf("a negative row = %d, want 400", rec.Code)
+ }
+}
+
+// The poster's client id comes back so its own edit arriving over the
+// stream can be skipped. Without it the tab you typed in repaints the
+// cell you are still editing.
+func TestClientIDIsEchoed(t *testing.T) {
+ app, cookies := signIn(t)
+ _, cells := newSheet(t, app, cookies, "Budget")
+ g := decodeGrid(t, postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"1","client":"tab-abc"}`))
+ if g.Client != "tab-abc" {
+ t.Fatalf("client = %q, want %q", g.Client, "tab-abc")
+ }
+}
+
+// A write must reach every other open tab. This is the whole point of
+// the feature, asserted at the layer below the browser.
+func TestAWriteReachesASubscriber(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+
+ sub := app.hub.Subscribe(s.TabID)
+ defer sub.Close()
+
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"=6*7"}`)
+
+ select {
+ case m := <-sub.C():
+ var g gridResponse
+ if err := json.Unmarshal(m.Data, &g); err != nil {
+ t.Fatalf("the fanned-out payload does not decode: %v", err)
+ }
+ if len(g.Cells) != 1 || g.Cells[0].Display != "42" {
+ t.Fatalf("fanned out %+v, want one cell displaying 42", g.Cells)
+ }
+ default:
+ t.Fatal("a committed write reached no subscriber; nothing would ever live-update")
+ }
+}
diff --git a/internal/sheets/migrations.go b/internal/sheets/migrations.go
index c4fc717..043442f 100644
--- a/internal/sheets/migrations.go
+++ b/internal/sheets/migrations.go
@@ -6,6 +6,8 @@ import (
"github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/migrate"
"github.com/carlosframework/rastrillo/sessions"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
)
// migrationsFS embeds internal/sheets/migrations/*.sql — this package's
@@ -40,21 +42,13 @@ var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)
// diff Schema against — Sheet, Tab, Row, Col, Cell, Format and Member
// from models.go, plus Event.
//
-// Event is declared in models.go, not internal/sheets/store/events.go
-// where the rest of its behaviour (AppendInput, AppendRecalc, the Kind
-// consts) lives, and store/events.go keeps only a type alias back to it
-// (type Event = sheets.Event). That split is forced by this file, not a
-// tidiness preference: the CLI's loader (cmd/rastrillo/migration.go,
-// appPackage) requires exactly one non-test package under internal/ and
-// reads Schema, BootSchema and Models from it by name — there is no
-// second package generate/check will ever look at. package store already
-// imports package sheets for Sheet/Tab/Row/Col/Cell/Format, so the
-// reverse import sheets -> store this file would otherwise need to add
-// Event here cycles. A model check cannot see can never be caught
-// drifting, which would leave exactly one of Sheets' seven tables outside
-// the gate this file exists to build — so Event moved, and the alias
-// keeps every existing store.Event call site, production and test,
-// compiling unchanged.
+// Every model but Member is declared in package store, beside the
+// persistence that owns it. The list is a []any of values, so it names
+// them across the package boundary without needing them declared here —
+// which is what lets the CLI read one Models from one package while the
+// structs live where they belong. See store/models.go for why they
+// moved.
var Models = []any{
- Sheet{}, Tab{}, Row{}, Col{}, Cell{}, Format{}, Event{}, Member{},
+ store.Sheet{}, store.Tab{}, store.Row{}, store.Col{},
+ store.Cell{}, store.Format{}, store.Event{}, Member{},
}
diff --git a/internal/sheets/models.go b/internal/sheets/models.go
index b967cc6..2d7c0f1 100644
--- a/internal/sheets/models.go
+++ b/internal/sheets/models.go
@@ -2,106 +2,6 @@ package sheets
import "time"
-// Sheet is one spreadsheet document.
-type Sheet struct {
- ID uint64 `gorm:"primaryKey"`
- TitleSealed []byte // seal purpose "sheet.title"
- CreatedBy uint64
- CreatedAt time.Time
- UpdatedAt time.Time
- DeletedAt *time.Time `gorm:"index"`
-}
-
-// Tab is one sheet within a document — the tabs along the bottom.
-type Tab struct {
- ID uint64 `gorm:"primaryKey"`
- SheetID uint64 `gorm:"index"`
- NameSealed []byte // seal purpose "tab.name"
- Position int
- FrozenRows int
- FrozenCols int
-}
-
-// Row and Col carry stable identity. Position is a property of the record,
-// never part of a key — so inserting a row above a comment or a range does
-// not move what they point at. See spec section 4.2 for what this does and
-// does not buy: it removes reference rewriting for insert and delete only.
-//
-// (TabID, Position) is a uniqueIndex, not a plain one: at most one Row (or
-// Col) may ever claim a given grid coordinate within a tab. Nothing wrote
-// Row/Col rows until store.Handle.EnsureRow/EnsureCol (XLSX import), which
-// is why this constraint arrives with them rather than with the schema
-// itself — see that file's doc comment for the race a plain index would
-// leave open across two processes.
-type Row struct {
- ID uint64 `gorm:"primaryKey"`
- TabID uint64 `gorm:"uniqueIndex:idx_row_tab_pos"`
- Position int `gorm:"uniqueIndex:idx_row_tab_pos"`
- Height int
-}
-
-type Col struct {
- ID uint64 `gorm:"primaryKey"`
- TabID uint64 `gorm:"uniqueIndex:idx_col_tab_pos"`
- Position int `gorm:"uniqueIndex:idx_col_tab_pos"`
- Width int
-}
-
-// Cell is sparse: only non-empty cells exist.
-//
-// CacheVersion records the InputVersion its CachedSealed was derived from.
-// A cache whose CacheVersion trails its input is stale and must not be
-// served — which is how a crash mid-recalc is detected rather than assumed
-// away. See spec section 4.4.
-type Cell struct {
- TabID uint64 `gorm:"primaryKey"`
- RowID uint64 `gorm:"primaryKey"`
- ColID uint64 `gorm:"primaryKey"`
- InputSealed []byte // seal purpose "cell.input"
- CachedSealed []byte // seal purpose "cell.cached"
- InputVersion uint64
- CacheVersion uint64
- FormatID *uint64
- UpdatedBy uint64
- UpdatedAt time.Time
-}
-
-// Format is interned. A column of 5,000 identically styled cells is one row
-// here, not 5,000 — which is the difference between a format change being
-// cheap and being a table rewrite.
-type Format struct {
- ID uint64 `gorm:"primaryKey"`
- Digest string `gorm:"uniqueIndex;size:64"`
- NumberFmt string
- Bold bool
- Italic bool
- FillIntentH *float64 // OKLCH hue; nil means no fill
- FillIntentC *float64 // OKLCH chroma
- FillHex string // retained from XLSX import for lossless re-export
- Align string
-}
-
-// Event is the append-only log: live-sync fan-out, version history, undo
-// and audit. Its behaviour (AppendInput, AppendRecalc, the Kind consts)
-// lives in package store, which is where it conceptually belongs — store
-// owns persistence. The struct lives here instead, aliased back as
-// store.Event, purely because of what migrations.go needs: `rastrillo
-// migration check` diffs this package's Models against Schema, and
-// package sheets cannot import store (store already imports sheets for
-// Sheet/Tab/Row/Col/Cell/Format, so the reverse import would cycle). A
-// model the check can't see is a model it can never catch drifting, which
-// defeats the point of this file. See migrations.go and store/events.go.
-type Event struct {
- ID uint64 `gorm:"primaryKey"`
- Kind string `gorm:"index;size:16"`
- TabID uint64 `gorm:"index"`
- ActorID uint64
- PayloadSealed []byte // seal purpose "event.payload"; empty for recalc
- TriggerID uint64 // recalc only: the input event that caused it
- DerivedCount int // recalc only: how many cells the pass touched
- CreatedAt time.Time
-}
-
// Member is one person who may hold a session on this instance, and the
// stable uint64 an edit records as its actor.
//
diff --git a/internal/sheets/models_test.go b/internal/sheets/models_test.go
index e79300f..07e75bb 100644
--- a/internal/sheets/models_test.go
+++ b/internal/sheets/models_test.go
@@ -9,29 +9,30 @@ import (
)
// The models must carry no column that could hold a readable cell value.
-// This is a schema-level restatement of the one rule: there is nothing to
-// accidentally populate.
+// This is a schema-level restatement of the one rule: there is nothing
+// to accidentally populate.
//
-// This is a genuine reflection-based assertion, not a check of a hardcoded
-// list against itself. It walks every model struct that can hold user
-// content and asserts two properties directly on the struct shape:
+// It is a genuine reflection-based assertion, not a check of a
+// hardcoded list against itself. It walks every model and asserts two
+// properties directly on the struct shape:
//
-// 1. every field whose name ends in "Sealed" has type []byte — a sealed
-// field typed string would mean somebody stored plaintext in it.
+// 1. every field whose name ends in "Sealed" has type []byte — a
+// sealed field typed string would mean somebody stored plaintext in
+// it.
// 2. no field is named exactly one of the forbidden bare names — those
-// names without the "Sealed" suffix are the shape a plaintext column
-// takes.
+// names without the "Sealed" suffix are the shape a plaintext
+// column takes.
//
// Add "Title string" to Sheet and this test fails; that is the point.
//
-// Event (sealed PayloadSealed) is included via sheets.Event, not
-// store.Event: the two are the same type (store.Event is now a type
-// alias — see models.go's doc comment on Event and store/events.go),
-// but this file no longer needs to import store to reach it, so it
-// doesn't. This test still lives in package sheets_test rather than
-// package sheets — no functional need since the migrations.go work,
-// only inertia — but there is no reason to move it now that it compiles
-// either way.
+// It walks sheets.Models rather than a list of its own. Models is the
+// list `rastrillo migration check` reads, so it is the one list that
+// cannot be quietly incomplete: a model missing from it fails CI at the
+// migration gate. A second list here could silently omit a new table —
+// which is exactly what a plaintext column would need in order to
+// arrive unnoticed. (The structs themselves moved to package store when
+// the HTTP layer needed to open a Handle; this test followed the list,
+// not the package.)
func TestNoPlaintextValueColumns(t *testing.T) {
forbidden := map[string]bool{
"Title": true,
@@ -41,19 +42,13 @@ func TestNoPlaintextValueColumns(t *testing.T) {
"Value": true,
}
- models := []any{
- sheets.Sheet{},
- sheets.Tab{},
- sheets.Row{},
- sheets.Col{},
- sheets.Cell{},
- sheets.Format{},
- sheets.Event{},
+ if len(sheets.Models) == 0 {
+ t.Fatal("sheets.Models is empty; this test would pass by walking nothing")
}
byteSliceType := reflect.TypeOf([]byte(nil))
- for _, m := range models {
+ for _, m := range sheets.Models {
typ := reflect.TypeOf(m)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
@@ -73,3 +68,19 @@ func TestNoPlaintextValueColumns(t *testing.T) {
}
}
}
+
+// Member.Address is deliberately plaintext (spec §2.2 lists readable
+// email addresses among what this design does not seal), so it is worth
+// stating that the test above passes it on purpose rather than by
+// accident: "Address" is not in the forbidden set, and it is not in it
+// because membership is published in the README as plaintext.
+func TestMemberAddressIsIntentionallyPlaintext(t *testing.T) {
+ f, ok := reflect.TypeOf(sheets.Member{}).FieldByName("Address")
+ if !ok {
+ t.Fatal("Member has no Address field")
+ }
+ if f.Type.Kind() != reflect.String {
+ t.Fatalf("Member.Address is %s; spec §2.2 says membership addresses are readable, "+
+ "and a sealed one could not carry the unique index either", f.Type)
+ }
+}
diff --git a/internal/sheets/sheetsvc.go b/internal/sheets/sheetsvc.go
index 5e6329d..2a5f0a4 100644
--- a/internal/sheets/sheetsvc.go
+++ b/internal/sheets/sheetsvc.go
@@ -6,6 +6,8 @@ import (
"time"
"gorm.io/gorm"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
)
// maxTitleRunes caps a sheet title. It is a UI-shaped limit, not a
@@ -86,8 +88,8 @@ func (a *App) createSheet(title string, by Member) (SheetSummary, error) {
return SheetSummary{}, err
}
- sheet := Sheet{TitleSealed: sealedTitle, CreatedBy: by.ID}
- tab := Tab{NameSealed: sealedName, Position: 0}
+ sheet := store.Sheet{TitleSealed: sealedTitle, CreatedBy: by.ID}
+ tab := store.Tab{NameSealed: sealedName, Position: 0}
if err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&sheet).Error; err != nil {
return err
@@ -119,7 +121,7 @@ func (a *App) listSheets() ([]SheetSummary, error) {
if a.sealer == nil {
return nil, errNoSealer
}
- var rows []Sheet
+ var rows []store.Sheet
if err := a.db.Where("deleted_at IS NULL").
Order("updated_at DESC, id DESC").Find(&rows).Error; err != nil {
return nil, err
@@ -145,7 +147,7 @@ func (a *App) sheetSummary(id uint64) (SheetSummary, error) {
if a.sealer == nil {
return SheetSummary{}, errNoSealer
}
- var s Sheet
+ var s store.Sheet
if err := a.db.Where("id = ? AND deleted_at IS NULL", id).First(&s).Error; err != nil {
return SheetSummary{}, err
}
@@ -164,7 +166,7 @@ func (a *App) tabBelongsTo(sheetID, tabID uint64) bool {
return false
}
var n int64
- if err := a.db.Model(&Tab{}).
+ if err := a.db.Model(&store.Tab{}).
Where("id = ? AND sheet_id = ?", tabID, sheetID).Count(&n).Error; err != nil {
return false
}
@@ -179,17 +181,17 @@ func (a *App) touchSheet(sheetID uint64) {
if a.db == nil {
return
}
- a.db.Model(&Sheet{}).Where("id = ?", sheetID).
+ a.db.Model(&store.Sheet{}).Where("id = ?", sheetID).
Update("updated_at", time.Now().UTC())
}
// summarize unseals a Sheet row's title and finds its first tab.
-func (a *App) summarize(s Sheet) (SheetSummary, error) {
+func (a *App) summarize(s store.Sheet) (SheetSummary, error) {
title, err := a.sealer.Open("sheet.title", s.TitleSealed)
if err != nil {
return SheetSummary{}, err
}
- var tab Tab
+ var tab store.Tab
if err := a.db.Where("sheet_id = ?", s.ID).
Order("position ASC, id ASC").First(&tab).Error; err != nil {
return SheetSummary{}, err
diff --git a/internal/sheets/store/events.go b/internal/sheets/store/events.go
index 39ac41b..729c564 100644
--- a/internal/sheets/store/events.go
+++ b/internal/sheets/store/events.go
@@ -7,7 +7,6 @@ import (
"gorm.io/gorm"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/seal"
)
@@ -18,18 +17,6 @@ const (
KindRecalc = "recalc"
)
-// Event is the append-only log. It does four jobs — live-sync fan-out,
-// version history, undo, and audit — which is why the storage model pays.
-//
-// The struct itself is declared as sheets.Event, not here: see that
-// type's doc comment in models.go for why (migrations.go's Models list
-// has to see it, and package sheets cannot import this package back).
-// This alias means every existing call site — store.Event{...},
-// &store.Event{}, db.Where(...).First(&store.Event{}) — keeps compiling
-// unchanged; a type alias makes store.Event and sheets.Event the exact
-// same type, not merely convertible ones.
-type Event = sheets.Event
-
// InputEvent is a person's edit, before sealing.
type InputEvent struct {
TabID, RowID, ColID uint64
diff --git a/internal/sheets/store/export_test.go b/internal/sheets/store/export_test.go
new file mode 100644
index 0000000..1fccef5
--- /dev/null
+++ b/internal/sheets/store/export_test.go
@@ -0,0 +1,52 @@
+package store
+
+import (
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+ "amadan.net/oficina/sheets/internal/sheets/seal"
+)
+
+// Test-only seams, exported for package store_test.
+//
+// Three tests used to live in package store itself so they could reach
+// these unexported names. They cannot any more: they migrate a database
+// with sheets.BootSchema, and package sheets now imports this package —
+// so an internal test file importing sheets is a cycle Go refuses. This
+// file is the standard way out. It is a _test.go, so none of it exists
+// in a built binary.
+//
+// The seams stay seams rather than becoming real API: what each of
+// those tests proves is that the REAL code path does the right thing,
+// and a test driving a parallel exported implementation would prove
+// nothing about the one that ships.
+
+// EncodeValueForTest seals-encodes a computed value the way the handle
+// does, so a fixture can plant a cache entry that looks like one a real
+// recalculation wrote.
+func EncodeValueForTest(v calc.Value) []byte { return encodeValue(v) }
+
+// BuildHandleForTest builds a handle without going through Open's
+// refcounted registry, so a benchmark can time construction itself.
+func BuildHandleForTest(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error) {
+ return buildHandle(db, sealer, tabID)
+}
+
+// TransitiveDependentsForTest reads the dependency graph directly, so a
+// fixture can assert it really did wire more dependents than
+// calc.MaxDirtyPerPass before exercising the over-cap path.
+func TransitiveDependentsForTest(h *Handle, k calc.CellKey) map[calc.CellKey]struct{} {
+ return h.graph.TransitiveDependents(k)
+}
+
+// InterruptBlankAfterFirstTransaction replaces the seam setBlank calls
+// between its two transactions, and returns a function that puts the
+// production no-op back. It is what lets a test interrupt real
+// Set/setBlank code exactly at the crash window, rather than
+// hand-fabricating a database state that Open's recovery would know how
+// to fix whether or not Set ever wrote it.
+func InterruptBlankAfterFirstTransaction(f func() error) (restore func()) {
+ old := afterBlankTransactionOneCommits
+ afterBlankTransactionOneCommits = f
+ return func() { afterBlankTransactionOneCommits = old }
+}
diff --git a/internal/sheets/store/handle.go b/internal/sheets/store/handle.go
index 48d9bf0..7a64e55 100644
--- a/internal/sheets/store/handle.go
+++ b/internal/sheets/store/handle.go
@@ -13,7 +13,6 @@ import (
"gorm.io/gorm"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
)
@@ -104,7 +103,7 @@ var (
// caller of Value ever observes a cache a crash left behind.
func Open(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error) {
var count int64
- if err := db.Model(&sheets.Cell{}).Where("tab_id = ?", tabID).Count(&count).Error; err != nil {
+ if err := db.Model(&Cell{}).Where("tab_id = ?", tabID).Count(&count).Error; err != nil {
return nil, fmt.Errorf("store: count cells for tab: %w", err)
}
if count > MaxCellsPerTab {
@@ -166,8 +165,8 @@ func (h *Handle) Value(rowID, colID uint64) calc.Value {
// through to the database rather than through the in-memory graph: format
// assignment is comparatively rare and Format rows are already interned
// (models.go), so there is no equivalent cache to keep warm here.
-func (h *Handle) Format(rowID, colID uint64) *sheets.Format {
- var cell sheets.Cell
+func (h *Handle) Format(rowID, colID uint64) *Format {
+ var cell Cell
if err := h.db.Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID).
First(&cell).Error; err != nil {
return nil
@@ -175,7 +174,7 @@ func (h *Handle) Format(rowID, colID uint64) *sheets.Format {
if cell.FormatID == nil {
return nil
}
- var f sheets.Format
+ var f Format
if err := h.db.First(&f, *cell.FormatID).Error; err != nil {
return nil
}
diff --git a/internal/sheets/store/handle_blank_cap_test.go b/internal/sheets/store/handle_blank_cap_test.go
index faf837c..3074408 100644
--- a/internal/sheets/store/handle_blank_cap_test.go
+++ b/internal/sheets/store/handle_blank_cap_test.go
@@ -1,4 +1,4 @@
-package store
+package store_test
import (
"context"
@@ -11,19 +11,20 @@ import (
"amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
+ "amadan.net/oficina/sheets/internal/sheets/store"
)
// TestBlankFeederOverCapDependentsAreRecoverable proves that blanking a
// feeder cell with MORE dependents than calc.MaxDirtyPerPass still leaves
-// every dependent recoverable on the next Open, rather than silently and
+// every dependent recoverable on the next store.Open, rather than silently and
// permanently stale.
//
// Before this fix, setBlank (handle_set.go) skipped markDirty entirely
// once len(touched) > calc.MaxDirtyPerPass. On the UPDATE path that is
// survivable because the origin cell keeps its own version gap for
-// Open's recovery pass to notice. On the BLANK path the origin row is
+// store.Open's recovery pass to notice. On the BLANK path the origin row is
// deleted, so nothing is left to notice: every dependent's CacheVersion
-// stays equal to its own (untouched) InputVersion, which Open's
+// stays equal to its own (untouched) InputVersion, which store.Open's
// crash-recovery loop (handle_open.go) reads as "already consistent" and
// never revisits. Each dependent then keeps serving its old cached value
// forever, even though the cell it read no longer exists — the exact
@@ -58,13 +59,13 @@ func TestBlankFeederOverCapDependentsAreRecoverable(t *testing.T) {
const localTab, feederRow, colA uint64 = 1, 1, 1
const n = calc.MaxDirtyPerPass + 1 // strictly over the cap
- if err := db.Create(&sheets.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
+ if err := db.Create(&store.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
t.Fatalf("seed tab: %v", err)
}
- if err := db.Create(&sheets.Col{ID: colA, TabID: localTab, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Col{ID: colA, TabID: localTab, Position: 0}).Error; err != nil {
t.Fatalf("seed col: %v", err)
}
- if err := db.Create(&sheets.Row{ID: feederRow, TabID: localTab, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Row{ID: feederRow, TabID: localTab, Position: 0}).Error; err != nil {
t.Fatalf("seed feeder row: %v", err)
}
@@ -72,11 +73,11 @@ func TestBlankFeederOverCapDependentsAreRecoverable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- feederCachedSealed, err := s.Seal("cell.cached", encodeValue(calc.Value{Kind: calc.KindNum, Num: 5}))
+ feederCachedSealed, err := s.Seal("cell.cached", store.EncodeValueForTest(calc.Value{Kind: calc.KindNum, Num: 5}))
if err != nil {
t.Fatal(err)
}
- if err := db.Create(&sheets.Cell{
+ if err := db.Create(&store.Cell{
TabID: localTab, RowID: feederRow, ColID: colA,
InputSealed: feederInputSealed, InputVersion: 1,
CachedSealed: feederCachedSealed, CacheVersion: 1,
@@ -94,16 +95,16 @@ func TestBlankFeederOverCapDependentsAreRecoverable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- depCachedSealed, err := s.Seal("cell.cached", encodeValue(calc.Value{Kind: calc.KindNum, Num: 10}))
+ depCachedSealed, err := s.Seal("cell.cached", store.EncodeValueForTest(calc.Value{Kind: calc.KindNum, Num: 10}))
if err != nil {
t.Fatal(err)
}
- rows := make([]*sheets.Row, 0, n)
- cells := make([]*sheets.Cell, 0, n)
+ rows := make([]*store.Row, 0, n)
+ cells := make([]*store.Cell, 0, n)
for i := 1; i <= n; i++ {
rid := feederRow + uint64(i)
- rows = append(rows, &sheets.Row{ID: rid, TabID: localTab, Position: i})
- cells = append(cells, &sheets.Cell{
+ rows = append(rows, &store.Row{ID: rid, TabID: localTab, Position: i})
+ cells = append(cells, &store.Cell{
TabID: localTab, RowID: rid, ColID: colA,
InputSealed: depInputSealed, InputVersion: 1,
CachedSealed: depCachedSealed, CacheVersion: 1,
@@ -117,14 +118,14 @@ func TestBlankFeederOverCapDependentsAreRecoverable(t *testing.T) {
t.Fatalf("seed dependent cells: %v", err)
}
- h, err := Open(db, s, localTab)
+ h, err := store.Open(db, s, localTab)
if err != nil {
- t.Fatalf("Open: %v", err)
+ t.Fatalf("store.Open: %v", err)
}
// Sanity: the fixture really is over the cap and dependents really
// do read 10 to start, so the assertions below are exercising the
- // over-cap blank path, not something Open's own startup recompute
+ // over-cap blank path, not something store.Open's own startup recompute
// already fixed for an unrelated reason.
sample := []uint64{feederRow + 1, feederRow + n/2, feederRow + n}
for _, rid := range sample {
@@ -132,7 +133,7 @@ func TestBlankFeederOverCapDependentsAreRecoverable(t *testing.T) {
t.Fatalf("dependent row %d before blank = %+v, want KindNum 10", rid, got)
}
}
- if touched := h.graph.TransitiveDependents(calc.CellKey{RowID: 1, ColID: 1}); len(touched) <= calc.MaxDirtyPerPass {
+ if touched := store.TransitiveDependentsForTest(h, calc.CellKey{RowID: 1, ColID: 1}); len(touched) <= calc.MaxDirtyPerPass {
t.Fatalf("test setup: only %d dependents wired, want more than MaxDirtyPerPass (%d)", len(touched), calc.MaxDirtyPerPass)
}
@@ -145,8 +146,8 @@ func TestBlankFeederOverCapDependentsAreRecoverable(t *testing.T) {
// just a version stamp. A1 is gone, so the correct answer is now 0
// (blank reads as 0 in arithmetic). Before this fix they silently
// keep serving 10 forever, because nothing ever marked them dirty
- // and Open's recovery pass has nothing to notice.
- h2, err := Open(db, s, localTab)
+ // and store.Open's recovery pass has nothing to notice.
+ h2, err := store.Open(db, s, localTab)
if err != nil {
t.Fatalf("reopen: %v", err)
}
diff --git a/internal/sheets/store/handle_blank_crash_test.go b/internal/sheets/store/handle_blank_crash_test.go
index 47218a9..a52b072 100644
--- a/internal/sheets/store/handle_blank_crash_test.go
+++ b/internal/sheets/store/handle_blank_crash_test.go
@@ -1,4 +1,4 @@
-package store
+package store_test
import (
"context"
@@ -12,6 +12,7 @@ import (
"amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
+ "amadan.net/oficina/sheets/internal/sheets/store"
)
// crashSentinel is what afterBlankTransactionOneCommits returns while
@@ -28,7 +29,7 @@ var crashSentinel = errors.New("store: simulated crash for test")
// interrupt REAL Set/setBlank code exactly between the two transactions
// — the only way to prove this fix's actual code path writes the
// dependents' dirty marker atomically with the delete, as opposed to a
-// hand-fabricated DB state that Open's pre-existing per-cell recovery
+// hand-fabricated DB state that store.Open's pre-existing per-cell recovery
// would already know how to fix regardless of whether Set ever wrote it.
func TestBlankCrashLeavesDependentsRecoverable(t *testing.T) {
dir := t.TempDir()
@@ -51,22 +52,22 @@ func TestBlankCrashLeavesDependentsRecoverable(t *testing.T) {
}
const localTab, rowA, rowB, colA uint64 = 1, 1, 2, 1
- if err := db.Create(&sheets.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
+ if err := db.Create(&store.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
t.Fatalf("seed tab: %v", err)
}
- if err := db.Create([]*sheets.Row{
+ if err := db.Create([]*store.Row{
{ID: rowA, TabID: localTab, Position: 0},
{ID: rowB, TabID: localTab, Position: 1},
}).Error; err != nil {
t.Fatalf("seed rows: %v", err)
}
- if err := db.Create(&sheets.Col{ID: colA, TabID: localTab, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Col{ID: colA, TabID: localTab, Position: 0}).Error; err != nil {
t.Fatalf("seed col: %v", err)
}
- h, err := Open(db, s, localTab)
+ h, err := store.Open(db, s, localTab)
if err != nil {
- t.Fatalf("Open: %v", err)
+ t.Fatalf("store.Open: %v", err)
}
if err := h.Set(rowA, colA, "5", 7); err != nil {
t.Fatalf("Set A1: %v", err)
@@ -76,7 +77,7 @@ func TestBlankCrashLeavesDependentsRecoverable(t *testing.T) {
}
h.Release()
- h, err = Open(db, s, localTab)
+ h, err = store.Open(db, s, localTab)
if err != nil {
t.Fatalf("reopen: %v", err)
}
@@ -85,10 +86,10 @@ func TestBlankCrashLeavesDependentsRecoverable(t *testing.T) {
}
// Interrupt setBlank exactly between its two transactions.
- old := afterBlankTransactionOneCommits
- afterBlankTransactionOneCommits = func() error { return crashSentinel }
+ restore := store.InterruptBlankAfterFirstTransaction(
+ func() error { return crashSentinel })
err = h.Set(rowA, colA, "", 7)
- afterBlankTransactionOneCommits = old
+ restore()
if !errors.Is(err, crashSentinel) {
t.Fatalf("Set A1 blank (interrupted): got %v, want the simulated crash sentinel", err)
}
@@ -96,7 +97,7 @@ func TestBlankCrashLeavesDependentsRecoverable(t *testing.T) {
// Transaction 1 must have really committed: A1's row is gone.
var count int64
- db.Model(&sheets.Cell{}).Where("row_id = ? AND col_id = ?", rowA, colA).Count(&count)
+ db.Model(&store.Cell{}).Where("row_id = ? AND col_id = ?", rowA, colA).Count(&count)
if count != 0 {
t.Fatal("A1's row survived the interrupted blank; transaction 1 should have deleted it")
}
@@ -104,26 +105,26 @@ func TestBlankCrashLeavesDependentsRecoverable(t *testing.T) {
// B1 must have been left with a durable dirty marker: its own
// InputVersion leading its own CacheVersion, written atomically with
// the delete in transaction 1, per this fix.
- var b sheets.Cell
+ var b store.Cell
db.Where("row_id = ? AND col_id = ?", rowB, colA).First(&b)
if b.CacheVersion >= b.InputVersion {
t.Fatalf("B1 was not left with a durable dirty marker: CacheVersion %d, InputVersion %d (want CacheVersion < InputVersion)", b.CacheVersion, b.InputVersion)
}
- // The tab must be recoverable on the very next Open, exactly as
+ // The tab must be recoverable on the very next store.Open, exactly as
// TestStaleCacheIsRecomputedOnOpen requires for any dirty marker.
- h2, err := Open(db, s, localTab)
+ h2, err := store.Open(db, s, localTab)
if err != nil {
- t.Fatalf("Open after interrupted blank: %v", err)
+ t.Fatalf("store.Open after interrupted blank: %v", err)
}
defer h2.Release()
if got := h2.Value(rowB, colA); got.Kind != calc.KindNum || got.Num != 0 {
t.Fatalf("B1 after recovery = %+v, want KindNum 0 (A1 is gone; blank reads as 0 in arithmetic)", got)
}
- var b2 sheets.Cell
+ var b2 store.Cell
db.Where("row_id = ? AND col_id = ?", rowB, colA).First(&b2)
if b2.CacheVersion < b2.InputVersion {
- t.Fatalf("B1 still stale after Open's recovery: CacheVersion %d < InputVersion %d", b2.CacheVersion, b2.InputVersion)
+ t.Fatalf("B1 still stale after store.Open's recovery: CacheVersion %d < InputVersion %d", b2.CacheVersion, b2.InputVersion)
}
}
diff --git a/internal/sheets/store/handle_blank_recovery_bench_test.go b/internal/sheets/store/handle_blank_recovery_bench_test.go
index f8344fd..66e03f7 100644
--- a/internal/sheets/store/handle_blank_recovery_bench_test.go
+++ b/internal/sheets/store/handle_blank_recovery_bench_test.go
@@ -1,4 +1,4 @@
-package store
+package store_test
import (
"context"
@@ -15,11 +15,12 @@ import (
"amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
+ "amadan.net/oficina/sheets/internal/sheets/store"
)
// TestBlankRecoveryBench reproduces, on demand, the two headline timings
// batching-report.md's "Measurements" section reports for this task: the
-// setBlank/markDirty lock-hold time and Open's crash-recovery pass, both
+// setBlank/markDirty lock-hold time and store.Open's crash-recovery pass, both
// measured over the FIXED code (this file never touches, reverts or
// otherwise reconstructs the pre-fix path — see handle_batch.go for what
// changed).
@@ -29,7 +30,7 @@ import (
//
// N = 199,999 dependents (calc.MaxCellsPerTab - 1, the real ceiling)
// setBlank (h.mu hold, whole Set call): 2.779s
-// Open (crash-recovery pass over the N stale dependents): 8.818s
+// store.Open (crash-recovery pass over the N stale dependents): 8.818s
//
// Machine those were measured on: whatever ran the original task session
// — a single unspecified development machine, WAL-mode SQLite via the
@@ -45,7 +46,7 @@ import (
// logs, not a Test that fails, on timing.
//
// WHAT IT MEASURES:
-// - Phase 1 ("setBlank"): one Handle.Set call blanking a feeder cell
+// - Phase 1 ("setBlank"): one store.Handle.Set call blanking a feeder cell
// that N dependent cells reference as "=A1*2", timed end to end. Set
// holds h.mu for its entire body (handle_set.go), so this is the
// real exclusive-lock hold time any other Value/Set call on this tab
@@ -55,10 +56,10 @@ import (
// and persistTouched steps do not run inside it. That is what makes
// this a clean read on markDirty specifically at the real ceiling,
// matching how the recorded 2.779s figure above was measured.
-// - Phase 2 ("Open"): the very next Open on the same tab, timed end to
+// - Phase 2 ("store.Open"): the very next store.Open on the same tab, timed end to
// end. Phase 1 left every dependent's InputVersion ahead of its own
// CacheVersion (markDirty's job), so buildHandle's stale scan
-// (handle_open.go) finds all N of them and this Open drives
+// (handle_open.go) finds all N of them and this store.Open drives
// recoverStale (handle_batch.go) — the batched crash-recovery pass —
// over the whole set before returning.
//
@@ -82,7 +83,7 @@ import (
// above describe, and finishes in well under a second post-fix (the
// sibling correctness test, TestBlankFeederOverCapDependentsAreRecoverable,
// runs the same shape at the same N as part of every `make test`). Push
-// BLANK_RECOVERY_BENCH_N=199999 to reach the real MaxCellsPerTab-1
+// BLANK_RECOVERY_BENCH_N=199999 to reach the real store.MaxCellsPerTab-1
// ceiling the recorded figures were measured at; expect several minutes
// total, dominated by seeding N rows and cells, not by the two timed
// phases themselves.
@@ -117,9 +118,9 @@ func TestBlankRecoveryBench(t *testing.T) {
const localTab, feederRow, colA uint64 = 1, 1, 1
seedBlankRecoveryFixture(t, db, s, localTab, feederRow, colA, n)
- h, err := Open(db, s, localTab)
+ h, err := store.Open(db, s, localTab)
if err != nil {
- t.Fatalf("Open (initial): %v", err)
+ t.Fatalf("store.Open (initial): %v", err)
}
blankStart := time.Now()
@@ -130,15 +131,15 @@ func TestBlankRecoveryBench(t *testing.T) {
h.Release()
openStart := time.Now()
- h2, err := Open(db, s, localTab)
+ h2, err := store.Open(db, s, localTab)
openElapsed := time.Since(openStart)
if err != nil {
- t.Fatalf("Open (recovery): %v", err)
+ t.Fatalf("store.Open (recovery): %v", err)
}
defer h2.Release()
- t.Logf("N=%d setBlank(h.mu hold)=%s Open(recovery pass)=%s", n, blankElapsed, openElapsed)
- t.Logf("recorded (batching-report.md, N=199999): setBlank=2.779s Open=8.818s")
+ t.Logf("N=%d setBlank(h.mu hold)=%s store.Open(recovery pass)=%s", n, blankElapsed, openElapsed)
+ t.Logf("recorded (batching-report.md, N=199999): setBlank=2.779s store.Open=8.818s")
}
// blankRecoveryBenchN reads BLANK_RECOVERY_BENCH_N, defaulting to
@@ -167,13 +168,13 @@ func blankRecoveryBenchN(t *testing.T) int {
func seedBlankRecoveryFixture(t *testing.T, db *gorm.DB, s *seal.Sealer, tabID, feederRow, colA uint64, n int) {
t.Helper()
- if err := db.Create(&sheets.Tab{ID: tabID, SheetID: 1}).Error; err != nil {
+ if err := db.Create(&store.Tab{ID: tabID, SheetID: 1}).Error; err != nil {
t.Fatalf("seed tab: %v", err)
}
- if err := db.Create(&sheets.Col{ID: colA, TabID: tabID, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Col{ID: colA, TabID: tabID, Position: 0}).Error; err != nil {
t.Fatalf("seed col: %v", err)
}
- if err := db.Create(&sheets.Row{ID: feederRow, TabID: tabID, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Row{ID: feederRow, TabID: tabID, Position: 0}).Error; err != nil {
t.Fatalf("seed feeder row: %v", err)
}
@@ -181,11 +182,11 @@ func seedBlankRecoveryFixture(t *testing.T, db *gorm.DB, s *seal.Sealer, tabID,
if err != nil {
t.Fatal(err)
}
- feederCachedSealed, err := s.Seal("cell.cached", encodeValue(calc.Value{Kind: calc.KindNum, Num: 5}))
+ feederCachedSealed, err := s.Seal("cell.cached", store.EncodeValueForTest(calc.Value{Kind: calc.KindNum, Num: 5}))
if err != nil {
t.Fatal(err)
}
- if err := db.Create(&sheets.Cell{
+ if err := db.Create(&store.Cell{
TabID: tabID, RowID: feederRow, ColID: colA,
InputSealed: feederInputSealed, InputVersion: 1,
CachedSealed: feederCachedSealed, CacheVersion: 1,
@@ -197,16 +198,16 @@ func seedBlankRecoveryFixture(t *testing.T, db *gorm.DB, s *seal.Sealer, tabID,
if err != nil {
t.Fatal(err)
}
- depCachedSealed, err := s.Seal("cell.cached", encodeValue(calc.Value{Kind: calc.KindNum, Num: 10}))
+ depCachedSealed, err := s.Seal("cell.cached", store.EncodeValueForTest(calc.Value{Kind: calc.KindNum, Num: 10}))
if err != nil {
t.Fatal(err)
}
- rows := make([]*sheets.Row, 0, n)
- cells := make([]*sheets.Cell, 0, n)
+ rows := make([]*store.Row, 0, n)
+ cells := make([]*store.Cell, 0, n)
for i := 1; i <= n; i++ {
rid := feederRow + uint64(i)
- rows = append(rows, &sheets.Row{ID: rid, TabID: tabID, Position: i})
- cells = append(cells, &sheets.Cell{
+ rows = append(rows, &store.Row{ID: rid, TabID: tabID, Position: i})
+ cells = append(cells, &store.Cell{
TabID: tabID, RowID: rid, ColID: colA,
InputSealed: depInputSealed, InputVersion: 1,
CachedSealed: depCachedSealed, CacheVersion: 1,
diff --git a/internal/sheets/store/handle_gate_test.go b/internal/sheets/store/handle_gate_test.go
index 86355ba..26ee6f5 100644
--- a/internal/sheets/store/handle_gate_test.go
+++ b/internal/sheets/store/handle_gate_test.go
@@ -4,7 +4,6 @@ import (
"path/filepath"
"testing"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/seal"
"amadan.net/oficina/sheets/internal/sheets/sealtest"
"amadan.net/oficina/sheets/internal/sheets/store"
@@ -30,13 +29,13 @@ func TestSetDoesNotLeakPlaintext(t *testing.T) {
}
const gateTabID, gateRow, gateCol uint64 = 1, 1, 1
- if err := db.Create(&sheets.Tab{ID: gateTabID, SheetID: 1}).Error; err != nil {
+ if err := db.Create(&store.Tab{ID: gateTabID, SheetID: 1}).Error; err != nil {
t.Fatalf("seed tab: %v", err)
}
- if err := db.Create(&sheets.Row{ID: gateRow, TabID: gateTabID, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Row{ID: gateRow, TabID: gateTabID, Position: 0}).Error; err != nil {
t.Fatalf("seed row: %v", err)
}
- if err := db.Create(&sheets.Col{ID: gateCol, TabID: gateTabID, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Col{ID: gateCol, TabID: gateTabID, Position: 0}).Error; err != nil {
t.Fatalf("seed col: %v", err)
}
diff --git a/internal/sheets/store/handle_grid.go b/internal/sheets/store/handle_grid.go
index 1eab3fd..2a8b6b6 100644
--- a/internal/sheets/store/handle_grid.go
+++ b/internal/sheets/store/handle_grid.go
@@ -17,8 +17,6 @@ import (
"strings"
"gorm.io/gorm"
-
- "amadan.net/oficina/sheets/internal/sheets"
)
// EnsureRow returns the id of the row at pos, creating it if this tab has
@@ -43,9 +41,9 @@ func (h *Handle) EnsureRow(pos int) (uint64, error) {
if id, ok := h.rowByPos[pos]; ok {
return id, nil
}
- r := sheets.Row{TabID: h.tabID, Position: pos}
+ r := Row{TabID: h.tabID, Position: pos}
if err := h.db.Create(&r).Error; err != nil {
- var existing sheets.Row
+ var existing Row
if err2 := h.db.Where("tab_id = ? AND position = ?", h.tabID, pos).First(&existing).Error; err2 == nil {
h.rowPos[existing.ID] = pos
h.rowByPos[pos] = existing.ID
@@ -66,9 +64,9 @@ func (h *Handle) EnsureCol(pos int) (uint64, error) {
if id, ok := h.colByPos[pos]; ok {
return id, nil
}
- c := sheets.Col{TabID: h.tabID, Position: pos}
+ c := Col{TabID: h.tabID, Position: pos}
if err := h.db.Create(&c).Error; err != nil {
- var existing sheets.Col
+ var existing Col
if err2 := h.db.Where("tab_id = ? AND position = ?", h.tabID, pos).First(&existing).Error; err2 == nil {
h.colPos[existing.ID] = pos
h.colByPos[pos] = existing.ID
@@ -131,7 +129,7 @@ type CellCoord struct{ RowID, ColID uint64 }
// the same way Format already does (unlike Value, which reads through
// the in-memory graph — see Format's own doc comment on handle.go).
func (h *Handle) Cells() ([]CellCoord, error) {
- var cells []sheets.Cell
+ var cells []Cell
if err := h.db.Select("row_id", "col_id").
Where("tab_id = ?", h.tabID).Find(&cells).Error; err != nil {
return nil, fmt.Errorf("store: list cells: %w", err)
@@ -163,7 +161,7 @@ func (h *Handle) RawInput(rowID, colID uint64) (string, bool) {
// empty sealed input if the cell is otherwise blank: FormatID lives on
// Cell, so a formatted-but-empty cell (a coloured header nothing has been
// typed into yet) still needs a row to carry it.
-func (h *Handle) SetFormat(rowID, colID uint64, f sheets.Format) error {
+func (h *Handle) SetFormat(rowID, colID uint64, f Format) error {
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.cellKey(rowID, colID); !ok {
@@ -180,10 +178,10 @@ func (h *Handle) SetFormat(rowID, colID uint64, f sheets.Format) error {
// (by digest) or creates one. A duplicate-key race against a concurrent
// interner of the identical format is resolved by re-reading rather than
// failing: the digest's uniqueIndex is what makes that safe.
-func (h *Handle) internFormat(f sheets.Format) (uint64, error) {
+func (h *Handle) internFormat(f Format) (uint64, error) {
digest := formatDigest(f)
- var existing sheets.Format
+ var existing Format
err := h.db.Where("digest = ?", digest).First(&existing).Error
if err == nil {
return existing.ID, nil
@@ -197,7 +195,7 @@ func (h *Handle) internFormat(f sheets.Format) (uint64, error) {
// Another writer interned the same content between the lookup
// above and this Create — re-read rather than propagating what
// is, from the caller's point of view, not really a failure.
- var again sheets.Format
+ var again Format
if err2 := h.db.Where("digest = ?", digest).First(&again).Error; err2 == nil {
return again.ID, nil
}
@@ -209,7 +207,7 @@ func (h *Handle) internFormat(f sheets.Format) (uint64, error) {
// formatDigest canonicalizes f's fields into the string Format.Digest
// hashes — every field that participates in Format's own uniqueIndex
// (models.go), and none that doesn't (ID, Digest itself).
-func formatDigest(f sheets.Format) string {
+func formatDigest(f Format) string {
var sb strings.Builder
sb.WriteString(f.NumberFmt)
sb.WriteByte('|')
@@ -235,7 +233,7 @@ func formatDigest(f sheets.Format) string {
// assignFormat points the cell at (rowID, colID)'s FormatID at formatID,
// creating the Cell row (with empty sealed input) if none exists yet.
func (h *Handle) assignFormat(rowID, colID, formatID uint64) error {
- var cell sheets.Cell
+ var cell Cell
err := h.db.Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID).
First(&cell).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -244,7 +242,7 @@ func (h *Handle) assignFormat(rowID, colID, formatID uint64) error {
return fmt.Errorf("store: seal empty input for format-only cell: %w", serr)
}
id := formatID
- return h.db.Create(&sheets.Cell{
+ return h.db.Create(&Cell{
TabID: h.tabID, RowID: rowID, ColID: colID,
InputSealed: sealedEmpty, FormatID: &id,
}).Error
@@ -253,7 +251,7 @@ func (h *Handle) assignFormat(rowID, colID, formatID uint64) error {
return fmt.Errorf("store: look up cell for format assignment: %w", err)
}
id := formatID
- return h.db.Model(&sheets.Cell{}).
+ return h.db.Model(&Cell{}).
Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID).
Update("format_id", &id).Error
}
diff --git a/internal/sheets/store/handle_grid_race_test.go b/internal/sheets/store/handle_grid_race_test.go
index d490535..ad72548 100644
--- a/internal/sheets/store/handle_grid_race_test.go
+++ b/internal/sheets/store/handle_grid_race_test.go
@@ -3,7 +3,6 @@ package store_test
import (
"testing"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/store"
)
@@ -23,7 +22,7 @@ func TestEnsureRowRetriesOnConcurrentCreate(t *testing.T) {
defer h.Release()
const pos = 3
- winner := sheets.Row{TabID: tabID, Position: pos}
+ winner := store.Row{TabID: tabID, Position: pos}
if err := db.Create(&winner).Error; err != nil {
t.Fatalf("seed concurrent row: %v", err)
}
@@ -37,7 +36,7 @@ func TestEnsureRowRetriesOnConcurrentCreate(t *testing.T) {
}
var count int64
- if err := db.Model(&sheets.Row{}).Where("tab_id = ? AND position = ?", tabID, pos).Count(&count).Error; err != nil {
+ if err := db.Model(&store.Row{}).Where("tab_id = ? AND position = ?", tabID, pos).Count(&count).Error; err != nil {
t.Fatalf("count rows at position: %v", err)
}
if count != 1 {
@@ -62,7 +61,7 @@ func TestEnsureColRetriesOnConcurrentCreate(t *testing.T) {
defer h.Release()
const pos = 3
- winner := sheets.Col{TabID: tabID, Position: pos}
+ winner := store.Col{TabID: tabID, Position: pos}
if err := db.Create(&winner).Error; err != nil {
t.Fatalf("seed concurrent col: %v", err)
}
@@ -76,7 +75,7 @@ func TestEnsureColRetriesOnConcurrentCreate(t *testing.T) {
}
var count int64
- if err := db.Model(&sheets.Col{}).Where("tab_id = ? AND position = ?", tabID, pos).Count(&count).Error; err != nil {
+ if err := db.Model(&store.Col{}).Where("tab_id = ? AND position = ?", tabID, pos).Count(&count).Error; err != nil {
t.Fatalf("count cols at position: %v", err)
}
if count != 1 {
@@ -90,10 +89,10 @@ func TestEnsureColRetriesOnConcurrentCreate(t *testing.T) {
// the guarantee the retry-by-read logic above depends on existing.
func TestRowUniqueIndexRejectsDuplicatePosition(t *testing.T) {
db, _, tabID := emptyTab(t)
- if err := db.Create(&sheets.Row{TabID: tabID, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Row{TabID: tabID, Position: 0}).Error; err != nil {
t.Fatalf("first create: %v", err)
}
- if err := db.Create(&sheets.Row{TabID: tabID, Position: 0}).Error; err == nil {
+ if err := db.Create(&store.Row{TabID: tabID, Position: 0}).Error; err == nil {
t.Fatal("second Row at the same (TabID, Position) succeeded, want a uniqueIndex violation")
}
}
diff --git a/internal/sheets/store/handle_grid_test.go b/internal/sheets/store/handle_grid_test.go
index 81a119e..23b93b3 100644
--- a/internal/sheets/store/handle_grid_test.go
+++ b/internal/sheets/store/handle_grid_test.go
@@ -6,7 +6,6 @@ import (
"gorm.io/gorm"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/seal"
"amadan.net/oficina/sheets/internal/sheets/store"
)
@@ -25,7 +24,7 @@ func emptyTab(t *testing.T) (*gorm.DB, *seal.Sealer, uint64) {
t.Fatalf("seal.New: %v", err)
}
const emptyTabID uint64 = 55
- if err := db.Create(&sheets.Tab{ID: emptyTabID, SheetID: 1}).Error; err != nil {
+ if err := db.Create(&store.Tab{ID: emptyTabID, SheetID: 1}).Error; err != nil {
t.Fatalf("seed tab: %v", err)
}
return db, s, emptyTabID
@@ -153,7 +152,7 @@ func TestSetFormatInterns(t *testing.T) {
t.Fatalf("Set: %v", err)
}
- want := sheets.Format{NumberFmt: "0.00", Bold: true}
+ want := store.Format{NumberFmt: "0.00", Bold: true}
if err := h.SetFormat(rowA, colA, want); err != nil {
t.Fatalf("SetFormat(A1): %v", err)
}
@@ -196,7 +195,7 @@ func TestSetFormatOnBlankCell(t *testing.T) {
t.Fatalf("EnsureCol: %v", err)
}
- if err := h.SetFormat(rowID, colID, sheets.Format{FillHex: "FFFF0000"}); err != nil {
+ if err := h.SetFormat(rowID, colID, store.Format{FillHex: "FFFF0000"}); err != nil {
t.Fatalf("SetFormat on blank cell: %v", err)
}
@@ -230,7 +229,7 @@ func TestCellsListsContentAndFormatOnlyCells(t *testing.T) {
if err != nil {
t.Fatalf("EnsureCol: %v", err)
}
- if err := h.SetFormat(rowFmt, colFmt, sheets.Format{Bold: true}); err != nil {
+ if err := h.SetFormat(rowFmt, colFmt, store.Format{Bold: true}); err != nil {
t.Fatalf("SetFormat: %v", err)
}
diff --git a/internal/sheets/store/handle_more_test.go b/internal/sheets/store/handle_more_test.go
index f7d9cf6..c2fc48b 100644
--- a/internal/sheets/store/handle_more_test.go
+++ b/internal/sheets/store/handle_more_test.go
@@ -4,7 +4,6 @@ import (
"path/filepath"
"testing"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
"amadan.net/oficina/sheets/internal/sheets/store"
@@ -47,7 +46,7 @@ func TestSetBlankDeletesRowAndPropagates(t *testing.T) {
}
var count int64
- db.Model(&sheets.Cell{}).Where("row_id = ? AND col_id = ?", rowA, colA).Count(&count)
+ db.Model(&store.Cell{}).Where("row_id = ? AND col_id = ?", rowA, colA).Count(&count)
if count != 0 {
t.Fatalf("A1's row still exists after blanking; cells must be sparse")
}
@@ -73,11 +72,11 @@ func TestFormatReturnsInternedFormat(t *testing.T) {
t.Fatalf("A1 has no FormatID; want nil, got %+v", f)
}
- fmtRow := &sheets.Format{Digest: "test-digest", NumberFmt: "0.00"}
+ fmtRow := &store.Format{Digest: "test-digest", NumberFmt: "0.00"}
if err := db.Create(fmtRow).Error; err != nil {
t.Fatalf("create format: %v", err)
}
- if err := db.Model(&sheets.Cell{}).
+ if err := db.Model(&store.Cell{}).
Where("row_id = ? AND col_id = ?", rowA, colA).
Update("format_id", fmtRow.ID).Error; err != nil {
t.Fatalf("assign format: %v", err)
@@ -116,7 +115,7 @@ func TestReleaseEvictsAndReopenSeesFreshState(t *testing.T) {
// Mutate directly, bypassing the handle entirely — a released handle
// must not still be answering Value() from stale memory, and the next
// Open must reflect this write.
- db.Model(&sheets.Cell{}).
+ db.Model(&store.Cell{}).
Where("row_id = ? AND col_id = ?", rowA, colA).
Updates(map[string]any{"input_version": 5, "cache_version": 5})
@@ -150,16 +149,16 @@ func TestRecoveryDoesNotRegressAConsistentDependentsVersion(t *testing.T) {
}
const localTab, localRowA, localRowC, localCol uint64 = 1, 1, 2, 1
- if err := db.Create(&sheets.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
+ if err := db.Create(&store.Tab{ID: localTab, SheetID: 1}).Error; err != nil {
t.Fatalf("seed tab: %v", err)
}
- if err := db.Create([]*sheets.Row{
+ if err := db.Create([]*store.Row{
{ID: localRowA, TabID: localTab, Position: 0},
{ID: localRowC, TabID: localTab, Position: 1},
}).Error; err != nil {
t.Fatalf("seed rows: %v", err)
}
- if err := db.Create(&sheets.Col{ID: localCol, TabID: localTab, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Col{ID: localCol, TabID: localTab, Position: 0}).Error; err != nil {
t.Fatalf("seed col: %v", err)
}
@@ -176,7 +175,7 @@ func TestRecoveryDoesNotRegressAConsistentDependentsVersion(t *testing.T) {
}
// A1: consistent at rest (InputVersion 5 == CacheVersion 5).
- if err := db.Create(&sheets.Cell{
+ if err := db.Create(&store.Cell{
TabID: localTab, RowID: localRowA, ColID: localCol,
InputSealed: sealInput("1"), InputVersion: 5,
CachedSealed: blankCache, CacheVersion: 5,
@@ -186,7 +185,7 @@ func TestRecoveryDoesNotRegressAConsistentDependentsVersion(t *testing.T) {
// C1 (at row localRowC): also consistent at rest, and at a HIGHER
// version than A1 — the shape a formula edited well after its
// precedent takes.
- if err := db.Create(&sheets.Cell{
+ if err := db.Create(&store.Cell{
TabID: localTab, RowID: localRowC, ColID: localCol,
InputSealed: sealInput("=A1+1"), InputVersion: 10,
CachedSealed: blankCache, CacheVersion: 10,
@@ -198,7 +197,7 @@ func TestRecoveryDoesNotRegressAConsistentDependentsVersion(t *testing.T) {
// C1's cache — the same shape as the brief's own crash test, but with
// the bump smaller than C1's already-consistent version, which is
// exactly what exposes a stamp-regression bug.
- if err := db.Model(&sheets.Cell{}).
+ if err := db.Model(&store.Cell{}).
Where("row_id = ? AND col_id = ?", localRowA, localCol).
Update("input_version", 6).Error; err != nil {
t.Fatalf("tamper A1: %v", err)
@@ -210,7 +209,7 @@ func TestRecoveryDoesNotRegressAConsistentDependentsVersion(t *testing.T) {
}
defer h.Release()
- var c sheets.Cell
+ var c store.Cell
db.Where("row_id = ? AND col_id = ?", localRowC, localCol).First(&c)
if c.CacheVersion < c.InputVersion {
t.Fatalf("C1 regressed: CacheVersion %d < its own InputVersion %d", c.CacheVersion, c.InputVersion)
diff --git a/internal/sheets/store/handle_open.go b/internal/sheets/store/handle_open.go
index 11d12d6..9e76354 100644
--- a/internal/sheets/store/handle_open.go
+++ b/internal/sheets/store/handle_open.go
@@ -5,7 +5,6 @@ import (
"gorm.io/gorm"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
)
@@ -44,15 +43,15 @@ import (
// calc's evaluator to real ids, which is the more invasive but more
// durable fix. This handle does neither because it needs neither yet.
func buildHandle(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error) {
- var rows []sheets.Row
+ var rows []Row
if err := db.Where("tab_id = ?", tabID).Find(&rows).Error; err != nil {
return nil, fmt.Errorf("store: load rows: %w", err)
}
- var cols []sheets.Col
+ var cols []Col
if err := db.Where("tab_id = ?", tabID).Find(&cols).Error; err != nil {
return nil, fmt.Errorf("store: load cols: %w", err)
}
- var cells []sheets.Cell
+ var cells []Cell
if err := db.Where("tab_id = ?", tabID).Find(&cells).Error; err != nil {
return nil, fmt.Errorf("store: load cells: %w", err)
}
diff --git a/internal/sheets/store/handle_report.go b/internal/sheets/store/handle_report.go
index 2b71f5f..15111d8 100644
--- a/internal/sheets/store/handle_report.go
+++ b/internal/sheets/store/handle_report.go
@@ -49,6 +49,20 @@ type Report struct {
Capped bool
}
+// Version is the version a snapshot taken right now would carry.
+//
+// It is the handle's next stamp, not the last one issued, and that is
+// the point: a window fetched at Version N already contains every edit
+// stamped below N, so a live message arriving late with a version below
+// N is one the snapshot has already applied and the client is right to
+// discard it. Per-cell versions would be more precise and buy nothing —
+// the client's question is only ever "is this older than what I have".
+func (h *Handle) Version() uint64 {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.nextVersion
+}
+
// SetReport writes a cell's new raw input, recalculates forward from it,
// and reports every cell that changed.
//
diff --git a/internal/sheets/store/handle_set.go b/internal/sheets/store/handle_set.go
index 2805ad6..f4a8039 100644
--- a/internal/sheets/store/handle_set.go
+++ b/internal/sheets/store/handle_set.go
@@ -3,7 +3,6 @@ package store
import (
"gorm.io/gorm"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
)
@@ -54,7 +53,7 @@ func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor
if err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID).
- Delete(&sheets.Cell{}).Error; err != nil {
+ Delete(&Cell{}).Error; err != nil {
return err
}
// Marking is unconditional on len(touched) > 0 — NOT gated by
@@ -125,7 +124,7 @@ func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Nod
return nil, false, err
}
if err := h.db.Transaction(func(tx *gorm.DB) error {
- res := tx.Model(&sheets.Cell{}).
+ res := tx.Model(&Cell{}).
Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, rowID, colID).
Updates(map[string]any{
"input_sealed": sealedInput,
@@ -136,7 +135,7 @@ func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Nod
return res.Error
}
if res.RowsAffected == 0 {
- if err := tx.Create(&sheets.Cell{
+ if err := tx.Create(&Cell{
TabID: h.tabID, RowID: rowID, ColID: colID,
InputSealed: sealedInput, InputVersion: v,
UpdatedBy: actor,
@@ -239,7 +238,7 @@ func (h *Handle) persistTouched(touched map[calc.CellKey]struct{}, v uint64) err
if err != nil {
return err
}
- if err := tx.Model(&sheets.Cell{}).
+ if err := tx.Model(&Cell{}).
Where("tab_id = ? AND row_id = ? AND col_id = ?", h.tabID, ids.RowID, ids.ColID).
Updates(map[string]any{
"cached_sealed": sealedCache,
diff --git a/internal/sheets/store/handle_test.go b/internal/sheets/store/handle_test.go
index 7fa8c94..8d75a01 100644
--- a/internal/sheets/store/handle_test.go
+++ b/internal/sheets/store/handle_test.go
@@ -7,7 +7,6 @@ import (
"gorm.io/gorm"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
"amadan.net/oficina/sheets/internal/sheets/store"
@@ -40,16 +39,16 @@ func seeded(t *testing.T) (*gorm.DB, *seal.Sealer, uint64) {
t.Fatalf("seal.New: %v", err)
}
- if err := db.Create(&sheets.Tab{ID: tabID, SheetID: 1}).Error; err != nil {
+ if err := db.Create(&store.Tab{ID: tabID, SheetID: 1}).Error; err != nil {
t.Fatalf("seed tab: %v", err)
}
- if err := db.Create([]*sheets.Row{
+ if err := db.Create([]*store.Row{
{ID: rowA, TabID: tabID, Position: 0},
{ID: rowB, TabID: tabID, Position: 1},
}).Error; err != nil {
t.Fatalf("seed rows: %v", err)
}
- if err := db.Create(&sheets.Col{ID: colA, TabID: tabID, Position: 0}).Error; err != nil {
+ if err := db.Create(&store.Col{ID: colA, TabID: tabID, Position: 0}).Error; err != nil {
t.Fatalf("seed col: %v", err)
}
@@ -64,7 +63,7 @@ func seeded(t *testing.T) (*gorm.DB, *seal.Sealer, uint64) {
// Open to run a real recompute for A1 rather than trusting a
// hand-seeded placeholder cache, which would otherwise silently
// stand in for "5" without ever being checked against it.
- if err := db.Create(&sheets.Cell{
+ if err := db.Create(&store.Cell{
TabID: tabID, RowID: rowA, ColID: colA,
InputSealed: inputSealed, InputVersion: 1,
}).Error; err != nil {
@@ -92,7 +91,7 @@ func TestStaleCacheIsRecomputedOnOpen(t *testing.T) {
// Simulate a crash mid-recalc: bump A1's input version without
// rewriting B1's cache. This is exactly what a panic between the two
// writes leaves behind.
- db.Model(&sheets.Cell{}).
+ db.Model(&store.Cell{}).
Where("row_id = ? AND col_id = ?", rowA, colA).
Updates(map[string]any{"input_version": 99})
@@ -102,7 +101,7 @@ func TestStaleCacheIsRecomputedOnOpen(t *testing.T) {
}
defer h2.Release()
- var b sheets.Cell
+ var b store.Cell
db.Where("row_id = ? AND col_id = ?", rowB, colA).First(&b)
if b.CacheVersion < 99 {
t.Fatalf("stale cache served: CacheVersion %d < InputVersion 99", b.CacheVersion)
diff --git a/internal/sheets/store/models.go b/internal/sheets/store/models.go
new file mode 100644
index 0000000..fd31481
--- /dev/null
+++ b/internal/sheets/store/models.go
@@ -0,0 +1,118 @@
+// Models for everything this package persists.
+//
+// They live here, in package store, rather than in package sheets where
+// they started. The move happened when the HTTP layer needed to open a
+// Handle: store imported sheets for these structs, so sheets could not
+// import store back, and every route that reads a cell is in package
+// sheets. Putting them where the persistence is removes the cycle
+// rather than routing around it — and it is where models.go's own
+// comment on Event already said they conceptually belonged.
+//
+// package sheets keeps Member, which is an app concern (who may hold a
+// session) rather than a storage one, and keeps the Models list that
+// `rastrillo migration check` reads: the list is a []any of values, so
+// it names these types across the package boundary without needing
+// them declared beside it.
+package store
+
+import "time"
+
+// Sheet is one spreadsheet document.
+type Sheet struct {
+ ID uint64 `gorm:"primaryKey"`
+ TitleSealed []byte // seal purpose "sheet.title"
+ CreatedBy uint64
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ DeletedAt *time.Time `gorm:"index"`
+}
+
+// Tab is one sheet within a document — the tabs along the bottom.
+type Tab struct {
+ ID uint64 `gorm:"primaryKey"`
+ SheetID uint64 `gorm:"index"`
+ NameSealed []byte // seal purpose "tab.name"
+ Position int
+ FrozenRows int
+ FrozenCols int
+}
+
+// Row and Col carry stable identity. Position is a property of the record,
+// never part of a key — so inserting a row above a comment or a range does
+// not move what they point at. See spec section 4.2 for what this does and
+// does not buy: it removes reference rewriting for insert and delete only.
+//
+// (TabID, Position) is a uniqueIndex, not a plain one: at most one Row (or
+// Col) may ever claim a given grid coordinate within a tab. Nothing wrote
+// Row/Col rows until store.Handle.EnsureRow/EnsureCol (XLSX import), which
+// is why this constraint arrives with them rather than with the schema
+// itself — see that file's doc comment for the race a plain index would
+// leave open across two processes.
+type Row struct {
+ ID uint64 `gorm:"primaryKey"`
+ TabID uint64 `gorm:"uniqueIndex:idx_row_tab_pos"`
+ Position int `gorm:"uniqueIndex:idx_row_tab_pos"`
+ Height int
+}
+
+type Col struct {
+ ID uint64 `gorm:"primaryKey"`
+ TabID uint64 `gorm:"uniqueIndex:idx_col_tab_pos"`
+ Position int `gorm:"uniqueIndex:idx_col_tab_pos"`
+ Width int
+}
+
+// Cell is sparse: only non-empty cells exist.
+//
+// CacheVersion records the InputVersion its CachedSealed was derived from.
+// A cache whose CacheVersion trails its input is stale and must not be
+// served — which is how a crash mid-recalc is detected rather than assumed
+// away. See spec section 4.4.
+type Cell struct {
+ TabID uint64 `gorm:"primaryKey"`
+ RowID uint64 `gorm:"primaryKey"`
+ ColID uint64 `gorm:"primaryKey"`
+ InputSealed []byte // seal purpose "cell.input"
+ CachedSealed []byte // seal purpose "cell.cached"
+ InputVersion uint64
+ CacheVersion uint64
+ FormatID *uint64
+ UpdatedBy uint64
+ UpdatedAt time.Time
+}
+
+// Format is interned. A column of 5,000 identically styled cells is one row
+// here, not 5,000 — which is the difference between a format change being
+// cheap and being a table rewrite.
+type Format struct {
+ ID uint64 `gorm:"primaryKey"`
+ Digest string `gorm:"uniqueIndex;size:64"`
+ NumberFmt string
+ Bold bool
+ Italic bool
+ FillIntentH *float64 // OKLCH hue; nil means no fill
+ FillIntentC *float64 // OKLCH chroma
+ FillHex string // retained from XLSX import for lossless re-export
+ Align string
+}
+
+// Event is the append-only log: live-sync fan-out, version history, undo
+// and audit. Its behaviour (AppendInput, AppendRecalc, the Kind consts)
+// lives in package store, which is where it conceptually belongs — store
+// owns persistence. The struct lives here instead, aliased back as
+// store.Event, purely because of what migrations.go needs: `rastrillo
+// migration check` diffs this package's Models against Schema, and
+// package sheets cannot import store (store already imports sheets for
+// Sheet/Tab/Row/Col/Cell/Format, so the reverse import would cycle). A
+// model the check can't see is a model it can never catch drifting, which
+// defeats the point of this file. See migrations.go and store/events.go.
+type Event struct {
+ ID uint64 `gorm:"primaryKey"`
+ Kind string `gorm:"index;size:16"`
+ TabID uint64 `gorm:"index"`
+ ActorID uint64
+ PayloadSealed []byte // seal purpose "event.payload"; empty for recalc
+ TriggerID uint64 // recalc only: the input event that caused it
+ DerivedCount int // recalc only: how many cells the pass touched
+ CreatedAt time.Time
+}
diff --git a/internal/sheets/xlsx/format.go b/internal/sheets/xlsx/format.go
index a390d91..1a2be31 100644
--- a/internal/sheets/xlsx/format.go
+++ b/internal/sheets/xlsx/format.go
@@ -5,7 +5,7 @@ import (
"github.com/xuri/excelize/v2"
- "amadan.net/oficina/sheets/internal/sheets"
+ "amadan.net/oficina/sheets/internal/sheets/store"
)
// styleFromFormat builds the excelize style a Format should render as.
@@ -19,7 +19,7 @@ import (
// isn't one of its ~50 builtins, so relying on the id for something as
// common as "0.00" would silently stop being lossless the day this
// package's number-format strings happen to collide with one.
-func styleFromFormat(f *sheets.Format) (excelize.Style, bool) {
+func styleFromFormat(f *store.Format) (excelize.Style, bool) {
if f == nil {
return excelize.Style{}, false
}
@@ -54,7 +54,7 @@ func styleFromFormat(f *sheets.Format) (excelize.Style, bool) {
// retained import hex when present (lossless re-export — the whole point
// of FillHex, models.go), otherwise the intent's hue/chroma resolved at a
// fixed lightness (colors.go), otherwise no fill at all.
-func exportFillHex(f *sheets.Format) string {
+func exportFillHex(f *store.Format) string {
if f.FillHex != "" {
return f.FillHex
}
@@ -69,8 +69,8 @@ func exportFillHex(f *sheets.Format) string {
// app stores. ok is false when the style carries nothing this app
// tracks (the workbook's default style, most commonly), so the caller
// can skip writing a Format at all.
-func formatFromStyle(st *excelize.Style) (sheets.Format, bool) {
- var f sheets.Format
+func formatFromStyle(st *excelize.Style) (store.Format, bool) {
+ var f store.Format
used := false
if st.CustomNumFmt != nil && *st.CustomNumFmt != "" {
diff --git a/internal/sheets/xlsx/helpers_test.go b/internal/sheets/xlsx/helpers_test.go
index bd78d6d..0ebb55c 100644
--- a/internal/sheets/xlsx/helpers_test.go
+++ b/internal/sheets/xlsx/helpers_test.go
@@ -80,11 +80,11 @@ func openTestDB(t *testing.T) *gorm.DB {
// rows or columns yet, which Import's own EnsureRow/EnsureCol calls grow.
func newTab(t *testing.T, db *gorm.DB, sealer *seal.Sealer) *store.Handle {
t.Helper()
- sheet := sheets.Sheet{CreatedBy: 1}
+ sheet := store.Sheet{CreatedBy: 1}
if err := db.Create(&sheet).Error; err != nil {
t.Fatalf("create sheet: %v", err)
}
- tab := sheets.Tab{SheetID: sheet.ID}
+ tab := store.Tab{SheetID: sheet.ID}
if err := db.Create(&tab).Error; err != nil {
t.Fatalf("create tab: %v", err)
}
diff --git a/internal/sheets/xlsx/roundtrip_test.go b/internal/sheets/xlsx/roundtrip_test.go
index 5ea3b59..c191bbb 100644
--- a/internal/sheets/xlsx/roundtrip_test.go
+++ b/internal/sheets/xlsx/roundtrip_test.go
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
- "amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/store"
"amadan.net/oficina/sheets/internal/sheets/xlsx"
)
@@ -57,7 +56,7 @@ func TestImportedFillHexIsRetained(t *testing.T) {
// formatOf resolves (rowPos, colPos) to the real ids h.GridRows/GridCols
// hold for them and fetches the Format there, failing the test if either
// the coordinate or the format is missing.
-func formatOf(t *testing.T, h *store.Handle, rowPos, colPos int) *sheets.Format {
+func formatOf(t *testing.T, h *store.Handle, rowPos, colPos int) *store.Format {
t.Helper()
rowID, colID, ok := cellAt(h, rowPos, colPos)
if !ok {