oficina / sheets Public

Make the Sheets version contract explicit for native clients

store.Open took a recovery stamp unconditionally, so sequential writes
advanced the tab version by two and the native app refetched on every
other write and every multi-cell edit (B12). Open now stamps only when
cells are stale, write responses carry the stamps they used (1 per cell
write, N per batch, absent for format/size/GET), and the native client
refetches only on resync or a jump past what it was told. Web grid
behaviour is unchanged. Proven against real server responses.
Reviewed by Fable 5.1 on #36; supersedes #35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from fix/r1-native-version-contract into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b9a92e3325ceecbeca08b1a58e5e1cb65d2b626f parent c55252e
11 files changed, +239 −6
  • internal/sheets/grid.go +10 −1
  • internal/sheets/gridstyle.go +1 −0
  • internal/sheets/handlers_cell_batch.go +1 −1
  • internal/sheets/handlers_grid.go +1 −0
  • internal/sheets/store/handle_open.go +11 −2
  • internal/sheets/store/handle_report.go +7 −1
  • internal/sheets/store/handle_write_batch.go +1 −0
  • internal/sheets/version_contract_test.go +125 −0
  • native/apple/Sources/SheetsKit/SheetModel.swift +7 −1
  • native/apple/Sources/SheetsKit/SheetWire.swift +4 −0
  • native/apple/Tests/SheetsKitTests/VersionGapTests.swift +71 −0

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== migrations ===
rastrillo migration check
rastrillo migration check: models and migrations agree
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/sheets/cmd/sheets	[no test files]
ok  	amadan.net/oficina/sheets/internal/guardstest	0.079s
ok  	amadan.net/oficina/sheets/internal/sheets	10.961s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.007s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.004s
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.054s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.004s
ok  	amadan.net/oficina/sheets/internal/sheets/store	3.402s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.288s
?   	amadan.net/oficina/sheets/tools/gencorpus	[no test files]
=== race ===
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
ok  	amadan.net/oficina/sheets/internal/sheets/store	45.917s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.059s
diff --git a/internal/sheets/grid.go b/internal/sheets/grid.go
index b32d0b1..14f4c18 100644
--- a/internal/sheets/grid.go
+++ b/internal/sheets/grid.go
@@ -63,7 +63,16 @@ type gridResponse struct {
Cols int `json:"cols,omitempty"`
Cells []gridCell `json:"cells"`
Resync bool `json:"resync,omitempty"`
- Client string `json:"client,omitempty"`
+ // Stamps is how many versions the write behind this response
+ // consumed (store.Report.Stamps): 1 for a cell, N for an N-cell
+ // batch, absent for format, size and snapshot responses, which
+ // consume none. A client holding version H missed an edit iff
+ // Version > H + max(Stamps, 1); the one of slack is because those
+ // stampless responses report the NEXT stamp (Handle.Version), not
+ // the last one issued. Native's SheetModel.accept is the reader;
+ // grid.js only drops older payloads and ignores it.
+ Stamps int `json:"stamps,omitempty"`
+ Client string `json:"client,omitempty"`
// Formats is every distinct style the cells above refer to, keyed
// by the id each cell's F names. Absent when nothing in the batch
// is formatted.
diff --git a/internal/sheets/gridstyle.go b/internal/sheets/gridstyle.go
index 893514b..a36eb78 100644
--- a/internal/sheets/gridstyle.go
+++ b/internal/sheets/gridstyle.go
@@ -155,5 +155,6 @@ func (a *App) publishReport(s SheetSummary, h *store.Handle, rep store.Report) {
Cells: cells,
Formats: styles,
Resync: rep.Capped,
+ Stamps: rep.Stamps,
})
}
diff --git a/internal/sheets/handlers_cell_batch.go b/internal/sheets/handlers_cell_batch.go
index 1cb390a..e794bf6 100644
--- a/internal/sheets/handlers_cell_batch.go
+++ b/internal/sheets/handlers_cell_batch.go
@@ -58,7 +58,7 @@ func (a *App) handleCellBatchPost(w http.ResponseWriter, r *http.Request) {
return
}
cells, styles := fromReport(rep, newPosIndex(h), h)
- resp := gridResponse{Version: rep.Version, Cells: cells, Formats: styles, Client: in.Client}
+ resp := gridResponse{Version: rep.Version, Cells: cells, Formats: styles, Stamps: rep.Stamps, Client: in.Client}
a.touchSheet(sheetIDFrom(r))
a.publish(tabIDFrom(r), resp)
writeJSON(w, http.StatusOK, resp)
diff --git a/internal/sheets/handlers_grid.go b/internal/sheets/handlers_grid.go
index 410c930..ad1f098 100644
--- a/internal/sheets/handlers_grid.go
+++ b/internal/sheets/handlers_grid.go
@@ -177,6 +177,7 @@ func (a *App) handleCellPost(w http.ResponseWriter, r *http.Request) {
Cells: cells,
Formats: styles,
Resync: rep.Capped,
+ Stamps: rep.Stamps,
Client: in.Client,
}
a.touchSheet(sheetIDFrom(r))
diff --git a/internal/sheets/store/handle_open.go b/internal/sheets/store/handle_open.go
index 8f3498b..83d3236 100644
--- a/internal/sheets/store/handle_open.go
+++ b/internal/sheets/store/handle_open.go
@@ -154,8 +154,17 @@ func buildHandle(db *gorm.DB, sealer *seal.Sealer, tabID uint64) (*Handle, error
// A single stamp taken from h.nextVersion is, by construction, greater
// than every InputVersion loaded above, so it can never regress
// anything it touches.
- recoveryVersion := h.nextVersion
- h.nextVersion++
+ //
+ // Only when something is stale. Taken unconditionally, every Open
+ // consumed a stamp nothing was written under, and because a handle
+ // is evicted at refcount zero (Release), sequential requests each
+ // re-open it: consecutive writes then jumped by two, and a client
+ // could not tell that from a write it missed (discussion #35).
+ var recoveryVersion uint64
+ if len(stale) > 0 {
+ recoveryVersion = h.nextVersion
+ h.nextVersion++
+ }
// recoverStale (handle_batch.go) is what used to be a plain loop
// recomputing and persisting once per stale key — one transaction (one
// WAL fsync) per key. That is fine at the 5,001-cell scale the
diff --git a/internal/sheets/store/handle_report.go b/internal/sheets/store/handle_report.go
index 835298f..ff7a414 100644
--- a/internal/sheets/store/handle_report.go
+++ b/internal/sheets/store/handle_report.go
@@ -47,6 +47,10 @@ type Report struct {
Version uint64
Cells []ChangedCell
Capped bool
+ // Stamps is how many versions this write consumed: one per typed
+ // cell, ending at Version. A client that last saw version H and
+ // receives this report missed nothing iff Version <= H + Stamps.
+ Stamps int
}
// Version is the version a snapshot taken right now would carry.
@@ -123,7 +127,9 @@ func (h *Handle) SetReport(rowID, colID uint64, raw string, actor uint64) (Repor
if err != nil {
return Report{}, err
}
- return h.report(key, rowID, colID, v, touched, capped), nil
+ rep := h.report(key, rowID, colID, v, touched, capped)
+ rep.Stamps = 1
+ return rep, nil
}
// report turns a recomputed touched set into the cells a client paints.
diff --git a/internal/sheets/store/handle_write_batch.go b/internal/sheets/store/handle_write_batch.go
index f3a2378..74f4340 100644
--- a/internal/sheets/store/handle_write_batch.go
+++ b/internal/sheets/store/handle_write_batch.go
@@ -92,5 +92,6 @@ func (h *Handle) SetBatch(writes []CellWrite, actor uint64) (Report, error) {
h.rowPos, h.colPos = staged.rowPos, staged.colPos
h.rowByPos, h.colByPos = staged.rowByPos, staged.colByPos
h.nextVersion = staged.nextVersion
+ result.Stamps = len(writes) // one SetReport, so one stamp, per write
return result, nil
}
diff --git a/internal/sheets/version_contract_test.go b/internal/sheets/version_contract_test.go
new file mode 100644
index 0000000..f88e83b
--- /dev/null
+++ b/internal/sheets/version_contract_test.go
@@ -0,0 +1,125 @@
+package sheets
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// versionFollower mirrors native SheetModel.accept (native/apple/Sources/
+// SheetsKit/SheetModel.swift), the one client that detects missed edits
+// from the version: drop anything older than what it holds, refetch on
+// resync or when the version moved further than the response accounts
+// for, otherwise apply. Change the two together.
+type versionFollower struct {
+ have uint64
+ refetches int
+}
+
+func (f *versionFollower) missed(g gridResponse) bool {
+ allowed := uint64(max(g.Stamps, 1))
+ return g.Resync || g.Version > f.have+allowed
+}
+
+// accept returns whether the response made the follower refetch.
+func (f *versionFollower) accept(t *testing.T, g gridResponse, refetch func() gridResponse) bool {
+ t.Helper()
+ if g.Version < f.have {
+ return false
+ }
+ if f.missed(g) {
+ f.refetches++
+ f.have = refetch().Version
+ return true
+ }
+ f.have = g.Version
+ return false
+}
+
+// B12 and its review (#35): replay REAL server responses — cell writes,
+// batches (with a derived formula cell), a format, a size, snapshots —
+// through the native rule. With nothing missed, nothing may refetch: an
+// Open used to take a stamp every time, and the handle is evicted between
+// requests, so sequential writes jumped by two and every other one
+// refetched. With one write missed, the next response of every kind must
+// refetch, including a format, whose N cells consume no stamp.
+func TestNativeVersionContractAgainstRealResponses(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Budget")
+ snapshot := func() gridResponse {
+ t.Helper()
+ rec := do(t, app, as(httptest.NewRequest(http.MethodGet, cells+"?rows=20&cols=10", nil), cookies))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET cells = %d: %s", rec.Code, rec.Body.String())
+ }
+ return decodeGrid(t, rec)
+ }
+ ok := func(rec *httptest.ResponseRecorder) gridResponse {
+ t.Helper()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("write = %d: %s", rec.Code, rec.Body.String())
+ }
+ return decodeGrid(t, rec)
+ }
+ cell := func(r, c int, raw string) gridResponse {
+ return ok(postCell(t, app, cookies, cells, fmt.Sprintf(`{"r":%d,"c":%d,"raw":%q}`, r, c, raw)))
+ }
+ batch := func(body string) gridResponse { return ok(postCell(t, app, cookies, cells+"/batch", body)) }
+ format := func(body string) gridResponse { return ok(postFormat(t, app, cookies, s, body)) }
+ size := func(body string) gridResponse { return ok(postSize(t, app, cookies, s, body)) }
+
+ t.Run("nothing missed, nothing refetched", func(t *testing.T) {
+ f := &versionFollower{have: snapshot().Version}
+ steps := []struct {
+ name string
+ resp func() gridResponse
+ }{
+ {"cell", func() gridResponse { return cell(0, 0, "1") }},
+ {"cell", func() gridResponse { return cell(1, 0, "2") }},
+ {"cell", func() gridResponse { return cell(2, 0, "3") }},
+ {"batch of 3 with a derived formula", func() gridResponse {
+ return batch(`{"cells":[{"r":3,"c":0,"raw":"=SUM(A1:A3)"},{"r":0,"c":1,"raw":"b"},{"r":0,"c":0,"raw":"10"}]}`)
+ }},
+ {"format over 6 cells", func() gridResponse { return format(`{"r1":0,"c1":0,"r2":1,"c2":2,"bold":true}`) }},
+ {"cell after format", func() gridResponse { return cell(1, 1, "x") }},
+ {"size", func() gridResponse { return size(`{"col":2,"size":220}`) }},
+ {"batch of 2", func() gridResponse { return batch(`{"cells":[{"r":5,"c":0,"raw":"5"},{"r":6,"c":0,"raw":"6"}]}`) }},
+ {"clear", func() gridResponse { return cell(5, 0, "") }},
+ {"snapshot", snapshot},
+ {"cell after snapshot", func() gridResponse { return cell(7, 0, "7") }},
+ {"batch of 4", func() gridResponse {
+ return batch(`{"cells":[{"r":8,"c":0,"raw":"8"},{"r":8,"c":1,"raw":"8"},{"r":8,"c":2,"raw":"8"},{"r":8,"c":3,"raw":"8"}]}`)
+ }},
+ }
+ for _, step := range steps {
+ before := f.have
+ g := step.resp()
+ if f.accept(t, g, snapshot) {
+ t.Errorf("%s: refetched with nothing missed (held %d, got version %d, stamps %d)",
+ step.name, before, g.Version, g.Stamps)
+ }
+ }
+ })
+
+ t.Run("one write missed, the next response refetches", func(t *testing.T) {
+ for _, next := range []struct {
+ name string
+ resp func() gridResponse
+ }{
+ {"cell", func() gridResponse { return cell(10, 0, "n") }},
+ {"batch", func() gridResponse { return batch(`{"cells":[{"r":10,"c":1,"raw":"n"},{"r":10,"c":2,"raw":"n"}]}`) }},
+ {"format over 20 cells", func() gridResponse { return format(`{"r1":11,"c1":0,"r2":14,"c2":4,"italic":true}`) }},
+ {"size", func() gridResponse { return size(`{"col":3,"size":140}`) }},
+ } {
+ // Hold the version of a write this follower saw, then miss one.
+ f := &versionFollower{have: cell(9, 0, "seen").Version}
+ cell(9, 1, "missed")
+ g := next.resp()
+ if !f.missed(g) {
+ t.Errorf("%s after a missed write was applied, not refetched (held %d, got version %d, stamps %d)",
+ next.name, f.have, g.Version, g.Stamps)
+ }
+ }
+ })
+}
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift
index 2decf12..964f83a 100644
--- a/native/apple/Sources/SheetsKit/SheetModel.swift
+++ b/native/apple/Sources/SheetsKit/SheetModel.swift
@@ -114,7 +114,13 @@ import RastrilloApple
func accept(_ response: GridResponse) async {
guard response.version >= version else { return }
- if response.resync == true || response.version > version + 1 {
+ // A write of N cells moves the version by N and says so in `stamps`.
+ // Stampless responses (format, size, snapshot) report the server's
+ // NEXT stamp, hence the minimum of one. A wider jump is a missed
+ // edit. The server's TestNativeVersionContractAgainstRealResponses
+ // replays real responses through a Go mirror of this rule.
+ let allowed = UInt64(max(response.stamps ?? 0, 1))
+ if response.resync == true || response.version > version + allowed {
await refresh()
return
}
diff --git a/native/apple/Sources/SheetsKit/SheetWire.swift b/native/apple/Sources/SheetsKit/SheetWire.swift
index a7b6c3b..ec455b4 100644
--- a/native/apple/Sources/SheetsKit/SheetWire.swift
+++ b/native/apple/Sources/SheetsKit/SheetWire.swift
@@ -66,6 +66,10 @@ struct GridResponse: Decodable, Sendable {
var formats: [String: CellStyle]?
var sizes: Sizes?
var resync: Bool?
+ /// Versions the write behind this response consumed: 1 for a cell, N
+ /// for an N-cell batch, absent for format, size and snapshot responses
+ /// (server grid.go gridResponse.Stamps). See SheetModel.accept.
+ var stamps: Int?
}
struct CellWrite: Encodable, Sendable {
let r: Int
diff --git a/native/apple/Tests/SheetsKitTests/VersionGapTests.swift b/native/apple/Tests/SheetsKitTests/VersionGapTests.swift
new file mode 100644
index 0000000..8663aac
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/VersionGapTests.swift
@@ -0,0 +1,71 @@
+import Foundation
+import RastrilloApple
+import Testing
+
+@testable import SheetsKit
+
+// B12 and its review (#35). The server says how many versions a write
+// consumed (`stamps`); accept() refetches only when the version moved
+// further than that, so a multi-cell batch applies in place. Stampless
+// responses (format, size, snapshot) allow one. The server-side proof,
+// against real responses, is TestNativeVersionContractAgainstRealResponses.
+//
+// The client points at a closed port, so an attempted refetch shows as a
+// connection error and leaves the incremental cells unapplied.
+@MainActor private func gapModel(version: UInt64) -> SheetModel {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://127.0.0.1:1")!))
+ model.page = SheetPage(
+ Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 10, Cols: 5,
+ Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [],
+ InkSwatches: [], NumberFormats: [], Warned: nil)
+ model.version = version
+ model.error = ""
+ return model
+}
+
+@MainActor @Test func stampedBatchAppliesWithoutRefetch() async {
+ let model = gapModel(version: 5)
+ // Three typed cells (versions 6, 7, 8) plus one formula they changed.
+ await model.received(
+ SheetEvent(
+ name: "cells",
+ data: #"{"version":8,"stamps":3,"cells":[{"r":0,"c":0,"raw":"1","display":"1"},{"r":1,"c":0,"raw":"2","display":"2"},{"r":2,"c":0,"raw":"3","display":"3"},{"r":3,"c":0,"raw":"=SUM(A1:A3)","display":"6","derived":true}]}"#
+ ), source: model.grid)
+ #expect(model.error == "", "a stamped batch attempted a refetch: \(model.error)")
+ #expect(model.version == 8)
+ #expect(model.cells[Coordinate(r: 2, c: 0)]?.raw == "3")
+ #expect(model.cells[Coordinate(r: 3, c: 0)]?.display == "6")
+}
+
+@MainActor @Test func versionGapBeyondTheStampsStillRefetches() async {
+ let model = gapModel(version: 5)
+ // Two stamps ending at 9, so versions 6 and 7 were missed.
+ await model.received(
+ SheetEvent(
+ name: "cells",
+ data: #"{"version":9,"stamps":2,"cells":[{"r":0,"c":0,"raw":"1","display":"1"},{"r":1,"c":0,"raw":"2","display":"2"}]}"#
+ ), source: model.grid)
+ #expect(model.error != "", "a real gap was applied incrementally instead of refetched")
+ #expect(model.version == 5)
+ #expect(model.cells[Coordinate(r: 0, c: 0)] == nil)
+}
+
+@MainActor @Test func formatCellsDoNotWidenTheAcceptedJump() async {
+ let model = gapModel(version: 5)
+ // A format over four cells consumes no stamp; version 7 means a missed
+ // edit, however many cells the format response lists.
+ let cells = (0..<4).map { Cell(r: $0, c: 0, raw: "", display: "") }
+ await model.accept(GridResponse(version: 7, cells: cells))
+ #expect(model.error != "")
+ #expect(model.version == 5)
+}
+
+@MainActor @Test func singleStepsAndStamplessResponsesStillApply() async {
+ let model = gapModel(version: 5)
+ await model.accept(GridResponse(version: 6, cells: [Cell(r: 0, c: 0, raw: "x", display: "x")], stamps: 1))
+ // A format right after reports the next stamp: one ahead, no stamps.
+ await model.accept(GridResponse(version: 7, cells: [Cell(r: 0, c: 0, raw: "x", display: "x")]))
+ #expect(model.error == "")
+ #expect(model.version == 7)
+ #expect(model.cells[Coordinate(r: 0, c: 0)]?.raw == "x")
+}