Skip to content

svedit as an op-emitting engine #335

Description

@johannesmutter

Status: draft for discussion
Scope: Session / Transaction internals. All changes additive; no breaking API changes intended.
Shape: one RFC to agree on direction, then four issues, each landing as a series of small, independently reviewable PRs.

Problem

svedit treats persistence as the host's problem and gives the host exactly one tool: serialize the whole document (session.to_json()) at a moment the app chooses ("save"). Three pressures make that too coarse for the apps svedit is being used in:

1. Users expect save-less editing. Crash safety and continuous persistence (the Apple Notes/iA Writer/Notion/Docs/Figma baseline) require the host to observe every committed change the moment it happens. Today there is no change notification at all. Worse, undo() and redo() mutate session.doc directly, bypassing apply(), so even a host that wraps apply desyncs from the real document state on the first undo.

2. External writers are now normal. A) Agents edit documents headlessly. b) A second tab edits the same document. c) A sync layer or a version-restore feature wants to apply changes into a live session … There is no supported entry point for any of these: mutating the doc out-of-band skips validation, breaks selection, and corrupts undo bookkeeping, so hosts fall back to full reloads.

3. Long sessions have an unbounded memory profile, and ops carry no meaning. Every text keystroke records the whole property value twice: ['set', path, whole_value] in ops and the previous whole value in inverse_ops. Typing 30 characters into a 5 KB paragraph stores roughly 300 KB of strings in history; an afternoon of writing grows without bound (batching concatenates the copies, it does not avoid them). And because every text edit is "content changed", the op stream is useless as an edit trace: no meaningful diffs, previews, or audit.

svedit is unusually well positioned to fix all three, because the architecture is already right:

  • transactions commit by swapping an immutable snapshot (0.12's create_document_draft / apply_op_to_draft),
  • ops are three serializable, invertible primitives (set, create, delete) with inverses recorded,
  • apply() is a single choke point (except for the undo/redo bypass),
  • zero runtime dependencies, so none of this drags in a framework.

The gaps are small and precise. Closing them turns svedit into an engine that emits and accepts operations, while persistence, sync, and merge policy stay entirely in host land.

Proposal

Four parts, in dependency order.

1. One commit path + change notifications

A private _commit(ops, inverse_ops, meta) applies ops (via the existing draft mechanism), swaps session.doc, and notifies subscribers. apply(), undo(), and redo() all route through it. Public API sketch:

const unsubscribe = session.on_change(({ ops, inverse_ops, origin }) => {
  // origin: 'transaction' | 'undo' | 'redo' | 'external'
});

With this, a host implements autosave, an op log, or sync in a dozen lines, without forking Session, and undo/redo are no longer invisible to observers.

Drive-by fix in the same area: BATCH_WINDOW_MS is 1000 (Session.svelte.js:37) while apply()'s doc comment says "max one entry per 2 seconds" (line 260); the comment should match the constant.

2. Fine-grained ops

Add a text splice op alongside whole-value set:

['splice', [node_id, prop], offset, delete_count, inserted_text]

plus insert/remove (and possibly move) ops for node arrays. insert_text and delete_selection already compute exactly this data (offset, delta, and the mark/annotation range adjustments) before flattening it into a whole-value set; the change is to keep it instead of discarding it. Range adjustment moves into op application, making it the single source of truth (today Transaction adjusts ranges and then writes the whole value). Inverse splices carry the deleted text, plus prior ranges where the adjustment is lossy. Ops gain a schema_version stamp so persisted op streams stay replayable across svedit versions.

Standalone wins, before any collaboration feature exists:

  • bounded history memory: tuples instead of full-string copies per keystroke,
  • coalescing: adjacent single-character splices merge into one op, replacing concatenated string snapshots,
  • meaningful edit traces: real diffs, change previews, per-node history views.

3. External change ingestion

session.ingest(ops, { origin: 'external' });

Applies foreign ops through the same commit path, with three guarantees:

  • validate + repair the affected subgraph, generalizing validate_transaction_result's affected-set approach (repair rules to be specified: dangling references, duplicate array entries),
  • rebase the live selection (splice offsets make text rebasing arithmetic; node-array offsets rebase by index),
  • keep foreign ops out of the local undo stack: undo reverts my edits, never someone else's.

This is the missing entry point for agent edits into a live session, applying a restored version, multi-tab editing, and any sync layer. All of these currently either bypass the model or force a reload.

4. Durable + scoped history

History entries ({ops, inverse_ops, selection_before, selection_after}) are already plain JSON. Add:

  • a cap + spill hook, so hosts can persist older entries and memory stays flat in long sessions,
  • doc-at-version materialization by replay (host supplies a snapshot + ops),
  • scoped history: because every op addresses one node, history can be filtered by node subgraph or origin.

Scoping unlocks a feature almost no editor has: per-node time travel ("slide through this paragraph's versions") and scoped undo ("undo my last change to this block"), instead of global undo that teleports the user to whatever changed last, anywhere in the document.

Non-goals

  • No CRDT, no merge policy, no network code in core. Conflict handling is host policy; svedit only guarantees that ops are observable, fine-grained, invertible, and ingestible.
  • No persistence in core. Zero runtime dependencies stays a hard constraint.
  • No breaking changes. Whole-value set remains valid; existing hosts keep working untouched.

Open questions

  1. Op shape and naming: one splice vs insert_text/delete_text pairs? Should node-array move be its own op (identity-preserving) or remove+insert?
  2. Should inverse splices store prior mark/annotation ranges, or recompute them on revert?
  3. on_change API shape: callback registry, EventTarget, or a store?
  4. Does scoped undo belong in core, or as a documented pattern over the history API?
  5. Sequencing against current plans: does any of this collide with in-flight model work?
  6. Should regular undo really only reverts my edits, never someone else's?

Rollout

Four PRs, each independently reviewable and useful:

1a. _commit + reroute undo/redo (plus the batch-window comment fix)
1b. on_change subscriptions

2a. splice op + application (incl. range adjustment)
2b. Transaction text methods emit splices
2c. splice coalescing

3a. ingest happy path
3b. repair rules
3c. selection rebase

4a. history cap + spill hooks
4b. replay / doc-at-version
4c. scoped history filters


Would love to hear your thoughts @michael
Also feel free to ping if you want to discuss live.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions