oficina / sheets Public

Native: apply multi-cell batches without a full refetch

accept() treated any version jump over one as a missed event and
refetched the whole grid. The server stamps each typed cell of a write
with its own version, so an N-cell batch legitimately moves the version by
N: every multi-cell paste, fill or clear, native or a peer's over the
stream, cost a full 25x50-chunked refresh.

The wire already marks formula-result cells "derived". Cell now decodes
that flag, and accept() allows a jump of up to the number of typed
(non-derived) cells in the response, minimum one. A jump wider than that
is still a gap and still refetches; resync still refetches.

Tests (VersionGapTests): a contiguous 3-cell batch over the stream applies
in place with no refetch (fails before this change: it refetched and
applied nothing); a jump past the batch still refetches; derived cells do
not widen the accepted jump; single-step and format-only responses apply.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 2e6c94e827122223ca59e46be8e74d001efef6aa parent 30e22d2
3 files changed, +87 −1
  • native/apple/Sources/SheetsKit/SheetModel.swift +6 −1
  • native/apple/Sources/SheetsKit/SheetWire.swift +3 −0
  • native/apple/Tests/SheetsKitTests/VersionGapTests.swift +78 −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.075s
ok  	amadan.net/oficina/sheets/internal/sheets	9.933s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.009s
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.002s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/store	2.715s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.280s
?   	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.785s
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..84a732a 100644
--- a/native/apple/Sources/SheetsKit/SheetModel.swift
+++ b/native/apple/Sources/SheetsKit/SheetModel.swift
@@ -114,7 +114,12 @@ import RastrilloApple
func accept(_ response: GridResponse) async {
guard response.version >= version else { return }
- if response.resync == true || response.version > version + 1 {
+ // The server stamps each typed cell of a write with its own version,
+ // so a batch of N typed cells moves the version by N and carries the
+ // last stamp. Derived (formula-result) cells take no stamp of their
+ // own. Only a jump wider than that is a missed event.
+ let typed = UInt64(response.cells?.lazy.filter { $0.derived != true }.count ?? 0)
+ if response.resync == true || response.version > version + max(typed, 1) {
await refresh()
return
}
diff --git a/native/apple/Sources/SheetsKit/SheetWire.swift b/native/apple/Sources/SheetsKit/SheetWire.swift
index a7b6c3b..68438e0 100644
--- a/native/apple/Sources/SheetsKit/SheetWire.swift
+++ b/native/apple/Sources/SheetsKit/SheetWire.swift
@@ -39,6 +39,9 @@ struct Cell: Codable, Sendable {
var display: String
var n: Bool?
var f: String?
+ /// True for a cell that changed because of a typed one (a formula's
+ /// result). Only typed cells advance the sheet version; see accept().
+ var derived: Bool?
var coordinate: Coordinate { Coordinate(r: r, c: c) }
}
struct CellStyle: Decodable, Sendable {
diff --git a/native/apple/Tests/SheetsKitTests/VersionGapTests.swift b/native/apple/Tests/SheetsKitTests/VersionGapTests.swift
new file mode 100644
index 0000000..8a8f11c
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/VersionGapTests.swift
@@ -0,0 +1,78 @@
+import Foundation
+import RastrilloApple
+import Testing
+
+@testable import SheetsKit
+
+// B12: the server stamps each typed cell of a batch with its own version,
+// so an N-cell batch moves the version by N. accept() used to treat any
+// jump over one as a missed event and refetch the whole grid, so every
+// multi-cell paste, fill or clear (native or a peer's, over the stream)
+// cost a full chunked refresh. A contiguous batch now applies in place;
+// only a jump wider than the batch's typed cells is a gap.
+//
+// The client points at a closed port, so an attempted refresh 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 contiguousBatchAppliesWithoutRefetch() 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,"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 contiguous 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 versionGapBeyondTheBatchStillRefetches() async {
+ let model = gapModel(version: 5)
+ // Two typed cells end at version 9, so versions 6 and 7 were missed.
+ await model.received(
+ SheetEvent(
+ name: "cells",
+ data: #"{"version":9,"cells":[{"r":0,"c":0,"raw":"1","display":"1"},{"r":1,"c":0,"raw":"2","display":"2"},{"r":2,"c":0,"raw":"=A1+A2","display":"3","derived":true}]}"#
+ ), 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 derivedCellsDoNotWidenTheAcceptedJump() async {
+ let model = gapModel(version: 5)
+ // One typed cell and three derived: the batch accounts for one version,
+ // not four, so version 8 means two edits were missed.
+ await model.accept(
+ GridResponse(
+ version: 8,
+ cells: [
+ Cell(r: 0, c: 0, raw: "1", display: "1"),
+ Cell(r: 1, c: 0, raw: "=A1", display: "1", derived: true),
+ Cell(r: 2, c: 0, raw: "=A1", display: "1", derived: true),
+ Cell(r: 3, c: 0, raw: "=A1", display: "1", derived: true),
+ ]))
+ #expect(model.error != "")
+ #expect(model.version == 5)
+}
+
+@MainActor @Test func singleStepAndFormatOnlyResponsesStillApply() async {
+ let model = gapModel(version: 5)
+ await model.accept(GridResponse(version: 6, cells: [Cell(r: 0, c: 0, raw: "x", display: "x")]))
+ await model.accept(GridResponse(version: 6, cells: []))
+ #expect(model.error == "")
+ #expect(model.version == 6)
+ #expect(model.cells[Coordinate(r: 0, c: 0)]?.raw == "x")
+}