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