feat: commit cell paste batches atomically with one live update
8 files changed,
+481
−40
docs/superpowers/specs/2026-09-07-atomic-cell-batch.md+12 −0internal/sheets/app.go+1 −0internal/sheets/handlers_cell_batch.go+65 −0internal/sheets/handlers_cell_batch_test.go+130 −0internal/sheets/static/cellqueue.js+16 −37internal/sheets/static/grid.js+3 −3internal/sheets/store/handle_write_batch.go+92 −0internal/sheets/store/handle_write_batch_test.go+162 −0
CI passed — run details
Intent
Human
You are the independent Claude Fable review buddy for a user-authorized overnight suite implementation. Read only; do not edit files or deploy. Review docs/superpowers/specs/2026-09-07-atomic-cell-batch.md against AGENTS.md and relevant handle/grid/store code. Focus on transactional handle isolation, deadlocks, consistency, security, meaningful validation, unhandled failures. Report APPROVE or REQUEST CHANGES with concrete findings. Scope is atomic batch writes; tabs are subsequent work. You may use read tools only.
diff --git a/docs/superpowers/specs/2026-09-07-atomic-cell-batch.md b/docs/superpowers/specs/2026-09-07-atomic-cell-batch.md| new file mode 100644 |
| index 0000000..8fb268f |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-07-atomic-cell-batch.md |
| @@ -0,0 +1,12 @@ |
| +# Atomic cell batch implementation contract |
| + |
| +Status: proposed; Fable review pending. Implements the overnight suite readiness request, under the existing Sheets design and next-three batch-route item. |
| + |
| +- POST /api/sheets/{sheetID}/tabs/{tabID}/cells/batch accepts {cells: [{r,c,raw,fromR?,fromC?}], client?}. Require an editor using openTab, preserving sheet/tab pairing and 404 isolation. |
| +- Limit to 500 distinct destinations and 1 MiB body. Validate every coordinate, copy origin, raw length, translated raw length, and formula before any committed writes. Duplicate destinations, empty batches, and trailing JSON are invalid. Reuse existing fixed response strings; add no UI copy. |
| +- Hold the live handle mutex while a transaction-scoped independent handle is built from the DB. It shares no mutable graph or maps with the live handle. Set its next version at least to the live handle version. Ensure rows/columns and perform existing SetReport operations through that transaction handle. Existing nested transactions become savepoints; only the outer transaction commits. Reject capped recalculation and over-limit resulting cell count atomically. |
| +- Preserve existing per-cell input history and recalculation semantics. Merge reports by coordinate, keeping final values and marking directly edited cells as direct. Return only after outer commit; swap graph/maps/version into the live handle after commit. On failure discard the transaction handle; no partial history, axes, data, or live publication. |
| +- One response and SSE publication contain all changed cells and the final version. Keep the current 500-cell paste cap. The existing cell queue submits one batch instead of one request per cell. Larger clear selections retain serial batches of 500; a failed batch must stop the queue. Existing post currently absorbs failures; make it return explicit success for queue control while preserving existing callers. |
| +- No format, row/column semantics, tab lifecycle, formula grammar, or permission changes. Clipboard provenance is a separate next slice; this changes only transmission, leaving clipboard.js untouched. |
| +- Cost: a second tab graph is built per batch. Measure a 500-cell batch and document this bounded first implementation. Avoid adding a graph clone API or redesigning calculation before evidence demands it. |
| +- Tests: dependent formulas and clears, overlapping formulas, encryption/history, invalid late formula, injected persistence failure after earlier writes, axis rollback, shared live handle unchanged on failure, viewer/stranger/cross-sheet isolation, request bounds, one publication, client queue one-request and stop-on-failure behavior. Run make test and exact-head CI. Build with make release, deploy isolated canary, run browser checks, obtain Fable branch review, then coordinate squash merge and production deployment with root. |
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index e7a000d..0f028cb 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -231,6 +231,7 @@ func (a *App) Router() http.Handler { |
| // ask for it in. |
| r.Get("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellsGet) |
| r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells", a.handleCellPost) |
| + r.Post("/api/sheets/{sheetID}/tabs/{tabID}/cells/batch", a.handleCellBatchPost) |
| r.Post("/api/sheets/{sheetID}/tabs/{tabID}/format", a.handleFormatPost) |
| r.Post("/api/sheets/{sheetID}/tabs/{tabID}/size", a.handleSizePost) |
| r.Get("/api/sheets/{sheetID}/tabs/{tabID}/stream", a.handleStream) |
diff --git a/internal/sheets/handlers_cell_batch.go b/internal/sheets/handlers_cell_batch.go| new file mode 100644 |
| index 0000000..1cb390a |
| --- /dev/null |
| +++ b/internal/sheets/handlers_cell_batch.go |
| @@ -0,0 +1,65 @@ |
| +package sheets |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +func (a *App) handleCellBatchPost(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, SheetEditor) |
| + if !ok { |
| + return |
| + } |
| + defer h.Release() |
| + |
| + var in struct { |
| + Cells []cellWrite `json:"cells"` |
| + Client string `json:"client"` |
| + } |
| + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) |
| + if err := dec.Decode(&in); err != nil { |
| + http.Error(w, "Malformed request.", http.StatusBadRequest) |
| + return |
| + } |
| + var extra any |
| + if err := dec.Decode(&extra); err != io.EOF || len(in.Cells) == 0 || len(in.Cells) > store.MaxCellBatch { |
| + http.Error(w, "Malformed request.", http.StatusBadRequest) |
| + return |
| + } |
| + writes := make([]store.CellWrite, len(in.Cells)) |
| + for i, cell := range in.Cells { |
| + dr, dc, valid := cell.origin() |
| + if !inCellRange(cell.R, cell.C) || !valid || (cell.FromR == nil) != (cell.FromC == nil) { |
| + http.Error(w, "Cell out of range.", http.StatusBadRequest) |
| + return |
| + } |
| + if len(cell.Raw) > maxRawBytes { |
| + http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge) |
| + return |
| + } |
| + raw := translateCopied(cell.Raw, dr, dc) |
| + if len(raw) > maxRawBytes { |
| + http.Error(w, "Cell input too long.", http.StatusRequestEntityTooLarge) |
| + return |
| + } |
| + writes[i] = store.CellWrite{Row: cell.R, Col: cell.C, Raw: raw} |
| + } |
| + rep, err := h.SetBatch(writes, actor.ID) |
| + if err != nil { |
| + http.Error(w, "That cell could not be written.", http.StatusBadRequest) |
| + return |
| + } |
| + cells, styles := fromReport(rep, newPosIndex(h), h) |
| + resp := gridResponse{Version: rep.Version, Cells: cells, Formats: styles, Client: in.Client} |
| + a.touchSheet(sheetIDFrom(r)) |
| + a.publish(tabIDFrom(r), resp) |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
diff --git a/internal/sheets/handlers_cell_batch_test.go b/internal/sheets/handlers_cell_batch_test.go| new file mode 100644 |
| index 0000000..6bf7103 |
| --- /dev/null |
| +++ b/internal/sheets/handlers_cell_batch_test.go |
| @@ -0,0 +1,130 @@ |
| +package sheets |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| +) |
| + |
| +func TestCellBatchPublishesOnlyFinalCommittedValues(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, path := newSheet(t, app, cookies, "Budget") |
| + sub := app.hub.Subscribe(s.TabID) |
| + defer sub.Close() |
| + rec := postCell(t, app, cookies, path+"/batch", `{"cells":[{"r":1,"c":0,"raw":"=A1*2"},{"r":0,"c":0,"raw":"21"}],"client":"batch-client"}`) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("batch %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + got := decodeGrid(t, rec) |
| + if len(got.Cells) != 2 || got.Client != "batch-client" { |
| + t.Fatalf("response %+v", got) |
| + } |
| + for _, c := range got.Cells { |
| + if c.R == 1 && c.Display != "42" { |
| + t.Fatalf("stale formula %+v", c) |
| + } |
| + } |
| + select { |
| + case m := <-sub.C(): |
| + if string(m.Data) != strings.TrimSpace(rec.Body.String()) { |
| + t.Fatalf("publication differs: %s", m.Data) |
| + } |
| + default: |
| + t.Fatal("no publication") |
| + } |
| + select { |
| + case <-sub.C(): |
| + t.Fatal("intermediate publication") |
| + default: |
| + } |
| + var events int64 |
| + app.db.Model(&store.Event{}).Where("tab_id = ? AND kind = ?", s.TabID, store.KindInput).Count(&events) |
| + if events != 2 { |
| + t.Fatalf("input events = %d", events) |
| + } |
| +} |
| + |
| +func TestCellBatchRejectsInvalidRequestsWithoutWrites(t *testing.T) { |
| + for _, body := range []string{ |
| + `{}`, `{"cells":[]}`, `{"cells":[{"r":0,"c":0,"raw":"7"}]} {}`, |
| + `{"cells":[{"r":0,"c":0,"raw":"7"},{"r":0,"c":0,"raw":"8"}]}`, |
| + `{"cells":[{"r":0,"c":0,"raw":"7"},{"r":-1,"c":0,"raw":"8"}]}`, |
| + `{"cells":[{"r":0,"c":0,"raw":"7"},{"r":1,"c":0,"raw":"=SUM(("}]}`, |
| + `{"cells":[{"r":0,"c":0,"raw":"7","fromR":0}]}`, |
| + `{"cells":[{"r":0,"c":0,"raw":"7","fromR":-1,"fromC":0}]}`, |
| + `{"cells":[{"r":0,"c":0,"raw":"` + strings.Repeat("x", maxRawBytes+1) + `"}]}`, |
| + `{"cells":[{"r":0,"c":0,"raw":"` + strings.Repeat("x", 1<<20) + `"}]}`, |
| + } { |
| + app, cookies := signIn(t) |
| + s, path := newSheet(t, app, cookies, "Budget") |
| + sub := app.hub.Subscribe(s.TabID) |
| + rec := postCell(t, app, cookies, path+"/batch", body) |
| + if rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge { |
| + t.Fatalf("invalid status %d", rec.Code) |
| + } |
| + if strings.Contains(rec.Body.String(), "SUM") || strings.Contains(rec.Body.String(), "xxx") { |
| + t.Fatal("content in error") |
| + } |
| + snap := decodeGrid(t, do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies))) |
| + if len(snap.Cells) != 0 { |
| + t.Fatalf("invalid batch wrote %+v", snap.Cells) |
| + } |
| + select { |
| + case <-sub.C(): |
| + t.Fatal("failed batch published") |
| + default: |
| + } |
| + sub.Close() |
| + } |
| + app, cookies := signIn(t) |
| + _, path := newSheet(t, app, cookies, "Budget") |
| + cells := make([]cellWrite, store.MaxCellBatch+1) |
| + for i := range cells { |
| + cells[i] = cellWrite{R: i, Raw: "1"} |
| + } |
| + body, _ := json.Marshal(map[string]any{"cells": cells}) |
| + if rec := postCell(t, app, cookies, path+"/batch", string(body)); rec.Code != http.StatusBadRequest { |
| + t.Fatalf("oversized batch = %d", rec.Code) |
| + } |
| +} |
| + |
| +func TestCellBatchUsesCopyTranslationAndRejectsWrongTab(t *testing.T) { |
| + app, cookies := signIn(t) |
| + s, path := newSheet(t, app, cookies, "Budget") |
| + rec := postCell(t, app, cookies, path+"/batch", `{"cells":[{"r":1,"c":0,"raw":"=A1+$B$1","fromR":0,"fromC":0}]}`) |
| + if rec.Code != 200 { |
| + t.Fatalf("copy %d", rec.Code) |
| + } |
| + if got := decodeGrid(t, rec).Cells[0].Raw; got != "=A2+$B$1" { |
| + t.Fatalf("translated = %q", got) |
| + } |
| + other, _ := newSheet(t, app, cookies, "Other") |
| + wrong := fmt.Sprintf("/api/sheets/%d/tabs/%d/cells/batch", s.ID, other.TabID) |
| + if rec := postCell(t, app, cookies, wrong, `{"cells":[{"r":0,"c":0,"raw":"1"}]}`); rec.Code != 404 { |
| + t.Fatalf("cross sheet = %d", rec.Code) |
| + } |
| + if rec := postCell(t, app, nil, path+"/batch", `{"cells":[{"r":0,"c":0,"raw":"1"}]}`); rec.Code == 200 { |
| + t.Fatal("unsigned write accepted") |
| + } |
| +} |
| + |
| +func TestCellBatchEnforcesViewerCommenterAndStrangerIsolation(t *testing.T) { |
| + for _, role := range []SheetRole{SheetViewer, SheetCommenter} { |
| + app, cookies := signIn(t) |
| + s, path := newSheet(t, app, cookies, "Budget") |
| + guestOn(t, app, s.ID, "ada@elsewhere.example", role) |
| + other, _ := newSheet(t, app, cookies, "Other") |
| + guestOn(t, app, other.ID, "stranger@elsewhere.example", SheetEditor) |
| + for _, email := range []string{"ada@elsewhere.example", "stranger@elsewhere.example"} { |
| + guest := signInAs(t, app, email) |
| + if rec := postCell(t, app, guest, path+"/batch", `{"cells":[{"r":0,"c":0,"raw":"1"}]}`); rec.Code != 404 { |
| + t.Fatalf("%s %s batch = %d", role, email, rec.Code) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/sheets/static/cellqueue.js b/internal/sheets/static/cellqueue.js| index 3e4124c..9c35585 100644 |
| --- a/internal/sheets/static/cellqueue.js |
| +++ b/internal/sheets/static/cellqueue.js |
| @@ -1,43 +1,22 @@ |
| -/* Send a batch of cell writes, safely. |
| - * |
| - * Its own module because "how do we send N cell writes without losing |
| - * one" is a different question from "what did the person copy", and |
| - * because the answer is subtle enough to be worth stating once in a |
| - * place both callers can read. |
| - * |
| - * **The writes go out ONE AT A TIME, and that is the whole point.** |
| - * |
| - * The first version sent six at once, on the reasonable ground that a |
| - * 200-cell paste in series is 200 sequential round trips. Responses to |
| - * concurrent writes come back in whatever order the server finishes |
| - * them, and grid.js correctly discards a paint whose version is older |
| - * than one already applied — so a six-cell paste landed five on screen. |
| - * Every cell was stored; one simply never painted, which looks exactly |
| - * like a cell that was never sent. |
| - * |
| - * The second version kept the six lanes and added a refetch afterwards |
| - * to repair them. That was worse: refetch() zeroes grid.js's version |
| - * before applying, so a snapshot still in flight when a live edit |
| - * arrives from another tab paints over that newer edit. A repair that |
| - * introduces a race is not a repair. |
| - * |
| - * Serial writes have neither problem. Versions arrive monotonically, |
| - * nothing is discarded, and no snapshot is needed to fix anything. It is |
| - * slower per cell, which is what the caller's own size cap is sized |
| - * against — the honest trade until a batch cell route exists, which is |
| - * the real answer and is named in the slice spec's deferred table. |
| - */ |
| (function () { |
| 'use strict'; |
| - /* jobs is an array of cell-write bodies; post is grid.js's own post, |
| - * passed in rather than reached for, so this module never has to know |
| - * whether the grid booted. */ |
| + var tail = Promise.resolve(); |
| window.SheetsCellQueue = function (jobs, post, done) { |
| - var i = 0; |
| - (function step() { |
| - if (i >= jobs.length) { if (done) done(); return; } |
| - post('/cells', jobs[i++]).then(step, step); |
| - }()); |
| + var run = function () { |
| + var i = 0; |
| + function step() { |
| + if (i >= jobs.length) { if (done) done(); return true; } |
| + var cells = jobs.slice(i, i + 500); |
| + i += cells.length; |
| + return post('/cells/batch', { cells: cells }).then(function (ok) { |
| + if (ok === true) return step(); |
| + return false; |
| + }, function () { return false; }); |
| + } |
| + return step(); |
| + }; |
| + tail = tail.then(run, run); |
| + return tail; |
| }; |
| }()); |
diff --git a/internal/sheets/static/grid.js b/internal/sheets/static/grid.js| index 6ab046d..b6c2f0b 100644 |
| --- a/internal/sheets/static/grid.js |
| +++ b/internal/sheets/static/grid.js |
| @@ -150,9 +150,9 @@ |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body) |
| }).then(function (resp) { |
| - if (!resp.ok) { refetch(); return; } |
| - return resp.json().then(applyBatch); |
| - }).catch(refetch); |
| + if (!resp.ok) { refetch(); return false; } |
| + return resp.json().then(function (payload) { applyBatch(payload); return true; }); |
| + }).catch(function () { refetch(); return false; }); |
| } |
| function write(r, c, text) { |
diff --git a/internal/sheets/store/handle_write_batch.go b/internal/sheets/store/handle_write_batch.go| new file mode 100644 |
| index 0000000..a1f0fa8 |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_write_batch.go |
| @@ -0,0 +1,92 @@ |
| +package store |
| + |
| +import ( |
| + "errors" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +const MaxCellBatch = 500 |
| + |
| +type CellWrite struct { |
| + Row, Col int |
| + Raw string |
| +} |
| + |
| +var errCellBatch = errors.New("store: invalid cell batch") |
| + |
| +func (h *Handle) SetBatch(writes []CellWrite, actor uint64) (Report, error) { |
| + if len(writes) == 0 || len(writes) > MaxCellBatch { |
| + return Report{}, errCellBatch |
| + } |
| + seen := make(map[[2]int]bool, len(writes)) |
| + for _, w := range writes { |
| + pos := [2]int{w.Row, w.Col} |
| + if w.Row < 0 || w.Col < 0 || seen[pos] { |
| + return Report{}, errCellBatch |
| + } |
| + seen[pos] = true |
| + if _, _, err := contentNode(w.Raw); err != nil { |
| + return Report{}, errCellBatch |
| + } |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + |
| + var staged *Handle |
| + var result Report |
| + err := h.db.Transaction(func(tx *gorm.DB) error { |
| + var err error |
| + staged, err = buildHandle(tx, h.sealer, h.tabID) |
| + if err != nil { |
| + return err |
| + } |
| + if staged.nextVersion < h.nextVersion { |
| + staged.nextVersion = h.nextVersion |
| + } |
| + changed := make(map[CellCoord]ChangedCell) |
| + direct := make(map[CellCoord]bool) |
| + for _, w := range writes { |
| + r, err := staged.EnsureRow(w.Row) |
| + if err != nil { |
| + return err |
| + } |
| + c, err := staged.EnsureCol(w.Col) |
| + if err != nil { |
| + return err |
| + } |
| + rep, err := staged.SetReport(r, c, w.Raw, actor) |
| + if err != nil { |
| + return err |
| + } |
| + if rep.Capped { |
| + return errCellBatch |
| + } |
| + direct[CellCoord{r, c}] = true |
| + result.Version = rep.Version |
| + for _, cell := range rep.Cells { |
| + changed[CellCoord{cell.RowID, cell.ColID}] = cell |
| + } |
| + } |
| + var count int64 |
| + if err := tx.Model(&Cell{}).Where("tab_id = ?", h.tabID).Count(&count).Error; err != nil { |
| + return err |
| + } |
| + if count > MaxCellsPerTab { |
| + return ErrTabTooLarge |
| + } |
| + for coord, cell := range changed { |
| + cell.Derived = !direct[coord] |
| + result.Cells = append(result.Cells, cell) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return Report{}, err |
| + } |
| + h.graph, h.rawText, h.keyToIDs = staged.graph, staged.rawText, staged.keyToIDs |
| + h.rowPos, h.colPos = staged.rowPos, staged.colPos |
| + h.rowByPos, h.colByPos = staged.rowByPos, staged.colByPos |
| + h.nextVersion = staged.nextVersion |
| + return result, nil |
| +} |
diff --git a/internal/sheets/store/handle_write_batch_test.go b/internal/sheets/store/handle_write_batch_test.go| new file mode 100644 |
| index 0000000..4e64012 |
| --- /dev/null |
| +++ b/internal/sheets/store/handle_write_batch_test.go |
| @@ -0,0 +1,162 @@ |
| +package store_test |
| + |
| +import ( |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| + "amadan.net/oficina/sheets/internal/sheets/sealtest" |
| + "errors" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/store" |
| + "gorm.io/gorm" |
| +) |
| + |
| +func TestCellBatchFinalValuesAndDirectOrigins(t *testing.T) { |
| + h := openSeeded(t) |
| + before := h.Version() |
| + rep, err := h.SetBatch([]store.CellWrite{{Row: 1, Col: 0, Raw: "=A1*2"}, {Row: 0, Col: 0, Raw: "21"}}, actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rep.Version <= before || h.Version() <= rep.Version { |
| + t.Fatalf("versions %d %d %d", before, rep.Version, h.Version()) |
| + } |
| + for _, c := range rep.Cells { |
| + if c.Derived { |
| + t.Fatalf("edited origin marked derived: %+v", c) |
| + } |
| + } |
| + if got := findCell(t, rep, rowB, colA); got.Value.Num != 42 { |
| + t.Fatalf("final formula = %+v", got) |
| + } |
| + rep, err = h.SetBatch([]store.CellWrite{{Row: 0, Col: 0, Raw: ""}}, actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c := findCell(t, rep, rowB, colA); c.Value.Num != 0 || !c.Derived { |
| + t.Fatalf("clear dependent = %+v", c) |
| + } |
| +} |
| + |
| +func TestCellBatchRollsBackStorageHistoryAxesAndLiveHandle(t *testing.T) { |
| + db, sealer, id := seeded(t) |
| + h, err := store.Open(db, sealer, id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer h.Release() |
| + other, err := store.Open(db, sealer, id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer other.Release() |
| + before := h.Version() |
| + var history int64 |
| + db.Model(&store.Event{}).Count(&history) |
| + calls := 0 |
| + if err := db.Callback().Create().Before("gorm:create").Register("batch-failure", func(tx *gorm.DB) { |
| + if tx.Statement.Table == "events" { |
| + calls++ |
| + if calls == 3 { |
| + tx.AddError(errors.New("injected failure")) |
| + } |
| + } |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer db.Callback().Create().Remove("batch-failure") |
| + _, err = h.SetBatch([]store.CellWrite{{Row: 0, Col: 0, Raw: "21"}, {Row: 30, Col: 40, Raw: "99"}}, actor) |
| + if err == nil { |
| + t.Fatal("injected persistence failure accepted") |
| + } |
| + if got := other.Value(rowA, colA).Num; got != 5 { |
| + t.Fatalf("live graph changed: %v", got) |
| + } |
| + if h.Version() != before { |
| + t.Fatal("failed batch advanced version") |
| + } |
| + var now int64 |
| + db.Model(&store.Event{}).Count(&now) |
| + if now != history { |
| + t.Fatalf("history %d -> %d", history, now) |
| + } |
| + db.Model(&store.Row{}).Where("tab_id = ? AND position = ?", id, 30).Count(&now) |
| + if now != 0 { |
| + t.Fatal("row survived rollback") |
| + } |
| + db.Model(&store.Col{}).Where("tab_id = ? AND position = ?", id, 40).Count(&now) |
| + if now != 0 { |
| + t.Fatal("column survived rollback") |
| + } |
| + db.Callback().Create().Remove("batch-failure") |
| + rep, err := h.SetBatch([]store.CellWrite{{Row: 0, Col: 0, Raw: "8"}}, actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if findCell(t, rep, rowA, colA).Value.Num != 8 || other.Value(rowA, colA).Num != 8 { |
| + t.Fatal("live handle failed to recover") |
| + } |
| +} |
| + |
| +func TestCellBatchValidatesAllInputsBeforeWriting(t *testing.T) { |
| + for _, writes := range [][]store.CellWrite{ |
| + nil, |
| + {{Row: 0, Col: 0, Raw: "7"}, {Row: 30, Col: 20, Raw: "=SUM(("}}, |
| + {{Row: 0, Col: 0, Raw: "7"}, {Row: 0, Col: 0, Raw: "8"}}, |
| + {{Row: -1, Col: 0, Raw: "7"}}, |
| + make([]store.CellWrite, store.MaxCellBatch+1), |
| + } { |
| + h := openSeeded(t) |
| + v := h.Version() |
| + if _, err := h.SetBatch(writes, actor); err == nil { |
| + t.Fatal("invalid batch accepted") |
| + } |
| + if h.Value(rowA, colA).Num != 5 || h.Version() != v { |
| + t.Fatal("invalid batch changed cell") |
| + } |
| + } |
| +} |
| + |
| +func TestCellBatchFiveHundredCells(t *testing.T) { |
| + h := openSeeded(t) |
| + writes := make([]store.CellWrite, store.MaxCellBatch) |
| + for i := range writes { |
| + writes[i] = store.CellWrite{Row: i, Col: 1, Raw: "=A1*2"} |
| + } |
| + start := time.Now() |
| + rep, err := h.SetBatch(writes, actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rep.Cells) != store.MaxCellBatch { |
| + t.Fatalf("got %d cells", len(rep.Cells)) |
| + } |
| + for _, c := range rep.Cells { |
| + if c.Value.Num != 10 { |
| + t.Fatalf("value = %+v", c) |
| + } |
| + } |
| + t.Logf("500-cell atomic batch: %s", time.Since(start)) |
| +} |
| + |
| +func TestCellBatchKeepsCellsAndHistorySealed(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "test.db") |
| + db := openTestDB(t, path) |
| + sealer, err := seal.New(make32()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := db.Create(&store.Tab{ID: tabID, SheetID: 1}).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + h, err := store.Open(db, sealer, tabID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer h.Release() |
| + if _, err := h.SetBatch([]store.CellWrite{{Row: 0, Col: 0, Raw: string(sealtest.Canaries[0])}, {Row: 1, Col: 0, Raw: "=A1"}}, actor); err != nil { |
| + t.Fatal(err) |
| + } |
| + sealtest.AssertNoPlaintext(t, path) |
| +} |