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>
3 files changed,
+87
−1
native/apple/Sources/SheetsKit/SheetModel.swift+6 −1native/apple/Sources/SheetsKit/SheetWire.swift+3 −0native/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") |
| +} |