oficina / sheets Public

store: a write reports what it changed, with the version to order it by

Live sync needs the touched set, and Set returned only an error. Set
keeps its signature — XLSX import and most of this package's tests want
exactly that — and becomes a wrapper over SetReport, which is now where
the work happens.

The report is built after both transactions commit, from the graph's
now-current cache. Built earlier it would tell a live client about an
edit a failed transaction then rolled back, and a client cannot
un-paint.

Two cases the report has to get right or the other tab is visibly
wrong: the cleared cell is added back by hand, because setBlank's
touched set is its dependents only and the origin's row is gone; and a
capped write reports an empty origin rather than the stale cached
number, because a stale value painted as fresh is worse than a blank
the client immediately replaces.

recalcAndPersist is gone — setContent was its only caller and now
inlines the pair.
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 031665f7104974f1742435d9169af31ed674686c parent 3ef248f
5 files changed, +372 −74
  • internal/sheets/store/handle_batch.go +2 −2
  • internal/sheets/store/handle_open.go +1 −1
  • internal/sheets/store/handle_report.go +164 −0
  • internal/sheets/store/handle_report_test.go +163 −0
  • internal/sheets/store/handle_set.go +42 −71
diff --git a/internal/sheets/store/handle_batch.go b/internal/sheets/store/handle_batch.go
index 2384759..22a0ca4 100644
--- a/internal/sheets/store/handle_batch.go
+++ b/internal/sheets/store/handle_batch.go
@@ -121,7 +121,7 @@ const openRecoveryBatchSize = 500
// Each origin still goes through recalcTouched's own
// calc.MaxDirtyPerPass gate individually — an origin whose own
// TransitiveDependents exceeds the cap is skipped exactly as
-// recalcAndPersist has always skipped it, left stale for a future Open
+// the ordinary Set path has always skipped it, left stale for a future Open
// (or an ordinary Set that touches it directly) to retry. Batching only
// changes when a successfully-recomputed origin's result is committed,
// never whether it is recomputed at all.
@@ -129,7 +129,7 @@ const openRecoveryBatchSize = 500
// Each flush's persistTouched call also writes one recalc marker
// (events.go) for the whole batch — up to openRecoveryBatchSize origins
// — rather than one marker per origin the way setContent's single-origin
-// recalcAndPersist does. See persistTouched's doc comment for why that
+// setContent does. See persistTouched's doc comment for why that
// granularity change is honest rather than misleading: its triggerID is
// unconditionally 0 on every call site, so a batch's marker was never
// claiming to name the one origin that caused it.
diff --git a/internal/sheets/store/handle_open.go b/internal/sheets/store/handle_open.go
index 44bdf7c..11d12d6 100644
--- a/internal/sheets/store/handle_open.go
+++ b/internal/sheets/store/handle_open.go
@@ -151,7 +151,7 @@ func buildHandle(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error
recoveryVersion := h.nextVersion
h.nextVersion++
// recoverStale (handle_batch.go) is what used to be a plain loop
- // calling recalcAndPersist once per stale key — one transaction (one
+ // recomputing and persisting once per stale key — one transaction (one
// WAL fsync) per key. That is fine at the 5,001-cell scale the
// original correctness fix was measured at, but stale is bounded by
// MaxCellsPerTab (200,000), not calc.MaxDirtyPerPass, so the real
diff --git a/internal/sheets/store/handle_report.go b/internal/sheets/store/handle_report.go
new file mode 100644
index 0000000..2b71f5f
--- /dev/null
+++ b/internal/sheets/store/handle_report.go
@@ -0,0 +1,164 @@
+package store
+
+import (
+ "fmt"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+)
+
+// ChangedCell is one cell a write altered.
+//
+// Raw is the cell's input text: what an editor should show when you
+// select it. Value is what it computes to: what a grid should paint.
+// Keeping both is the whole point — a client that only had Value would
+// replace "=A1*2" with "42" in the editor the moment somebody else's
+// edit arrived.
+//
+// Derived distinguishes the cell that was typed into from the cells that
+// changed because of it. Spec §7: derived cells carry the actor of the
+// edit that caused them, not the owner of the formula, and a derived
+// flag — so version history can say "your edit changed 400 cells" rather
+// than crediting a formula's output to whoever typed a number into a
+// different cell.
+type ChangedCell struct {
+ RowID, ColID uint64
+ Raw string
+ Value calc.Value
+ Derived bool
+}
+
+// Report is what one write changed.
+//
+// Version is the single number a client orders paints by (spec §7:
+// every message carries the cell's version, and clients discard
+// out-of-order paints by version rather than by arrival). Every cell in
+// one report shares it, because they were all written by one Set and one
+// recalculation pass — a per-cell version would be the same number
+// repeated once per cell.
+//
+// Capped is true when the edit's dependent set was wider than
+// calc.MaxDirtyPerPass, so no recalculation ran and Cells covers the
+// edited cell alone. Every dependent is still durably marked dirty and
+// will be recomputed by the next Open — nothing is lost — but a live
+// client must not paint the partial truth. It resyncs instead. This is
+// the honest error the cap exists to produce, surfaced rather than
+// swallowed.
+type Report struct {
+ Version uint64
+ Cells []ChangedCell
+ Capped bool
+}
+
+// SetReport writes a cell's new raw input, recalculates forward from it,
+// and reports every cell that changed.
+//
+// The write path is unchanged from what Set has always done, and the
+// crash-safety reasoning is the load-bearing part of it:
+//
+// For an edit to a surviving cell, the input write — plus the version
+// bump that IS the dirty marker (models.go: CacheVersion records the
+// InputVersion its cache was derived from, so bumping InputVersion
+// without yet touching CacheVersion is exactly what makes a cell look
+// dirty) — lands in one transaction. Recalculation, and persisting what
+// it computes, happens afterward in a second. A crash between the two
+// leaves precisely the state Open's crash-recovery pass exists to detect
+// and fix: no separate marker column is needed because the version gap
+// IS the marker, and clearing it is writing a CacheVersion that has
+// caught back up.
+//
+// Blanking a cell cannot use that mechanism on the cell itself — its row
+// is deleted, so there is no surviving row to carry a marker — so it
+// marks its DEPENDENTS dirty instead, atomically with the delete. See
+// setBlank and TestBlankCrashLeavesDependentsRecoverable.
+//
+// What is new is the report, and it is built AFTER both transactions
+// commit, from the graph's now-current cache. That ordering matters: a
+// report assembled before the commit would tell a live client about an
+// edit that a failed transaction then rolled back, and the client has no
+// way to un-paint it.
+func (h *Handle) SetReport(rowID, colID uint64, raw string, actor uint64) (Report, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ key, ok := h.cellKey(rowID, colID)
+ if !ok {
+ return Report{}, fmt.Errorf("store: row %d col %d is not part of tab %d",
+ rowID, colID, h.tabID)
+ }
+
+ node, blank, err := contentNode(raw)
+ if err != nil {
+ return Report{}, err
+ }
+
+ v := h.nextVersion
+ prevRaw := h.rawText[key]
+
+ var (
+ touched map[calc.CellKey]struct{}
+ capped bool
+ )
+ if blank {
+ touched, capped, err = h.setBlank(rowID, colID, key, v, actor, prevRaw)
+ } else {
+ touched, capped, err = h.setContent(rowID, colID, key, node, raw, v, actor, prevRaw)
+ }
+ if err != nil {
+ return Report{}, err
+ }
+ return h.report(key, rowID, colID, v, touched, capped), nil
+}
+
+// report turns a recomputed touched set into the cells a client paints.
+//
+// The edited cell is always present, whatever the touched set holds.
+// setBlank's touched set is its DEPENDENTS only — the origin's row is
+// gone, so it is not in the graph to be recomputed — and without this
+// the other tab would never clear the cell you just emptied, which is
+// the most visible bug this whole layer could have.
+//
+// Caller must hold h.mu.
+func (h *Handle) report(origin calc.CellKey, rowID, colID, v uint64,
+ touched map[calc.CellKey]struct{}, capped bool) Report {
+
+ rep := Report{Version: v, Capped: capped, Cells: make([]ChangedCell, 0, len(touched)+1)}
+
+ rep.Cells = append(rep.Cells, ChangedCell{
+ RowID: rowID, ColID: colID,
+ Raw: h.rawText[origin], // absent from the map once blanked, which yields ""
+ Value: h.originValue(origin, capped),
+ })
+
+ for k := range touched {
+ if k == origin {
+ continue // already appended, and never as derived
+ }
+ ids, ok := h.keyToIDs[k]
+ if !ok {
+ continue // defensive: every touched key was registered via SetFormula
+ }
+ val, _ := h.graph.CachedValue(k)
+ rep.Cells = append(rep.Cells, ChangedCell{
+ RowID: ids.RowID, ColID: ids.ColID,
+ Raw: h.rawText[k],
+ Value: val,
+ Derived: true,
+ })
+ }
+ return rep
+}
+
+// originValue reads the edited cell's current value.
+//
+// A capped write never ran Recalc, so the graph's cache for the origin
+// is whatever it held before — stale, and wrong to send. Report an empty
+// value instead and let Capped tell the client to resync; a stale number
+// painted as fresh is worse than a blank the client immediately
+// replaces.
+func (h *Handle) originValue(origin calc.CellKey, capped bool) calc.Value {
+ if capped {
+ return calc.Value{}
+ }
+ v, _ := h.graph.CachedValue(origin)
+ return v
+}
diff --git a/internal/sheets/store/handle_report_test.go b/internal/sheets/store/handle_report_test.go
new file mode 100644
index 0000000..3fcf99d
--- /dev/null
+++ b/internal/sheets/store/handle_report_test.go
@@ -0,0 +1,163 @@
+package store_test
+
+import (
+ "testing"
+
+ "amadan.net/oficina/sheets/internal/sheets/calc"
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// openSeeded is seeded() plus an Open, which is what every report test
+// wants and none of them vary.
+func openSeeded(t *testing.T) *store.Handle {
+ t.Helper()
+ db, sealer, id := seeded(t)
+ h, err := store.Open(db, sealer, id)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(h.Release)
+ return h
+}
+
+// findCell locates one coordinate in a report, so an assertion does not
+// depend on the order a map ranged in.
+func findCell(t *testing.T, rep store.Report, rowID, colID uint64) store.ChangedCell {
+ t.Helper()
+ for _, c := range rep.Cells {
+ if c.RowID == rowID && c.ColID == colID {
+ return c
+ }
+ }
+ t.Fatalf("report does not mention row %d col %d; it has %+v", rowID, colID, rep.Cells)
+ return store.ChangedCell{}
+}
+
+// The report is what the live layer fans out. If a dependent is missing
+// from it, the other tab shows a formula's old result forever.
+func TestSetReportNamesTheOriginAndItsDependents(t *testing.T) {
+ h := openSeeded(t)
+
+ // A2 = A1 * 2. A1 is seeded holding "5", so A2 starts at 10.
+ if err := h.Set(rowB, colA, "=A1*2", actor); err != nil {
+ t.Fatalf("Set A2: %v", err)
+ }
+
+ rep, err := h.SetReport(rowA, colA, "21", actor)
+ if err != nil {
+ t.Fatalf("SetReport A1: %v", err)
+ }
+ if rep.Capped {
+ t.Fatal("a two-cell recalculation reported itself capped")
+ }
+ if len(rep.Cells) != 2 {
+ t.Fatalf("report covers %d cells, want 2 (A1 and its dependent A2): %+v",
+ len(rep.Cells), rep.Cells)
+ }
+
+ origin := findCell(t, rep, rowA, colA)
+ if origin.Derived {
+ t.Error("the cell that was typed into is flagged derived")
+ }
+ if origin.Raw != "21" {
+ t.Errorf("origin raw = %q, want %q — the editor would show the wrong text", origin.Raw, "21")
+ }
+ if origin.Value.Kind != calc.KindNum || origin.Value.Num != 21 {
+ t.Errorf("origin value = %+v, want the number 21", origin.Value)
+ }
+
+ dep := findCell(t, rep, rowB, colA)
+ if !dep.Derived {
+ t.Error("a recalculated dependent is not flagged derived; history would credit the wrong edit")
+ }
+ if dep.Value.Kind != calc.KindNum || dep.Value.Num != 42 {
+ t.Errorf("A2 = %+v, want the number 42", dep.Value)
+ }
+ if dep.Raw != "=A1*2" {
+ t.Errorf("dependent raw = %q, want the formula, not its result", dep.Raw)
+ }
+}
+
+// The version is the only thing a client can order paints by. If it does
+// not advance, two concurrent editors produce a visibly wrong grid —
+// the exact bug optimistic painting is meant to hide.
+func TestSetReportVersionAdvances(t *testing.T) {
+ h := openSeeded(t)
+ first, err := h.SetReport(rowA, colA, "1", actor)
+ if err != nil {
+ t.Fatalf("SetReport: %v", err)
+ }
+ second, err := h.SetReport(rowA, colA, "2", actor)
+ if err != nil {
+ t.Fatalf("SetReport again: %v", err)
+ }
+ if second.Version <= first.Version {
+ t.Fatalf("version did not advance: %d then %d", first.Version, second.Version)
+ }
+}
+
+// setBlank's touched set is its dependents only — the origin's row is
+// deleted, so it is not in the graph to recompute. Without the report
+// adding it back, the other tab never clears the cell you just emptied.
+func TestSetReportOnBlankReportsTheClearedCell(t *testing.T) {
+ h := openSeeded(t)
+ if err := h.Set(rowA, colA, "7", actor); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ rep, err := h.SetReport(rowA, colA, "", actor)
+ if err != nil {
+ t.Fatalf("SetReport blank: %v", err)
+ }
+ cleared := findCell(t, rep, rowA, colA)
+ if cleared.Raw != "" {
+ t.Errorf("cleared cell reports raw %q, want empty", cleared.Raw)
+ }
+ if cleared.Value.Kind != calc.KindEmpty {
+ t.Errorf("cleared cell reports value %+v, want empty", cleared.Value)
+ }
+ if cleared.Derived {
+ t.Error("the cell that was cleared is flagged derived")
+ }
+}
+
+// Blanking a feeder must tell every dependent, or they keep painting a
+// number computed from a cell that no longer exists.
+func TestSetReportOnBlankReportsDependentsToo(t *testing.T) {
+ h := openSeeded(t)
+ if err := h.Set(rowB, colA, "=A1*2", actor); err != nil {
+ t.Fatalf("Set A2: %v", err)
+ }
+ rep, err := h.SetReport(rowA, colA, "", actor)
+ if err != nil {
+ t.Fatalf("SetReport blank A1: %v", err)
+ }
+ if len(rep.Cells) != 2 {
+ t.Fatalf("blanking a feeder reported %d cells, want 2: %+v", len(rep.Cells), rep.Cells)
+ }
+ dep := findCell(t, rep, rowB, colA)
+ if !dep.Derived {
+ t.Error("the dependent of a blanked cell is not flagged derived")
+ }
+ if dep.Value.Kind != calc.KindNum || dep.Value.Num != 0 {
+ t.Errorf("A2 after A1 was blanked = %+v, want 0", dep.Value)
+ }
+}
+
+// Set is now a wrapper. If it stopped writing, every existing caller —
+// XLSX import above all — would silently do nothing.
+func TestSetStillWrites(t *testing.T) {
+ h := openSeeded(t)
+ if err := h.Set(rowA, colA, "99", actor); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ if v := h.Value(rowA, colA); v.Kind != calc.KindNum || v.Num != 99 {
+ t.Fatalf("after Set, A1 = %+v, want 99", v)
+ }
+}
+
+func TestSetReportRefusesACellOutsideTheTab(t *testing.T) {
+ h := openSeeded(t)
+ if _, err := h.SetReport(999, 999, "1", actor); err == nil {
+ t.Fatal("SetReport on a row and column that are not in this tab = nil error")
+ }
+}
diff --git a/internal/sheets/store/handle_set.go b/internal/sheets/store/handle_set.go
index 3d62f17..2805ad6 100644
--- a/internal/sheets/store/handle_set.go
+++ b/internal/sheets/store/handle_set.go
@@ -1,8 +1,6 @@
package store
import (
- "fmt"
-
"gorm.io/gorm"
"amadan.net/oficina/sheets/internal/sheets"
@@ -20,42 +18,14 @@ var afterBlankTransactionOneCommits = func() error { return nil }
// Set writes a cell's new raw input and recalculates forward from it.
//
-// For an edit to a surviving cell, the input write — plus the version
-// bump that IS the dirty marker (models.go: CacheVersion records the
-// InputVersion its cache was derived from — bumping InputVersion here
-// without yet touching CacheVersion is exactly what makes this cell look
-// dirty) — lands in one transaction. Recalculation, and persisting what
-// it computes, happens afterward in a second transaction. A crash
-// between the two leaves precisely the state Open's crash-recovery pass
-// exists to detect and fix: no separate marker column is needed because
-// the version gap IS the marker, and clearing it is writing a
-// CacheVersion that has caught back up.
-//
-// Blanking a cell cannot use that mechanism on the cell itself — its row
-// is deleted, so there is no surviving row to carry a marker — so it
-// marks its DEPENDENTS dirty instead, atomically with the delete. See
-// the blank branch below and TestBlankCrashLeavesDependentsRecoverable.
+// It is SetReport with the report thrown away, and exists at this
+// signature because every caller that does not fan out over SSE — XLSX
+// import, and most of this package's tests — wants exactly that. The
+// crash-safety reasoning that used to live here moved to SetReport
+// (handle_report.go), which is now where the work happens.
func (h *Handle) Set(rowID, colID uint64, raw string, actor uint64) error {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- key, ok := h.cellKey(rowID, colID)
- if !ok {
- return fmt.Errorf("store: row %d col %d is not part of tab %d", rowID, colID, h.tabID)
- }
-
- node, blank, err := contentNode(raw)
- if err != nil {
- return err
- }
-
- v := h.nextVersion
- prevRaw := h.rawText[key]
-
- if blank {
- return h.setBlank(rowID, colID, key, v, actor, prevRaw)
- }
- return h.setContent(rowID, colID, key, node, raw, v, actor, prevRaw)
+ _, err := h.SetReport(rowID, colID, raw, actor)
+ return err
}
// setBlank deletes a cell's row (cells are sparse — models.go) and
@@ -71,7 +41,10 @@ func (h *Handle) Set(rowID, colID uint64, raw string, actor uint64) error {
// leads its own CacheVersion, and Open's ordinary per-cell recovery —
// already proven by TestStaleCacheIsRecomputedOnOpen — finds and fixes
// it without needing to know anything about deletes specifically.
-func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor uint64, prevRaw string) error {
+// It returns the dependents it actually recomputed, and whether the
+// dependent set was too wide to recompute at all — SetReport turns both
+// into a report a live client can either paint or resync from.
+func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor uint64, prevRaw string) (map[calc.CellKey]struct{}, bool, error) {
// TransitiveDependents reads g.dependents[key], which Clear (called
// below, after this transaction commits) never touches — only key's
// own precedent edges are rewired by Clear — so this is safe to
@@ -104,14 +77,14 @@ func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor
OldRaw: prevRaw, NewRaw: "",
})
}); err != nil {
- return err
+ return nil, false, err
}
// Test seam: a crash landing exactly here — transaction 1 durably
// committed, nothing after it has run yet — is what
// TestBlankCrashLeavesDependentsRecoverable simulates. Always nil in
// production.
if err := afterBlankTransactionOneCommits(); err != nil {
- return err
+ return nil, false, err
}
h.nextVersion++
delete(h.rawText, key)
@@ -126,21 +99,30 @@ func (h *Handle) setBlank(rowID, colID uint64, key calc.CellKey, v uint64, actor
// cap, so this is not silent: each one's own version gap is
// there for Open's ordinary per-cell recovery to find and fix,
// the same as any other stale cell.
- return nil
+ //
+ // capped is true only in the second of those two cases: no
+ // dependents at all is nothing to report, while too many is a
+ // partial truth a live client must not paint.
+ return nil, len(touched) > 0, nil
}
// Transaction 2: recompute the real values and persist them,
// clearing the marker transaction 1 just wrote.
h.graph.Recalc(key, newHandleEnv())
- return h.persistTouched(touched, v)
+ if err := h.persistTouched(touched, v); err != nil {
+ return nil, false, err
+ }
+ return touched, false, nil
}
// setContent writes a formula or literal cell's new input and
// recalculates forward from it.
-func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Node, raw string, v uint64, actor uint64, prevRaw string) error {
+// It returns everything the recalculation touched — the edited cell
+// included — and whether the pass was too wide to run.
+func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Node, raw string, v uint64, actor uint64, prevRaw string) (map[calc.CellKey]struct{}, bool, error) {
sealedInput, err := h.sealer.Seal("cell.input", []byte(raw))
if err != nil {
- return err
+ return nil, false, err
}
if err := h.db.Transaction(func(tx *gorm.DB) error {
res := tx.Model(&sheets.Cell{}).
@@ -167,42 +149,30 @@ func (h *Handle) setContent(rowID, colID uint64, key calc.CellKey, node calc.Nod
OldRaw: prevRaw, NewRaw: raw,
})
}); err != nil {
- return err
+ return nil, false, err
}
h.nextVersion++
h.rawText[key] = raw
h.graph.SetFormula(key, node)
h.keyToIDs[key] = cellIDs{RowID: rowID, ColID: colID}
- return h.recalcAndPersist(key, v, true)
+ touched, ok := h.recalcTouched(key, true)
+ if !ok {
+ // recalcTouched refuses only past calc.MaxDirtyPerPass here:
+ // includeOrigin puts this cell in the set, so the set is never
+ // empty on this path.
+ return nil, true, nil
+ }
+ if err := h.persistTouched(touched, v); err != nil {
+ return nil, false, err
+ }
+ return touched, false, nil
}
// markDirty is defined in handle_batch.go, alongside the crash-recovery
// batching it shares its "bounded number of statements, not one per
// touched cell" shape with.
-// recalcAndPersist propagates a change at origin to its transitive
-// dependents, evaluates every touched cell exactly once (via
-// Graph.Recalc), and persists each one's new CachedSealed/CacheVersion —
-// the step that closes the dirty marker setContent (or Open's recovery
-// pass, via recoverStale in handle_batch.go) opened. includeOrigin is
-// true whenever origin's own row survives to receive a cache write
-// (every caller except setBlank, which persists dependents directly once
-// its own recompute step is reached).
-//
-// This is the single-origin, persist-immediately shape setContent needs
-// (one Set call, one commit). Open's crash-recovery loop needs to spread
-// many origins' recomputation across few commits instead, so it calls
-// recalcTouched directly and batches the persistTouched calls itself —
-// see recoverStale.
-func (h *Handle) recalcAndPersist(origin calc.CellKey, v uint64, includeOrigin bool) error {
- touched, ok := h.recalcTouched(origin, includeOrigin)
- if !ok {
- return nil
- }
- return h.persistTouched(touched, v)
-}
-
// recalcTouched computes origin's touched set — TransitiveDependents,
// plus origin itself if includeOrigin — and, if it is within
// calc.MaxDirtyPerPass, runs Graph.Recalc so every touched cell's
@@ -213,11 +183,12 @@ func (h *Handle) recalcAndPersist(origin calc.CellKey, v uint64, includeOrigin b
// duplicating) Recalc's own cap. See calc.MaxDirtyPerPass's comment for
// why an over-wide pass is refused rather than run.
//
-// Split out of what used to be recalcAndPersist's body so a caller that
-// wants to recompute several origins before persisting any of them
+// Split out from the recompute-and-persist pair so a caller that wants
+// to recompute several origins before persisting any of them
// (recoverStale, handle_batch.go) can do the cheap in-memory part here
// and defer the expensive part — a transaction commit — to its own
-// batching.
+// batching. setContent and setBlank call it and then persistTouched
+// directly, one commit per Set.
func (h *Handle) recalcTouched(origin calc.CellKey, includeOrigin bool) (map[calc.CellKey]struct{}, bool) {
touched := h.graph.TransitiveDependents(origin)
if includeOrigin {