Store undo history as serialized patches - #359
Conversation
Replace the snapshot undo history (raw copies of the whole cloud plus a Go-side header list) with a patch-based history: each edit pushes a patch reverting it, serialized and stored uniformly on the JS heap. For now every edit type uses replacePatch, a whole-cloud snapshot, so behavior and memory characteristics are unchanged while the pipeline (push, serialized storage, undo by revert) is in place. Follow-ups replace the snapshot fallback with cheap per-operation patches and compress what remains. The non-js history stub becomes a real implementation (historyMem), making undo behavior testable with plain go test; randomized round-trip tests assert byte-exact restoration. Undo depth semantics of max_history are unchanged (N entries = N undos). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #359 +/- ##
==========================================
+ Coverage 39.34% 45.93% +6.58%
==========================================
Files 8 9 +1
Lines 1426 1585 +159
==========================================
+ Hits 561 728 +167
+ Misses 829 808 -21
- Partials 36 49 +13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR refactors the editor undo history mechanism to store serialized “inverse edit” patches (currently implemented as a whole-cloud replacePatch) instead of storing full point cloud copies, laying groundwork for future per-operation patches and improved memory behavior (especially in WASM/JS).
Changes:
- Introduces a patch serialization format (
patch.go) and uses it to store undo history as packed patch bytes. - Replaces the previous undo history API with
push(patch),undo(current), andsquashLatest()and updates call sites accordingly. - Adds Go (non-js) in-memory history implementation plus new tests validating undo round-trips and history constraints.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| undo.go | Replaces dummy history with historyMem storing packed patch chunks for non-js builds. |
| undo_js.go | Refactors JS history to store packed patch bytes in JS Uint8Array chunks and adds squashLatest(). |
| patch.go | Adds patch interface plus replacePatch encoding/decoding and chunk reversion logic. |
| patch_test.go | Adds unit tests for replacePatch revert and encode/decode round-trip. |
| history_test.go | Adds randomized editor edit/undo round-trip tests, max history depth tests, and squash tests. |
| editor.go | Updates history interface and editor operations to record replacePatch snapshots on edits. |
| command.go | Replaces the previous pop() usage with squashLatest() for voxel-filter undo grouping. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A failed revert used to discard the entry; a later undo would then apply an older patch to a state it was not recorded against. Keep the history intact and block undo at the broken entry instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
buf.Bytes() retains the grown capacity of the buffer, which can be nearly twice the content size and is held long-term by historyMem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
historyMem exists only in the non-js build; go vet for GOOS=js compiles test files too and failed on the reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bound nFields by the minimal encoded field size so corrupted counts fail before allocating, and rewrite the viewpoint bound in the same multiplication-free form as the other guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pushing a replacePatch serialized the whole cloud into a Go buffer before copying it to the JS heap, transiently holding extra full-size copies in the WASM linear memory, which never shrinks. Split the patch wire form into a head and a raw payload (encodeHead/payload) and copy both straight into one Uint8Array, restoring the memory behavior of the previous direct-copy implementation for snapshots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Randomized tests are good for fuzzing but not good for defining the desired behavior.
Basic tests are better to be written with explicit small examples.
| const ( | ||
| patchTypeLabel = iota + 1 | ||
| patchTypeDelete | ||
| patchTypeAppend | ||
| patchTypeReplace | ||
| ) |
There was a problem hiding this comment.
Should be typed:
| const ( | |
| patchTypeLabel = iota + 1 | |
| patchTypeDelete | |
| patchTypeAppend | |
| patchTypeReplace | |
| ) | |
| type patchType byte | |
| const ( | |
| patchTypeLabel patchType = iota + 1 | |
| patchTypeDelete | |
| patchTypeAppend | |
| patchTypeReplace | |
| ) |
| // pp may be mutated; use the returned cloud | ||
| revert(pp *pc.PointCloud) (*pc.PointCloud, error) | ||
| // The wire form is the head followed by the raw payload | ||
| encodeHead(buf *bytes.Buffer) |
There was a problem hiding this comment.
Comments should explain the functions, not partial internal behavior
| buf.WriteByte(patchTypeReplace) | ||
| writeUint32(buf, math.Float32bits(p.header.Version)) | ||
| writeUint32(buf, uint32(len(p.header.Fields))) | ||
| for i := range p.header.Fields { | ||
| writeString(buf, p.header.Fields[i]) | ||
| writeUint32(buf, uint32(p.header.Size[i])) | ||
| writeString(buf, p.header.Type[i]) | ||
| writeUint32(buf, uint32(p.header.Count[i])) | ||
| } | ||
| writeUint32(buf, uint32(p.header.Width)) | ||
| writeUint32(buf, uint32(p.header.Height)) | ||
| writeUint32(buf, uint32(len(p.header.Viewpoint))) | ||
| for _, v := range p.header.Viewpoint { | ||
| writeUint32(buf, math.Float32bits(v)) | ||
| } | ||
| writeUint32(buf, uint32(len(p.data))) |
There was a problem hiding this comment.
Writing custom encoder/decoder for internal storage format makes it unnecessarily complex.
Consider using stdlib's encoding/glob or something others in stdlib.
| type replacePatch struct { | ||
| header pc.PointCloudHeader | ||
| data []byte | ||
| } |
There was a problem hiding this comment.
Patch usually means a thing converting original state to desired state. So, the name replacePatch evokes having the desired state, however this seems having the original state.
Field names or interface/struct names are better to be fixed to explain what is stored.
| entry := h.entries[n-1] | ||
| chunks := make([][]byte, len(entry)) | ||
| for i, c := range entry { | ||
| b := make([]byte, c.Get("byteLength").Int()) | ||
| js.CopyBytesToGo(b, c) | ||
| chunks[i] = b | ||
| } | ||
| out, err := revertChunks(pp, chunks) |
There was a problem hiding this comment.
Copying all patches to Go memory and apply them later increases peak memory usage.
Better to copy and apply one by one.
| // pp may be mutated; use the returned cloud | ||
| revert(pp *pc.PointCloud) (*pc.PointCloud, error) | ||
| // The wire form is the head followed by the raw payload | ||
| encodeHead(buf *bytes.Buffer) |
There was a problem hiding this comment.
It doesn't need bytes.Buffer and io.Writer is enough
| return nil, nil, errUnknownPatchType | ||
| } | ||
|
|
||
| func revertChunks(pp *pc.PointCloud, chunks [][]byte) (*pc.PointCloud, error) { |
There was a problem hiding this comment.
This reverts pointcloud not chunks.
Also, chunk has almost no meaning. Actual meaning of the input is something like encoded/binary patch.
There was a problem hiding this comment.
This tests only test-only code.
To make some history handling logic testable,
- abstract low layer history data storage (JS memory for product and Go memory for testing) like:
type historyEntryStorage interface { Store(...[]byte) historyEntry } type historyEntry interface { Load() []byte }
- unify
historyclass with history data storage interface
| e.push(&replacePatch{ | ||
| header: e.pp.PointCloudHeader.Clone(), | ||
| data: e.pp.Data, | ||
| }) |
There was a problem hiding this comment.
Better to have
func newReplacePatch(*pc.PointCloud) patch
Problem
Every edit clones the whole point cloud in the WASM heap and pushes another full copy to the JS heap as undo history. For large PCDs the history alone keeps several times the file size resident, and since the WASM linear memory never shrinks, the per-edit clone turns the peak into permanent residency.
Changes
Groundwork for patch-based undo history; only the history mechanism is replaced.