Loro Extended Study

Source Snapshot

This note is based on schoolAI/loro-extended at commit e6f1ebbe011c05e4ec452789f4488b0d337c9791, package versions:

  • @loro-extended/change: 6.0.0-beta.0
  • @loro-extended/repo: 6.0.0-beta.0
  • @loro-extended/lens: 1.0.0-beta.0

The repository is a local-first framework over Loro. It is not only a Loro benchmark or adapter set. The relevant split for Careervector is:

  • packages/change: typed/schema/change/ref/path-selector layer over Loro CRDT containers.
  • packages/repo: document lifecycle, discovery, storage, network sync, permissions, ephemeral state.
  • packages/lens: filtered bidirectional worldview documents over typed Loro docs.

For CVL, packages/change is the important part. packages/repo is useful as an architectural analogue, but it should not be adopted wholesale while Careervector already has sub-doc REST persistence, server-side op validation, and realtime byte fanout.

Why This Matters To CVL

Careervector is trying to move from editor components directly interpreting a recursive quarry to a real CVL engine boundary:

  • a canonical graph/quarry model,
  • parent-owned selection groups at every level,
  • stable selectors for UI, render, MCP, and AI agents,
  • predictable layout projection,
  • local/device-first compute,
  • thin server persistence and sync.

Loro Extended has already solved several adjacent problems:

  • how to make raw CRDT state feel like a typed document instead of schemaless JSON,
  • how to expose refs as narrow mutable handles,
  • how to batch related mutations into one commit,
  • how to subscribe to paths instead of whole documents,
  • how to provide placeholders/defaults without writing defaults into CRDT state,
  • how to reason about before/after transitions from one live document,
  • how to prevent nested container identity bugs in concurrent CRDT edits.

The most important lesson is not "use Loro Extended as-is". The lesson is that Careervector needs an explicit typed change layer above the CRDT substrate. That layer should be CVL-specific and should work whether the backing engine is Yjs, Loro, or a local op-log baseline.

The change Package

@loro-extended/change defines a schema system named Shape, then wraps a LoroDoc in a TypedDoc proxy. Schema properties return typed refs:

  • Shape.struct() and Shape.record() map to LoroMap refs.
  • Shape.list() maps to LoroList.
  • Shape.movableList() maps to LoroMovableList.
  • Shape.text() maps to LoroText.
  • Shape.counter() maps to LoroCounter.
  • Shape.tree() maps to LoroTree.
  • Shape.plain.* stores ordinary CRDT values and exposes PlainValueRef.

The useful design pattern is facade plus internals:

TypedRef public facade
  -> INTERNAL_SYMBOL
     -> BaseRefInternals implementation

That gives the UI and API a small public surface while keeping container lookup, batching, materialization, overlays, and cache invalidation out of component code.

For CVL, this maps well to:

  • CvlDocRef
  • NodeRef
  • SelectionGroupRef
  • GroupSelectionRef
  • RenderProjectionRef
  • MetricsRef

These should expose quarry operations, not raw maps and arrays. A human UI, MCP tool, and AI agent should all call the same operation vocabulary.

Method-Based Mutation

Loro Extended moved toward one consistent API:

  • dot notation for schema traversal,
  • methods for reads and writes,
  • change(doc, draft => { ... }) for batched mutations.

That matters for CVL because our mutation semantics are not simple property assignment. Examples:

  • selecting 2 of 5 candidates must validate group cardinality,
  • moving a node may change group membership and render projection,
  • breaking a variant out of a group may create a new singleton group,
  • setting generated text may update metrics invalidation.

CVL should prefer explicit methods/ops such as:

change(cvl, draft => {
  draft.group(groupId).select(["a", "c"]);
  draft.node(nodeId).moveTo(parentId, 2);
  draft.node(nodeId).setText(text);
});

This keeps invariants visible and testable. It also makes MCP/API documentation clearer for agents.

Placeholders And Defaults

Loro Extended uses placeholders as an empty-state overlay. Defaults are computed on read and are not stored in CRDT state until explicitly modified.

This is directly relevant to CVL:

  • a section prompt can have default display values,
  • empty metrics can present zero/unknown safely,
  • a new group can present default exactness or labels,
  • editor UI can render before all persisted state exists.

The CVL caution: placeholders must never hide invalid quarry state from agents. A placeholder can improve UI continuity, but diagnostics must still distinguish "defaulted because absent" from "validly authored".

Deterministic Nested Containers

Loro Extended has a key CRDT lesson: nested container IDs can diverge when peers concurrently create the same logical path. It addresses this with mergeable: true, which stores nested containers at root-level path-encoded container names and leaves null markers in parent maps.

Example logical shape:

data.nested.value

can become root containers like:

data
data-nested

This is valuable for CVL because our document shape is heavily nested and concurrent editing may create groups, items, bullets, and metrics under the same logical parent.

The limitation is crucial: Loro Extended explicitly does not support lists of containers in mergeable mode. It recommends records with string keys instead.

That points toward a CVL-native normalized store:

  • nodes keyed by stable ID,
  • children order stored separately,
  • groups keyed by stable ID,
  • group order stored separately,
  • selected IDs stored separately,
  • metrics keyed by node ID.

In other words, this validates our normalized graph direction. It argues against storing the canonical CVL model as deeply nested CRDT container lists.

Path Selectors

Loro Extended has a typed path selector DSL that compiles to JSONPath where possible and falls back to global subscription plus manual filtering for flattened/mergeable storage.

The core idea is exactly what CVL needs:

  • UI subscribes to a section view, not the whole profile.
  • Render subscribes to a projection hash, not arbitrary editor state.
  • MCP can request group/node slices.
  • AI agents can reason over typed quarry paths.

For CVL, a selector layer should probably not be generic JSONPath first. It should be semantic:

engine.selectors.documentSummary(lang)
engine.selectors.section(sectionId)
engine.selectors.group(groupId)
engine.selectors.renderProjection(lang)
engine.selectors.metrics(nodeId)

Under the hood, that selector can be backed by Yjs observers, Loro subscriptions, or a worker-local op log. The public contract should be stable across substrates.

Diff Overlays And Transitions

Loro Extended can create a read-only diff overlay from a LoroEventBatch, allowing code to read before/after values from one live document without checking out or cloning the document.

This is useful for:

  • incremental view-model patches,
  • diagnostics after a mutation,
  • undo/redo surfaces,
  • "what changed" explanations for AI agents,
  • avoiding whole-document recomputation.

For CVL, the concept matters more than the exact implementation. A CVL engine should expose transition reports:

type CvlTransition = {
  changedNodeIds: NodeId[];
  changedGroupIds: GroupId[];
  renderProjectionChanged: boolean;
  metricsInvalidatedNodeIds: NodeId[];
  diagnostics: CvlDiagnostic[];
};

That report should be produced by engine operations and should be available to Svelte, MCP, and tests.

Lenses

@loro-extended/lens creates filtered worldview documents from a canonical world document. Inbound changes are filtered commit-by-commit; outbound worldview changes are pushed back with applyDiff.

This is not the first thing CVL needs, but it is conceptually relevant:

  • a human editor view and an AI tailoring view may be different worldviews over the same quarry,
  • a measurement projection can be a temporary expanded view,
  • an agent could work on an experimental view before committing valid quarry changes.

Do not import this as a dependency now. Keep it in mind as a pattern for future preview/sandbox workflows. It depends heavily on Loro applyDiff behavior and mergeable storage constraints.

Repo And Sync

The repo package uses:

  • always-available document handles,
  • pull-based discovery,
  • sync requests/responses,
  • storage adapters as eager peers,
  • permissions as synchronous sync-time predicates,
  • a pure TEA-style synchronizer with commands for side effects.

Careervector should borrow ideas, not the package:

  • always-available local docs are good for editor responsiveness,
  • pure state machine plus command execution is a good worker architecture,
  • storage/network as adapters is conceptually clean,
  • permissions/rules at sync boundaries map to agent guardrails later.

But replacing Careervector sync would be the wrong first move. We already have:

  • Yjs sub-doc snapshots in workspace_sub_doc,
  • canonical REST commits with op validation and CAS,
  • realtime as thin byte fanout,
  • MCP and browser both routing through the same web sub-doc API.

The near-term CVL engine should sit inside that architecture, not replace it.

Impact On The Yjs vs Loro Competition

This study changes the interpretation of our benchmark competition.

The question is not only "which CRDT writes fastest?" It is:

  • Which substrate supports the cheapest normalized CVL graph?
  • Which one gives us compact updates for frequent selection changes?
  • Which one supports deterministic identity without awkward workarounds?
  • Which one allows granular subscriptions without whole-document rebuilds?
  • Which one is easiest to wrap in a typed CVL operation layer?
  • Which one can run in a worker with low transfer overhead?

Current tiny benchmark after the second optimization pass:

Adapter Total State bytes Update bytes Selection index Render projection
In-memory baseline 10.791 ms 316,971 366,917 op-log estimate 1.645 ms 0.429 ms
Yjs normalized 352.770 ms 591,549 607,890 CRDT update 10.032 ms 1.143 ms
Loro normalized 385.774 ms 381,741 739,920 CRDT update 153.061 ms 1.655 ms

Interpretation:

  • The local normalized engine shows the algorithmic ceiling and is the right target for worker-side selectors/projections.
  • Yjs is currently stronger on this exact tiny benchmark after selection-log optimization, especially selection-index reads.
  • Loro is now close on total time and has smaller serialized state, but still loses badly on the shared selection-index adapter path.
  • Loro Extended suggests that a more Loro-native design should avoid nested container lists and use ID-keyed records/root containers, which is closer to our normalized model than a naive tree/list shape.

Build a CVL-specific "extended" layer, not a dependency on Loro Extended:

CRDT/local substrate
  -> normalized CVL store
  -> typed CVL refs and operations
  -> selectors/view-model patches
  -> Svelte, MCP, Typst, tailoring

The substrate should remain swappable during the prototype phase:

  • Yjs normalized contender,
  • Loro normalized contender,
  • local op-log baseline,
  • maybe a hybrid local engine with CRDT persistence adapter.

The API above the substrate should become stable first. That is where Careervector's domain value lives.

Concrete Borrow List

Borrow now:

  • typed schema/ref facade over raw storage,
  • change()-style batched mutation boundary,
  • method-based writes for invariant-heavy actions,
  • placeholders as read overlay, with explicit diagnostics,
  • path/semantic selector subscriptions,
  • before/after transition reports,
  • deterministic ID-keyed normalized storage,
  • pure worker state machine plus command execution pattern.

Borrow later:

  • lens/worldview idea for sandboxed AI or preview edits,
  • sync-time permission predicates for agent guardrails,
  • adapter abstraction if local/device sync topology grows.

Avoid for now:

  • replacing Careervector persistence/realtime with @loro-extended/repo,
  • modeling canonical CVL as lists of nested CRDT containers,
  • exposing raw CRDT refs directly to UI or MCP,
  • relying on placeholders as proof of valid quarry state,
  • committing to Loro only because Loro Extended is elegant.

Next Prototype Step

The next useful implementation step is a small CVL typed facade over the existing prototype engines:

  • createCvlEngine({ substrate })
  • engine.change(fn)
  • engine.node(id)
  • engine.group(id)
  • engine.selectors.section(id)
  • engine.selectors.renderProjection()
  • engine.getTransition(lastOp)

Do this over the local baseline first because it is fastest and easiest to reason about. Then back it with Yjs and Loro adapters and compare identical API behavior and benchmark costs. If the API is right, the substrate competition becomes much cleaner.

Source: wiki/content/studies/CRDT/loro-extended-study.md