nigig-org/crates/apps/doc/doc-engine/README.md
arena-agent 4df2193859
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
docs(doc): retire stale in-cell paste and migration-status claims
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.
2026-08-02 09:17:57 +00:00

121 lines
6.7 KiB
Markdown

# doc-engine
Standalone CRDT document engine rewrite crate.
Add to workspace members:
```toml
"crates/doc-engine",
```
Add to nigig-build dependencies:
```toml
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.