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.
121 lines
6.7 KiB
Markdown
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.
|