Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 15s
doc-engine / consumer (push) Has been cancelled
Tabular paste superseded the clipboard section's "in-cell pastes always stay a single whole-cell write, newlines included", and the doc-engine README still framed the controller migration as "next" even though the workspace navigation already runs on CrdtDocEditor (the classic editor stays registered as a fallback). Found during the phase audit.
6.7 KiB
6.7 KiB
doc-engine
Standalone CRDT document engine rewrite crate.
Add to workspace members:
"crates/doc-engine",
Add to nigig-build dependencies:
doc-engine = { path = "../../doc-engine" }
Migration status: COMPLETE for the live surface — the doc workspace's
CrdtDocEditor owns a doc_engine::DocumentController and renders
materialized projections (see "Application navigation switch to
CrdtDocWorkspace" in the workspace README); the classic editor stays
registered as a fallback. The invariants below are the contract that
migration runs on.
Projection invariants
- The insert
afterchain is unified across blocks and advanced nodes: a block anchored after a node materializes in chain order. (orderandblocksare derived from the same chain.) - Siblings sharing one anchor serialize by
OpIdascending, independent of operation kind, so peer materializations converge deterministically. SplitBlockopens a split session keyed by the parent block id andMergeBlocksfolds the parent and its successor into the parent and closes any open session. Materialization keeps the parent's trailing runs in a synthetic child block spliced after the parent's real-chain descendants (none for a plain Return, so it sits directly behind the parent; the middle lines of a multi-block paste keep it last in the pasted range), so peers replaying the same op log converge on the same visible sequence without a newInsertBlockop. Undo/redo uses the symmetricCompensation::{SplitBlock, MergeBlocks}pair.- Synthetic split children are first-class text targets: atoms and style ops ADDRESSED TO a split op id splice into the transplanted suffix RGA-wise during materialization (suffix atoms re-link head-to-tail with their original ids, so the counter-descending sibling rule orders text typed into the child exactly like mid-word typing), and suffix atoms keep the style runs they inherited from the parent while child-addressed text takes the block default. A split child with nothing addressed to it materializes byte-identically to before.
- Undo entries may group several leaf compensations
(
Compensation::Group):undoapplies the members in reverse,redoapplies the inverse group in authored order. A multi-line paste (insert_multiline_text) authors one block per line — line 0 spliced into the caret block, middle lines as sibling blocks inheriting the caret block's kind, and the split carrying the caret block's suffix onto the last line — folded into one group so the whole paste retracts in a single undo step. The caret anchor may be tombstoned (the caret a selection delete leaves behind):CrdtDocument::live_offset_ofresolves it to the same live-text offset RGA splicing uses, so pasting over a deleted selection lands exactly where a single-line paste would. Cell writes group the same way:set_table_cellspops each sub-write's compensation into one group (a single write keeps the leaf), so clearing or range-filling N cells un-applies in one undo step. - Cell-text compensations capture the projected text at BOTH
transition boundaries, per group member: undo's redo side
(
redo_compensation) re-applies the live text an undo is about to overwrite, and redo's undo side (undo_compensation) restores the live text a redo is about to overwrite. The compensationinverse()only swaps the verb and keeps the carried text, which is always the stale side of a cell write — captured live state is what makes cell edits round-trip through arbitrary undo/redo cycles, as leaves and inside groups. - Table rows and columns order by their
afteranchor chain: inserting below an existing row/col stays right after it even when peers appended further rows elsewhere. Siblings sharing one anchor serialize RGA-style (counter descending, then actor ascending), exactly like text atoms, so a newer "insert below X" renders before older rows anchored on X. - Text atom sibling order compares raw per-actor counters (counter
descending, then actor ascending) rather than wall-clock time, so a
second actor inserting into someone else's mid-run anchors
deterministically but not chronologically. Production runs a single
"local"actor per editing surface, where counter order is chronological order and this artifact cannot appear; either way every peer materializes the same order. - Cell text is last-writer-wins by op id: one actor per editing surface
keeps chronological edits winning locally, and peer edits converge
deterministically to the highest
OpId. - Tombstone pairs — text atoms, blocks, table rows/columns, merge
splits, cell style clears and block range cancels — resolve
chronologically per target in operation order: a restore clears
exactly the tombstones set before it, so delete/undo/redo cycles
re-delete instead of sticking live.
delete_textpushes the symmetricRestoreTextcompensation, which is what makes selection deletions (cut, Backspace-over-selection, type-over) undoable exactly like inserts. ReplaceBlockRangeonly rewrites the projection: the drained middle blocks and every atom beneath the span stay in the op log untouched. Its compensation therefore tombstones the range op itself (CancelBlockRange/RestoreBlockRange, mirroring the merge split/restore pair), so undo of a cross-block delete or cut is lossless — splices, blocks and text re-materialize exactly — and one undo step covers the whole span. Spans whose endpoints do not resolve against the projection in order are refused before the op records, so no-op ranges never strand entries on the undo stack.- Cell style runs (
SetTableCellStyle/ClearTableCellStyle/RestoreTableCellStyle) are char-offset snapshots applied against the cell's current LWW text: an authored span clamps when the text shrinks but does not stretch when it grows, and ops replay in operation order so newer patches win on overlap. Undo tombstones the style op exactly like a merge split, which leaves overlapping later styles intact and keeps undo/redo convergent across peers. Because ranges follow offsets rather than anchored atoms, styles visually keep their position through a whole-cell text replacement. MergeTableCellsprojects a span (stable start/end row+column ids);SplitTableCellshides one merge by tombstoning its op id andRestoreTableMergeclears the tombstone, so undo/redo of either side converges. Merges never delete cell text: covered cells keep their stored value in the projection and only stop rendering, which is what makes split lossless (the hidden text reappears). Overlapping merges are an authoring-layer concern — the engine applies them as written, so editors validate ranges before emitting the op.