nigig-org/crates/apps/doc/doc-engine
arena-agent 9d37874453 test(doc-engine): isolated source-coverage harness with floors
Mirror of the CAD engine harness for the doc crate, minus the shim
gymnastics (doc-engine depends only on serde/serde_json, so it
instruments directly): an isolated toolchain + cargo + target dir under
one mktemp directory, removed by a shell trap on every exit path;
nothing enters the host, the workspace target/, or $HOME. Runs the unit
tests plus tests/materialize.rs under -C instrument-coverage, enforces
a 96% total-lines floor against a 99.00% baseline plus per-file floors
(losing one module's tests must not hide in the total), and with
KEEP_COVERAGE=1 writes the uncovered-line listing that makes adding
branch tests directed rather than guesswork. COVERAGE.md records the
baseline, the exclusions, and the arms that are deliberately left
uncovered (defensive CRDT merge arms, one unreachable!, and the
Compensation::inverse arms unreachable through the public API).
2026-08-17 04:33:08 +00:00
..
src feat(doc): copy and clear cell ranges through the clipboard 2026-08-02 08:21:31 +00:00
tests test(doc-engine): cover the branches the first coverage report named 2026-08-17 04:33:08 +00:00
Cargo.toml Initial commit 2026-07-26 19:38:26 +03:00
COVERAGE.md test(doc-engine): isolated source-coverage harness with floors 2026-08-17 04:33:08 +00:00
README.md docs(doc): retire stale in-cell paste and migration-status claims 2026-08-02 09:17:57 +00:00

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 after chain is unified across blocks and advanced nodes: a block anchored after a node materializes in chain order. (order and blocks are derived from the same chain.)
  • Siblings sharing one anchor serialize by OpId ascending, independent of operation kind, so peer materializations converge deterministically.
  • SplitBlock opens a split session keyed by the parent block id and MergeBlocks folds 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 new InsertBlock op. Undo/redo uses the symmetric Compensation::{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): undo applies the members in reverse, redo applies 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_of resolves 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_cells pops 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 compensation inverse() 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 after anchor 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_text pushes the symmetric RestoreText compensation, which is what makes selection deletions (cut, Backspace-over-selection, type-over) undoable exactly like inserts.
  • ReplaceBlockRange only 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.
  • MergeTableCells projects a span (stable start/end row+column ids); SplitTableCells hides one merge by tombstoning its op id and RestoreTableMerge clears 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.