oficina / sheets Public

Native: apply multi-cell writes without a full refetch

accept() treated any version jump over one as a missed edit and refetched
the whole grid. An N-cell batch legitimately moves the version by N, so
every multi-cell paste, fill or clear (native, or a peer's over the stream)
cost a full 25x50-chunked refresh (B12).

GridResponse now decodes the server's `stamps`. accept() refetches iff
resync, or the version moved further than max(stamps, 1). Format, size and
snapshot responses carry no stamps, however many cells they list, so they
cannot widen the jump over a missed peer edit.

VersionGapTests: a stamped 3-cell batch over the stream applies in place.
With main's rule it fails: it refetches and applies nothing. A jump past the
stamps still refetches, a 4-cell format response at +2 refetches, and a
single write followed by a stampless format applies. The server side is
proven against real responses by TestNativeVersionContractAgainstRealResponses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a37c0329dfce4c278a53f662834828f4f8c791ae parent 579c553
3 files changed, +82 −1
  • 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.094s
ok  	amadan.net/oficina/sheets/internal/sheets	10.663s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.008s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.053s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/store	2.726s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.279s
?   	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.915s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.059s
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")
+}