docblocks-core
v0.2.0
Published
Headless CRDT document engine: schema v1, Doc (commands/transactions/undo), sync providers. Yjs-based, zero UI dependencies.
Readme
docblocks-core
The headless CRDT document engine for docblocks — the data half: the
document model, the editing commands, the selection, and the sync
transport. There is no UI in this package; rendering and editing live in
docblocks-react-ui.
The engine is built on Yjs (a CRDT): documents converge
across peers even when updates arrive out of order, duplicated, or with
losses. The only runtime dependencies are yjs, y-protocols, and lib0.
Install
npm i docblocks-coreThe package is ESM-only (import, not require); it stands Node
^22.22.2 || ^24.15.0 || >=26.0.0 (the engines field).
A document is created, edited with commands, and read back as plain JSON — every read returns a snapshot (a plain object), never a live Yjs reference, so mutating a returned block does nothing.
import { createDoc, fromJSON } from "docblocks-core";
const doc = createDoc({ id: "my-doc" });
const para = doc.insertBlock("paragraph");
doc.insertText(para, 0, "hello world");
doc.setMark(para, 0, 5, { type: "bold" });
const json = doc.toJSON(); // { v, id, title, avatar?, createdAt, blocks }
// …persist or send `json` …
const restored = fromJSON(json); // a fresh Doc over the same dataCommands are Doc methods. Each command runs inside a transaction — one
undo step, one wire update. Batch several into a single step with
transact:
doc.transact(() => {
const h = doc.insertBlock("heading", { level: 1 }, { after: para });
doc.insertText(h, 0, "Title");
}); // one undo step, one wire updateThe block and mark registries drive the command types: doc.insertBlock
accepts only a known block type, and its attrs are required exactly when
the type demands them — doc.insertBlock("heading", { level: 7 }) is a
compile-time error. Unknown input throws a typed error
(UnknownBlockError, InvalidRangeError, …); commands never no-op
silently.
The Doc
createDoc(options?) builds a document with only the root block (an empty
page — no auto first paragraph; the react-ui editor guarantees the trailing
editable paragraph). The other entry points:
fromJSON(json)— build aDocfrom a JSON snapshot.loadDoc(bytes)— build aDocfrom an encoded full state (a base snapshot; apply diffs withapplyUpdate).doc.toJSON()— the JSON snapshot ({ v, id, title, avatar?, createdAt, blocks }).doc.encode()/doc.encodeUpdate(stateVector?)— the encoded full state / the incremental update.doc.applyUpdate(bytes)— apply an encoded update (the remote-apply path).
doc.raw exposes the underlying Y.Doc; it is an explicit escape hatch
and its shape is not guaranteed.
Commands
Editing goes through the semantic API (the only path for normal editing).
The commands translate into Y.Doc mutations inside a transaction:
- Blocks —
insertBlock,updateBlock,moveBlock,removeBlock,changeBlockType. - Text —
insertText,deleteText,setMark,unsetMark, and the range forms (deleteTextRange,setMarkRange). - Document meta —
setTitle,setAvatar,setGeometry,setZOrder. - Database rows — the row commands (
insertRow,setCell,removeRow,moveRow, …). - Table rows / columns —
insertTableRow,removeTableRow,insertTableColumn,removeTableColumn, the move / duplicate / color / width commands, andsetTableRowColor.
Reads: getBlock(id), getChildren(id), getRuns(id), getText(id),
getTextLength(id). History: undo() / redo() (with canUndo() /
canRedo()) — one transact is one undo step, and a command batched
into a transact is one step across the batch.
Sync
attach(doc, provider) is the glue between a Doc and a
SyncProvider: it bootstraps the session (sending the full local state
first), then routes local transactions out and remote updates in. detach()
on the returned session tears the session down.
WsSyncProvider speaks the standard y-protocols wire over a WebSocket
(wire-compatible with a standard y-websocket / pycrdt server):
import { WsSyncProvider, attach } from "docblocks-core";
const provider = new WsSyncProvider("wss://my-server/ws/room-1", {
token: "…",
});
await provider.connect();
const session = attach(doc, provider);
// … on teardown:
session.detach();Reactivity: doc.onChange(handler) fires for every transaction;
doc.onRemoteChange(handler) fires for non-local-origin transactions only
(the feed a sync consumer's write-back guards hook into).
Selection
Selection is client / view state, not document data — it is not in the JSON
and not in document updates. createSelection(doc) builds a SelectionView
over the doc; other clients' selections travel only in the ephemeral
awareness channel (the presence payload). presenceSelectionOf(selection)
projects a selection into the presence payload shape.
Data format
The JSON format is v: 1: the document fields (id, title, the
optional avatar, createdAt) at the top level, plus a flat block map
(id → block) with explicit order via
children: [blockId]; nesting (lists, table cells) is children. The text
of a text block is content: [{ text, marks }] runs. JSON ↔ Yjs is
isomorphic — every JSON field has exactly one Yjs image.
The v1 registry covers the page types (paragraph, quote, heading,
list-item, callout, code, divider, image, embed, table /
table-row / table-cell, database), the five canvas types (surface,
shape, connector, frame, canvas-text), and root; the v1 marks
are bold, italic, strike, underline, code (exclusive), link,
color, highlight, and mention. The type lists stand exported
(PAGE_BLOCK_TYPES, LEAF_BLOCK_TYPES, BLOCK_REGISTRY,
MARK_REGISTRY, CALLOUT_ICONS).
The block and mark registries are the extension point. Adding a block type
or mark is additive (a minor): the registry entry plus a renderer; the
validator and the commands stay registry-driven. The reader tolerates the
unknown (an unknown block type renders a placeholder, an unknown mark is
ignored and its text stays); the validator is strict (validateDoc reports
only known types and marks — it protects the data you own). A breaking
change is a major: the reader rejects the unknown major, and a migration
helper converts the data. flattenDoc(json) normalizes the legacy nested
shape into the flat page model at load.
Validation
validateDoc(json, lenient?) returns the list of validation errors (empty
is valid). Strict (the default) is the writer's contract — it checks the
known types and mark values. Lenient is the reader boundary — unknown types
and marks pass through (the reader preserves them), so a reader of v1.0
can safely load v1.5 data.
Non-goals
This package is deliberately headless. It does not ship a UI (that is
docblocks-react-ui), a server (any Yjs-compatible server is the receiving
side), or blob resolution (the consumer provides a BlobStore). It holds no
persistence — snapshot and update storage is a consumer-side concern.
