/** * @factory/collaboration — the block tree ↔ Yjs doc codec (TASK-0092 R1). * * Doc shape (frontend-architecture §3.3 + realtime-architecture §4): * one `Y.XmlElement` per block (type `factoryBlock`, from `mapping.ts`), * attrs `{id, position, type}`, block `properties` as JSON-encoded element * attrs (`p.{key}` — atomic LWW metadata, not CRDT), and the * CRDT-mergeable text content as **named `Y.XmlText` children** * (`name` attr = the slot key: text / language / caption / title) — plus * a named `factoryRows` child element for tables (`factoryRow` children, * each holding `name="cell"` `Y.XmlText` cells — row/cell text merges, * structural row moves are CRDT). The block tree lives in a top-level * `Y.XmlFragment` (`blocks`); a `yMeta` `Y.Map` carries * `{pageId, schemaVersion}`. * * Yjs model note (recorded): `Y.XmlElement` has no named-slot API in yjs * 13.6 (`getText`/`getArray`/`getMap` do not exist) — `Y.Map`/`Y.Array` * also carry no attributes, so children are located by a `name` attribute * ({@link namedChild}). Text slots are `Y.XmlText` (character-level CRDT * merge); the rows/properties containers are `Y.XmlElement` (attrs are * supported, CRDT-merged). * * The load-bearing rule (recorded in the task sheet): **Yjs array order and * `position` order must never diverge** — a move rewrites the `position` * attr AND the fragment order in one transaction. `blocksToYDoc` builds the * fragment position-ordered; `yDocToBlockList` reads fragment order (the * doc's own order is the truth at decode time) and stamps `position` from * attrs. The round-trip property test asserts both. * * Persistence (`yDocToBlocks(doc, lastPersisted)`): walk the fragment, * diff against `lastPersistedBlocks` by id + content/properties/position, * emit the **minimal** `BlockOperation[]` set in the wire order * (deletes → updates → moves → inserts — the same contract as * `@factory/editor`'s `diffBlocks`, which this function mirrors structurally * so the two codecs agree — the two-codecs-agree property test). * * Pure + Node-testable: no DOM, no tiptap. The y-prosemirror bridge * (ProseMirror ↔ this doc) lives in the canvas (`apps/web`), which * implements `YjsDocBinding`. */ import { comparePositions, type Position } from "@factory/shared/positions"; import type { BlockContentByType, BlockOperation, BlockPatch, BlockType, } from "@factory/shared/dto"; import { betweenPosition, minPosition } from "@factory/shared/positions"; import * as Y from "yjs"; import { mappingForType } from "./mapping.js"; /** The codec's schema version (bumped when the doc shape changes). */ export const Y_SCHEMA_VERSION = 1; /** The top-level fragment name. */ export const FRAGMENT_NAME = "blocks"; /** The meta map name (carries `{pageId, schemaVersion}`). */ export const META_NAME = "yMeta"; /** Y.XmlElement type name for the table rows container. */ const ROWS_ELEMENT_TYPE = "factoryRows"; /** Y.XmlElement type name for one table row. */ const ROW_ELEMENT_TYPE = "factoryRow"; /** The `name` attr value of the rows container child. */ const ROWS_SLOT = "rows"; /** The `name` attr value of a row child. */ const ROW_SLOT = "row"; /** The `name` attr value of a cell text child. */ const CELL_SLOT = "cell"; /** Prefix of the element attrs that carry block properties. */ const PROP_ATTR_PREFIX = "p."; /** Block-shaped record (structural — the editor package's `BlockDto`). */ export interface BlockLike { id: string; type: BlockType; content: BlockContentByType[BlockType]; parentId: string | null; position: Position; /** Block properties (custom attributes) — wholesale-replaced on update. */ properties?: Record; } export interface BlocksToYDocOptions { readonly pageId?: string; } function newFragment(doc: Y.Doc): Y.XmlFragment { return doc.getXmlFragment(FRAGMENT_NAME); } function setMeta(doc: Y.Doc, pageId?: string): void { const meta = doc.getMap(META_NAME); meta.set("schemaVersion", Y_SCHEMA_VERSION); if (pageId !== undefined) meta.set("pageId", pageId); } function byPosition(a: { position: Position }, b: { position: Position }): number { return comparePositions(a.position, b.position); } /** * Set a node attr, widening past yjs 13.6's d.ts (`setAttribute` is typed * against `KV = Record`), but the runtime — and y-prosemirror, * which stores ProseMirror attrs the same way — keeps `string | number | * boolean | null` values natively. The value must NOT be stringified here: * the round-trip read returns the stored type, and content equality (`5` vs * `"5"`) depends on it. `null` deletes the attr (yjs runtime behavior). */ function setAttr( el: Y.XmlElement, key: string, value: string | number | boolean | null, ): void { (el as unknown as { setAttribute(k: string, v: unknown): void }).setAttribute(key, value); } /** * The named child of an element (children are located by their `name` * attr — see the module doc: yjs 13.6 `Y.XmlElement` has no named-slot * API). Returns null when absent. */ export function namedChild(el: Y.XmlElement, name: string): Y.XmlElement | Y.XmlText | null { for (const child of el.toArray()) { if (child instanceof Y.XmlText || child instanceof Y.XmlElement) { if (child.getAttribute("name") === name) return child; } } return null; } /** The named `Y.XmlText` slot (created on first use). */ function textSlot(el: Y.XmlElement, key: string): Y.XmlText { const existing = namedChild(el, key); if (existing instanceof Y.XmlText) return existing; if (existing instanceof Y.XmlElement) { // A slot changed shape (should not happen within one doc lifetime); // remove the stale child and create the text slot. const idx = (el.toArray() as (Y.XmlElement | Y.XmlText)[]).indexOf(existing); if (idx >= 0) el.delete(idx, 1); } const text = new Y.XmlText(""); text.setAttribute("name", key); el.insert(0, [text]); return text; } /** Set a text slot to exactly `value` with minimal CRDT edits. */ function setTextSlot(el: Y.XmlElement, key: string, value: string): void { const text = textSlot(el, key); const current = text.toString(); if (current.length > value.length) text.delete(value.length, current.length - value.length); else if (value.length > current.length) text.insert(current.length, value.slice(current.length)); } /** The named rows container (created on first use). */ function rowsSlot(el: Y.XmlElement): Y.XmlElement { const existing = namedChild(el, ROWS_SLOT); if (existing instanceof Y.XmlElement) return existing; if (existing instanceof Y.XmlText) { const idx = (el.toArray() as (Y.XmlElement | Y.XmlText)[]).indexOf(existing); if (idx >= 0) el.delete(idx, 1); } const rows = new Y.XmlElement(ROWS_ELEMENT_TYPE); rows.setAttribute("name", ROWS_SLOT); el.insert(0, [rows]); return rows; } /** * Reconcile the rows container with `targetRows` using minimal CRDT * edits (row add/remove/resize + per-cell text edits). */ function setRows(el: Y.XmlElement, targetRows: readonly (string | undefined)[]): void { const rows = rowsSlot(el); const currentRows = rows.toArray() as Y.XmlElement[]; if (currentRows.length > targetRows.length) { rows.delete(targetRows.length, currentRows.length - targetRows.length); } for (let r = 0; r < targetRows.length; r += 1) { const targetRow = targetRows[r] ?? []; let row = (rows.toArray() as Y.XmlElement[])[r]; if (row === undefined) { row = new Y.XmlElement(ROW_ELEMENT_TYPE); row.setAttribute("name", ROW_SLOT); rows.insert(r, [row]); } const cells = row.toArray() as Y.XmlText[]; if (cells.length > targetRow.length) { row.delete(targetRow.length, cells.length - targetRow.length); } for (let c = 0; c < targetRow.length; c += 1) { const value = String(targetRow[c] ?? ""); let cell = (row.toArray() as Y.XmlText[])[c]; if (cell === undefined || !(cell instanceof Y.XmlText)) { cell = new Y.XmlText(""); cell.setAttribute("name", CELL_SLOT); row.insert(c, [cell]); } const current = cell.toString(); if (current.length > value.length) cell.delete(value.length, current.length - value.length); else if (value.length > current.length) cell.insert(current.length, value.slice(current.length)); } } } function setSlot( el: Y.XmlElement, key: string, value: unknown, ): void { if (typeof value === "string") { setTextSlot(el, key, value); } else if (Array.isArray(value)) { setRows(el, value as readonly (string | undefined)[]); } else if (value !== undefined) { setAttr(el, key, value as string | number | boolean | null); } } /** Build one `factoryBlock` Y.XmlElement from a block (content per mapping). */ export function blockToYElement(block: BlockLike): Y.XmlElement { const mapping = mappingForType(block.type); const el = new Y.XmlElement(mapping.yNodeType); el.setAttribute("id", block.id); el.setAttribute("position", block.position); el.setAttribute("type", block.type); const content = block.content as Record; for (const key of mapping.textSlots) { if (content[key] !== undefined) setSlot(el, key, content[key]); } if (mapping.hasRows && content.rows !== undefined) { setSlot(el, "rows", content.rows as unknown[]); } for (const key of mapping.attrKeys) { if (content[key] !== undefined) { setAttr(el, key, content[key] as string | number | boolean | null); } } if (block.properties !== undefined) { for (const [k, v] of Object.entries(block.properties)) { if (v !== undefined) el.setAttribute(PROP_ATTR_PREFIX + k, JSON.stringify(v)); } } return el; } /** * Build the Y.Doc from a flat block forest (the hydrator's entry). * Siblings are appended in `position` order (children nested under their * parent's element); throws on duplicate ids or a missing parent. */ export function blocksToYDoc( blocks: readonly BlockLike[], opts: BlocksToYDocOptions = {}, ): Y.Doc { const doc = new Y.Doc(); setMeta(doc, opts.pageId); const seen = new Set(); for (const block of blocks) { if (seen.has(block.id)) { throw new Error(`blocksToYDoc: duplicate block id "${block.id}"`); } seen.add(block.id); } const childrenByParent = new Map(); for (const block of blocks) { if (block.parentId !== null && !seen.has(block.parentId)) { throw new Error( `blocksToYDoc: block "${block.id}" references missing parent "${block.parentId}"`, ); } const list = childrenByParent.get(block.parentId) ?? []; list.push(block); childrenByParent.set(block.parentId, list); } const fragment = newFragment(doc); // Build recursively: a block's element has its children NESTED (inserted // into the parent element), so the fragment/element tree matches the // parentId forest. Children are created fresh (not yet integrated), so // inserting them into the (also un-integrated) parent is safe — the // prelim content flushes on first integration. const childrenByParentId = (id: string): BlockLike[] => (childrenByParent.get(id) ?? []).slice().sort(byPosition); const build = (block: BlockLike): Y.XmlElement => buildSubtree(block, childrenByParentId); for (const root of (childrenByParent.get(null) ?? []).slice().sort(byPosition)) { fragment.insert(fragment.length, [build(root)]); } return doc; } /** Build a fresh (never-integrated) `factoryBlock` element for `block` with * its children (looked up by id, position-sorted) NESTED underneath. Shared * by the hydrator ({@link blocksToYDoc}) and the move applier (a moved * subtree is deleted and re-built this way — re-inserting an ALREADY * integrated Y.XmlElement crashes yjs 13.6, and `clone(true)` silently * drops the XmlText slots' `name` attribute because `YXmlText.clone()` * re-creates the text from its delta without copying attrs). */ function buildSubtree( block: BlockLike, childrenOf: (id: string) => BlockLike[], ): Y.XmlElement { const el = blockToYElement(block); for (const child of childrenOf(block.id)) { el.insert(el.length, [buildSubtree(child, childrenOf)]); } return el; } function textOf(el: Y.XmlElement, key: string): string | null { const slot = namedChild(el, key); return slot instanceof Y.XmlText ? slot.toString() : null; } function rowsOf(el: Y.XmlElement): string[][] | null { const rows = namedChild(el, ROWS_SLOT); if (!(rows instanceof Y.XmlElement)) return null; return (rows.toArray() as Y.XmlElement[]).map((row) => (row.toArray() as (Y.XmlText | Y.XmlElement)[]).map((cell) => cell instanceof Y.XmlText ? cell.toString() : "", ), ); } function propertiesOf(el: Y.XmlElement): Record | undefined { const properties: Record = {}; let has = false; const attrs = el.getAttributes() as Record; for (const [k, v] of Object.entries(attrs)) { if (k.startsWith(PROP_ATTR_PREFIX) && typeof v === "string") { properties[k.slice(PROP_ATTR_PREFIX.length)] = JSON.parse(v); has = true; } } return has ? properties : undefined; } /** Extract one block (without children) from a `factoryBlock` element. */ export function yElementToBlock(el: Y.XmlElement, parentId: string | null): BlockLike { const type = el.getAttribute("type") as BlockType; const mapping = mappingForType(String(type)); const content: Record = {}; for (const key of mapping.textSlots) { const value = textOf(el, key); if (value !== null) content[key] = value; } if (mapping.hasRows) { const rows = rowsOf(el); if (rows !== null) content.rows = rows; } for (const key of mapping.attrKeys) { const value = el.getAttribute(key); if (value !== null && value !== undefined) content[key] = value; } const block: BlockLike = { id: String(el.getAttribute("id") ?? ""), type, content: content as BlockContentByType[BlockType], parentId, position: String(el.getAttribute("position") ?? ""), }; const properties = propertiesOf(el); if (properties !== undefined) block.properties = properties; return block; } /** * The block children of a parent (a block is recognized by its `id` attr — * the slot children, named text/rows cells, carry no `id` and must never be * mistaken for blocks by the walkers). `getAttribute` returns `undefined` * for absent attrs (yjs 13.6 — not null), so the check is loose. */ function blockChildren(parent: YParent): Y.XmlElement[] { return (parent.toArray() as (Y.XmlElement | Y.XmlText)[]).filter( (c): c is Y.XmlElement => c instanceof Y.XmlElement && c.getAttribute("id") != null, ); } /** Recursively capture the block subtree rooted at `el` (it and its block * descendants) as plain {@link BlockLike} data (DFS, children right after * their parent; each block's `parentId` is its in-tree parent). The inverse * of {@link buildSubtree}: a moved subtree is captured with this, the * original is deleted, and the fresh rebuild goes through the encoder's own * builder — yjs 13.6 crashes when an ALREADY-integrated `Y.XmlElement` is * re-inserted, and `clone(true)` silently drops the XmlText slots' `name` * attribute (`YXmlText.clone` rebuilds from the delta without attrs). */ function captureSubtree(el: Y.XmlElement, parentId: string | null): BlockLike[] { const out: BlockLike[] = [yElementToBlock(el, parentId)]; for (const child of blockChildren(el)) { out.push(...captureSubtree(child, String(el.getAttribute("id")))); } return out; } /** * Walk the fragment to a flat block list — DFS, children immediately after * their parent, fragment order is the sibling order (the decode-time truth; * the encoder always wrote it position-ordered). */ export function yDocToBlockList(doc: Y.Doc): BlockLike[] { const fragment = newFragment(doc); const out: BlockLike[] = []; const walk = (els: Y.XmlElement[], parentId: string | null): void => { for (const el of els) { out.push(yElementToBlock(el, parentId)); walk(blockChildren(el), el.getAttribute("id") as string); } }; walk(blockChildren(fragment), null); return out; } /** * The persister's entry (TASK-0092 R1): walk the fragment position-ordered, * diff against `lastPersistedBlocks` (by id + content/properties/position), * emit the **minimal** `BlockOperation[]` set (wire order: deletes → * updates → moves → inserts). Returns `[]` when the doc equals the * baseline (the idempotency assert: the post-rebase flush is a no-op). */ export function yDocToBlocks( doc: Y.Doc, lastPersisted: readonly BlockLike[], ): BlockOperation[] { const current = yDocToBlockList(doc); return diffBlockSets(lastPersisted, current); } /** Deep, deterministic equality for plain-JSON values. */ export function jsonEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") { return false; } const ka = Object.keys(a as Record).sort(); const kb = Object.keys(b as Record).sort(); if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false; return ka.every((k) => jsonEqual((a as Record)[k], (b as Record)[k])); } export interface BlockSetDiff { readonly deletes: readonly string[]; readonly updates: readonly { readonly id: string; readonly patch: { content?: Record; properties?: Record; }; }[]; readonly moves: readonly { readonly id: string; readonly before?: string; readonly after?: string }[]; readonly inserts: readonly BlockLike[]; } /** * Minimal structural diff between two same-page block sets (the shared * logic behind {@link yDocToBlocks}; mirrors `@factory/editor`'s * `diffBlocks` so both codecs agree — the two-codecs-agree property test). * * - A `move`'s `before`/`after` are sibling ids in the *destination* order * (at most one; a cross-parent move with no anchors still emits — the * server resolves end-of-list; wire gap: `moveOp` has no `parentId`, * recorded in the editor package). * - Send order: deletes → updates → moves → inserts. */ export function diffBlockSets( previous: readonly BlockLike[], next: readonly BlockLike[], ): BlockOperation[] { const prevById = new Map(); for (const b of previous) prevById.set(b.id, b); const nextById = new Map(); for (const b of next) nextById.set(b.id, b); const deletes = previous.filter((b) => !nextById.has(b.id)).map((b) => b.id); const inserts = next.filter((b) => !prevById.has(b.id)); const byParent = new Map(); for (const b of next) { const list = byParent.get(b.parentId) ?? []; list.push(b); byParent.set(b.parentId, list); } for (const list of byParent.values()) list.sort(byPosition); const updates: { id: string; patch: { content?: Record; properties?: Record }; }[] = []; const moves: { id: string; before?: string; after?: string }[] = []; for (const [id, nb] of nextById) { const pb = prevById.get(id); if (pb === undefined) continue; const contentChanged = !jsonEqual(pb.content, nb.content); const propertiesChanged = (pb.properties === undefined) !== (nb.properties === undefined) || (pb.properties !== undefined && nb.properties !== undefined && !jsonEqual(pb.properties, nb.properties)); if (contentChanged || propertiesChanged) { // A properties-ONLY change still emits an update (the persister's // flush must not drop a color/checkbox toggle that carries no text // or structural edit). const patch: { content?: Record; properties?: Record } = {}; if (contentChanged) patch.content = nb.content as Record; if (propertiesChanged && nb.properties !== undefined) patch.properties = nb.properties; updates.push({ id, patch }); } if (pb.position !== nb.position || pb.parentId !== nb.parentId) { const siblings = byParent.get(nb.parentId) ?? []; const idx = siblings.findIndex((s) => s.id === id); const before = idx > 0 ? siblings[idx - 1]?.id : undefined; const after = idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1]?.id : undefined; const move: { id: string; before?: string; after?: string } = { id }; if (before !== undefined) move.before = before; if (after !== undefined) move.after = after; if (before !== undefined || after !== undefined || pb.parentId !== nb.parentId) { moves.push(move); } } } return toOperations(deletes, updates, moves, inserts); } /** Assemble a diff into wire `BlockOperation`s (send order). */ export function toOperations( deletes: readonly string[], updates: BlockSetDiff["updates"], moves: BlockSetDiff["moves"], inserts: readonly BlockLike[], ): BlockOperation[] { const ops: BlockOperation[] = []; for (const id of deletes) ops.push({ op: "delete", id }); for (const u of updates) ops.push({ op: "update", id: u.id, patch: u.patch }); for (const m of moves) { const move: { op: "move"; id: string; before?: string; after?: string } = { op: "move", id: m.id }; // Wire contract: at most one of before/after (the shared // blockOperationSchema's superRefine). Prefer the `before` anchor; // fall back to `after` when the block leads its destination siblings. if (m.before !== undefined) move.before = m.before; else if (m.after !== undefined) move.after = m.after; ops.push(move); } for (const b of inserts) { const block: { id?: string; type: BlockType; content: Record; properties?: Record; parentId?: string; position: Position; } = { type: b.type, content: b.content as Record, position: b.position, }; // Preserve the block's id when it has one (the wire insert accepts an // explicit id; the CRDT applier honors it — required for the server's // flush-rebase, which round-trips diffBlockSets ops back into the doc // and must land the block at its REAL id, not a server-minted one). if (b.id !== undefined && b.id !== "") block.id = b.id; if (b.properties !== undefined) block.properties = b.properties; if (b.parentId !== null) block.parentId = b.parentId; ops.push({ op: "insert", block }); } return ops; } // --- plain-blocks op applier (the non-CRDT path of the two-codecs test) ------- /** "After everything" sentinel: the max-length position (64 z's — the * alphabet's top char) sorts above every valid position (a position * equal to it is never assigned; one greater would exceed MAX_LEN). */ const END_SENTINEL = "z".repeat(64); // The smallest valid position ("0"). The virtual lower bound when a block // lands at the FRONT of its destination list (the anchor is the list's first // sibling, so there is no previous-sibling position to use as the lower // bound). The empty string is NOT a valid position (isValidPosition rejects // length 0), so it must never be passed to betweenPosition. const START_SENTINEL = minPosition(); /** * The canonical position for a block landing at the wire anchors * (at most one, the wire contract), given the destination siblings * ALREADY EXCLUDING the landing block (position-sorted). Anchor * semantics (the diff's contract, pinned by the editor codec tests): * - `before: X` — X is the landing block's PREDECESSOR ("X is before * me") → land AFTER X: `between(X, X's next sibling)`. * - `after: X` — X is the landing block's SUCCESSOR ("X is after me") * → land BEFORE X: `between(X's previous sibling, X)`. * Shared by both appliers so the plain and Yjs results agree byte-for-byte * (the two-codecs test). */ export function positionForAnchors( siblings: readonly { id: string; position: Position }[], before: string | undefined, after: string | undefined, ): Position { if (before !== undefined) { const idx = siblings.findIndex((s) => s.id === before); if (idx >= 0) { const anchor = siblings[idx]; const next = siblings[idx + 1]; if (anchor !== undefined) { return betweenPosition(anchor.position, next !== undefined ? next.position : END_SENTINEL); } } } if (after !== undefined) { const idx = siblings.findIndex((s) => s.id === after); if (idx >= 0) { const anchor = siblings[idx]; const prev = siblings[idx - 1]; if (anchor !== undefined) { return betweenPosition(prev !== undefined ? prev.position : START_SENTINEL, anchor.position); } } } const last = siblings[siblings.length - 1]; return last !== undefined ? betweenPosition(last.position, END_SENTINEL) : betweenPosition(START_SENTINEL, END_SENTINEL); } /** The array index a block lands at for `before`/`after` (siblings are * position-sorted — findIndex is valid). Same semantics as * {@link positionForAnchors}: `before` = predecessor (land at idx+1), * `after` = successor (land at idx). */ export function indexForAnchors( siblings: readonly T[], before: string | undefined, after: string | undefined, ): number { if (before !== undefined) { const idx = siblings.findIndex((s) => s.id === before); return idx >= 0 ? idx + 1 : siblings.length; } if (after !== undefined) { const idx = siblings.findIndex((s) => s.id === after); return idx >= 0 ? idx : siblings.length; } return siblings.length; } /** * Apply wire block operations to a plain block set (pure — the non-CRDT * half of the two-codecs-agree property test; the REST batch path applies * the same ops server-side). Ops must arrive in wire order (deletes → * updates → moves → inserts). A `move`'s destination is the anchor's * parent (the wire `move` has no `parentId` — recorded gap); cross-page * moves (`toPageId`) are out of scope for the single-page test (the ops * never carry one — recorded). */ export function applyBlockOperations( blocks: readonly BlockLike[], operations: readonly BlockOperation[], ): BlockLike[] { let current: BlockLike[] = blocks.map((b) => ({ ...b })); for (const op of operations) { current = applyOne(current, op); } // Canonical order (parents before children, siblings by position) — // the same order yDocToBlockList emits (the two-appliers test). return canonicalOrder(current); } function applyOne(current: BlockLike[], op: BlockOperation): BlockLike[] { switch (op.op) { case "delete": { return current.filter((b) => b.id !== op.id); } case "update": { return current.map((b) => { if (b.id !== op.id) return b; const next: BlockLike = { ...b }; if (op.patch.content !== undefined) { next.content = op.patch.content as BlockContentByType[BlockType]; } if (op.patch.properties !== undefined) next.properties = op.patch.properties; if (op.patch.position !== undefined) next.position = op.patch.position; return next; }); } case "move": { const moved = current.find((b) => b.id === op.id); if (moved === undefined || op.toPageId !== undefined) return current; const rest = current.filter((b) => b.id !== op.id); const destParentId = resolveDestParentId(current, moved.parentId, op.before, op.after); return placeBlock(rest, moved, destParentId, op.before, op.after); } case "insert": { // `position` is optional on the wire insert; the landing position is // re-derived from the anchors / payload in `placeBlock`, so a blank // placeholder is safe (it is never observed). const block: BlockLike = { id: op.block.id ?? `blk_${Math.floor(Math.random() * 1e9).toString(16)}`, type: op.block.type, content: op.block.content as BlockContentByType[BlockType], parentId: op.block.parentId ?? null, position: op.block.position ?? "", ...(op.block.properties !== undefined ? { properties: { ...op.block.properties } } : {}), }; const destParentId = resolveDestParentId(current, block.parentId, op.before, op.after); return placeBlock(current, block, destParentId, op.before, op.after); } } } /** The destination parent id, mirroring the Yjs applier exactly: the * anchor's parent wins (the wire `move` has no `parentId` — the anchor IS * the destination; a cross-parent move is expressible only via its * anchors). With no anchor: a move keeps its current parent (the recorded * wire gap — a move into an empty new parent is unanchorable), an insert * keeps its payload parent (root when null — the Yjs applier's root * fragment is the mirror). */ function resolveDestParentId( blocks: readonly BlockLike[], explicitParentId: string | null, before: string | undefined, after: string | undefined, ): string | null { const anchorId = before ?? after; if (anchorId !== undefined) { const anchor = blocks.find((b) => b.id === anchorId); if (anchor !== undefined) return anchor.parentId; } return explicitParentId; } /** Rebuild the flat list in CANONICAL order (parents before children, * siblings by position) — the same order `yDocToBlockList` emits, so * plain-vs-Yjs comparison is position-independent. */ function canonicalOrder(blocks: readonly BlockLike[]): BlockLike[] { const ids = new Set(blocks.map((b) => b.id)); const byParent = new Map(); for (const b of blocks) { const list = byParent.get(b.parentId) ?? []; list.push(b); byParent.set(b.parentId, list); } for (const list of byParent.values()) list.sort(byPosition); const out: BlockLike[] = []; const walk = (parentId: string | null): void => { for (const b of byParent.get(parentId) ?? []) { out.push(b); if (ids.has(b.id)) walk(b.id); } }; walk(null); return out; } /** Place a block: resolve the destination parent, compute the landing * position, rebuild canonical. Shared by move and insert (the Yjs * applier's twin — both re-derive the identical result). */ function placeBlock( current: readonly BlockLike[], block: BlockLike, destParentId: string | null, before: string | undefined, after: string | undefined, ): BlockLike[] { const landing = block.id; const siblings = current .filter((b) => b.parentId === destParentId && b.id !== landing) .sort(byPosition); const next: BlockLike = { ...block, parentId: destParentId, position: landingPosition(siblings, block.position, before, after), }; return canonicalOrder([...current, next]); } // --- Yjs-doc op applier (the CRDT half of the two-codecs test) ---------------- type YParent = Y.XmlElement | Y.XmlFragment; function elementOf(doc: Y.Doc, id: string): Y.XmlElement | undefined { const walk = (els: Y.XmlElement[]): Y.XmlElement | undefined => { for (const el of els) { if (el.getAttribute("id") === id) return el; const found = walk(blockChildren(el)); if (found !== undefined) return found; } return undefined; }; return walk(blockChildren(newFragment(doc))); } function parentOf(doc: Y.Doc, el: Y.XmlElement): YParent | null { const walk = (els: Y.XmlElement[], parent: YParent | null): YParent | null => { for (const candidate of els) { if (candidate === el) return parent; const found = walk(blockChildren(candidate), candidate); if (found !== null) return found; } return null; }; return walk(blockChildren(newFragment(doc)), newFragment(doc)); } function removeElement(parent: YParent, el: Y.XmlElement): void { const idx = (parent.toArray() as Y.XmlElement[]).indexOf(el); if (idx >= 0) parent.delete(idx, 1); } /** The parent block id of a `YParent` (null for the root fragment). */ function parentIdOf(doc: Y.Doc, parent: YParent): string | null { if (parent === newFragment(doc)) return null; return String((parent as Y.XmlElement).getAttribute("id") ?? ""); } /** The destination parent of a move/insert: the anchor's parent (the wire * `move` has no `parentId` — the anchor IS the destination; recorded wire * gap); `null` when no anchor resolves. */ function anchorParent(doc: Y.Doc, before: string | undefined, after: string | undefined): YParent | null { const anchorId = before ?? after; if (anchorId === undefined) return null; const anchor = elementOf(doc, anchorId); return anchor !== undefined ? parentOf(doc, anchor) : null; } /** * The destination siblings (position-sorted, excluding the landing block) * as plain `{id, position}` records — the shared input to * {@link positionForAnchors} / {@link indexForAnchors}, so the Yjs and * plain appliers compute identical positions (the two-codecs test). */ function siblingsOf(parent: YParent, excludeId: string): { id: string; position: Position }[] { return blockChildren(parent) .filter((e) => e.getAttribute("id") !== excludeId) .map((e) => ({ id: String(e.getAttribute("id") ?? ""), position: String(e.getAttribute("position") ?? ""), })) .sort(byPosition); } /** Compute the landing position for a block (shared by move and insert; * `siblings` is the destination's current siblings, position-sorted, * EXCLUDING the landing block). Mirrors the Yjs applier exactly: * - an anchor resolves → the canonical anchor position (the diff * derived the same value from the same destination siblings); * - no anchor + a payload position (inserts) → the payload position * (the diff set it from the next order — exact); * - no anchor + no payload position (the wire `move` gap — a move into * an empty new parent is unanchorable; recorded) → append after the * destination's last sibling (`between(last, END)`, or the empty-doc * position when the destination is empty). */ function landingPosition( siblings: readonly { id: string; position: Position }[], payloadPosition: Position | undefined, before: string | undefined, after: string | undefined, ): Position { if (before !== undefined || after !== undefined) { return positionForAnchors(siblings, before, after); } if (payloadPosition !== undefined && payloadPosition !== "") return payloadPosition; const last = siblings[siblings.length - 1]; return last !== undefined ? betweenPosition(last.position, END_SENTINEL) : betweenPosition(START_SENTINEL, END_SENTINEL); } /** * Apply a block patch to an element (text slots → Y.Text edits, attr keys * → atomic attrs, properties → `p.*` JSON attrs, position → attr). The * patch's `content` is the block's FULL next content (the diff's * contract — the plain applier replaces `content` wholesale), so a key * ABSENT from the patch clears the slot (text → "", attr → deleted); a * key present sets it. This keeps the Y.Doc and the plain block set in * lockstep. */ function applyPatchToElement(el: Y.XmlElement, patch: BlockPatch): void { if (patch.type !== undefined) el.setAttribute("type", patch.type); const mapping = mappingForType(String(patch.type ?? el.getAttribute("type") ?? "")); if (patch.content !== undefined) { // WHOLESALE content replacement (mirrors the plain applier's // `next.content = op.patch.content`). The diff emits the block's ENTIRE // next content, never a character delta — raw Yjs text deltas ride the // 0x01 binary channel, not these ops. So a slot's previous value is // deleted before the new value is written: applying `"edited"` to a slot // holding `"Ta"` must yield `"edited"`, not the incremental-edit // `"Taited"`. All deletes run before any inserts (deleting one child // does not shift the index of a sibling we address by reference). const childArray = () => el.toArray() as (Y.XmlElement | Y.XmlText)[]; for (const key of mapping.textSlots) { const slot = namedChild(el, key); if (slot instanceof Y.XmlText) el.delete(childArray().indexOf(slot), 1); } const rowsEl = namedChild(el, ROWS_SLOT); if (rowsEl !== null) el.delete(childArray().indexOf(rowsEl), 1); for (const key of mapping.textSlots) { const value = patch.content[key]; // A key ABSENT from the replacement stays deleted (cleared above) — // a wholesale replacement must not materialize an empty slot, which // the next read would emit as an empty-string key the plain applier // omits. A present-but-empty value (`""`) still creates the slot. if (value !== undefined) { setTextSlot(el, key, typeof value === "string" ? value : String(value)); } } if (mapping.hasRows) { const value = patch.content.rows; if (Array.isArray(value)) { setRows(el, value as readonly (string | undefined)[]); } } for (const key of mapping.attrKeys) { const value = patch.content[key]; // `undefined`/absent → delete the attr (the read omits absent keys, // matching the plain applier's wholesale content replacement); yjs // `setAttribute(key, null)` deletes the attr too, but `undefined` // would be a no-op — so use null for the absent case. setAttr(el, key, value === undefined ? null : (value as string | number | boolean | null)); } } if (patch.properties !== undefined) { // Wholesale replacement: clear every `p.*` attr, then set the patch's. const attrs = el.getAttributes() as Record; for (const k of Object.keys(attrs)) { if (k.startsWith(PROP_ATTR_PREFIX)) el.removeAttribute(k); } for (const [k, v] of Object.entries(patch.properties)) { if (v === undefined) continue; el.setAttribute(PROP_ATTR_PREFIX + k, JSON.stringify(v)); } } if (patch.position !== undefined) el.setAttribute("position", patch.position); } /** * Apply wire block operations to the Y.Doc (the CRDT half of the * two-codecs-agree property test). A `move` rewrites the `position` attr * AND the fragment order in one transaction (the load-bearing codec rule); * the destination parent resolves from the anchors (the wire gap: no * `parentId` on moves). Cross-`toPageId` moves are no-ops here — the * property test is single-page; cross-page moves ride the REST batch path * (recorded). */ export function applyBlockOperationsToYDoc( doc: Y.Doc, operations: readonly BlockOperation[], ): void { for (const op of operations) { switch (op.op) { case "delete": { const el = elementOf(doc, op.id); if (el !== undefined) { const parent = parentOf(doc, el); if (parent !== null) removeElement(parent, el); } break; } case "update": { const el = elementOf(doc, op.id); if (el !== undefined) applyPatchToElement(el, op.patch); break; } case "move": { const el = elementOf(doc, op.id); if (el === undefined || op.toPageId !== undefined) break; const currentParent = parentOf(doc, el); // el exists in the doc (elementOf just found it), so its parent // resolves — `null` means the walk never matched, which cannot // happen; guard anyway so `currentParent` narrows below. if (currentParent === null) break; // Destination: the anchor's parent (the wire `move` has no // `parentId` — the anchor IS the destination); no anchor keeps // the current parent (mirrors the plain applier's // resolveDestParentId exactly). const destParent = anchorParent(doc, op.before, op.after) ?? currentParent; if (destParent === null) break; const siblings = siblingsOf(destParent, op.id); // The load-bearing rule: the `position` attr and the fragment // order are rewritten in the SAME operation (never diverge). const newPosition = landingPosition( siblings, String(el.getAttribute("position") ?? ""), op.before, op.after, ); if ( destParent === currentParent && op.before === undefined && op.after === undefined ) { // No-anchor same-parent move: only the position attr changes // (the order is untouched — mirrors the plain applier, whose // canonical rebuild is a no-op when the position is unchanged). el.setAttribute("position", newPosition); break; } // Move = delete old + insert FRESH (the y-prosemirror 1.3.7 // snapshot-diff model: a reordered node is deleted and re-created // from its data, never re-inserted). Re-inserting an ALREADY // integrated Y.XmlElement crashes yjs 13.6 (`YXmlFragment._integrate` // re-runs `insert(0, _prelimContent)` with null), and `clone(true)` // is no good either: `YXmlText.clone()` rebuilds the text from its // delta WITHOUT copying the `name` attr, so a cloned slot is // unreadable by the codec (spurious empty-content update). Capture // the subtree as plain data instead, delete the original, and // rebuild it fresh through the encoder's own builder (blockToYElement // — the property-tested lossless path). const captured = captureSubtree(el, parentIdOf(doc, currentParent)).map((b) => ({ ...b, position: newPosition, })); removeElement(currentParent, el); const childrenOf = (id: string): BlockLike[] => captured.filter((b) => b.parentId === id); const fresh = buildSubtree(captured[0]!, childrenOf); destParent.insert(indexForAnchors(siblings, op.before, op.after), [fresh]); break; } case "insert": { const id = op.block.id ?? `blk_${Math.floor(Math.random() * 1e9).toString(16)}`; // Destination parent (mirrors the plain applier's // resolveDestParentId exactly): the anchor's parent wins (the // anchors are the destination); then the payload's `parentId` // when it exists in the doc; else the root fragment. let destParent: YParent = newFragment(doc); const anchor = anchorParent(doc, op.before, op.after); if (anchor !== null) { destParent = anchor; } else if (op.block.parentId !== undefined) { const parentEl = elementOf(doc, op.block.parentId); if (parentEl !== undefined) destParent = parentEl; } // `position` is optional on the wire insert; `landingPosition` // re-derives it below (anchors → payload → end-of-list), so a blank // placeholder is safe (it is never observed). const block: BlockLike = { id, type: op.block.type, content: op.block.content as BlockContentByType[BlockType], parentId: op.block.parentId ?? null, position: op.block.position ?? "", ...(op.block.properties !== undefined ? { properties: { ...op.block.properties } } : {}), }; const el = blockToYElement(block); el.setAttribute( "position", landingPosition(siblingsOf(destParent, id), op.block.position, op.before, op.after), ); destParent.insert(indexForAnchors(siblingsOf(destParent, id), op.before, op.after), [el]); break; } } } } // --- state vector / update encodings (base64 over the wire) ------------------- const B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; export function bytesToBase64(bytes: Uint8Array): string { let out = ""; for (let i = 0; i < bytes.length; i += 3) { const b0 = bytes[i] ?? 0; const b1 = i + 1 < bytes.length ? (bytes[i + 1] ?? 0) : 0; const b2 = i + 2 < bytes.length ? (bytes[i + 2] ?? 0) : 0; out += B64_CHARS[(b0 >> 2) & 0x3f] ?? ""; out += B64_CHARS[((b0 & 0x03) << 4) | ((b1 >> 4) & 0x0f)] ?? ""; out += i + 1 < bytes.length ? (B64_CHARS[((b1 & 0x0f) << 2) | ((b2 >> 6) & 0x03)] ?? "") : "="; out += i + 2 < bytes.length ? (B64_CHARS[b2 & 0x3f] ?? "") : "="; } return out; } const B64_LOOKUP: Record = {}; for (let i = 0; i < B64_CHARS.length; i += 1) B64_LOOKUP[B64_CHARS[i] ?? ""] = i; export function base64ToBytes(input: string): Uint8Array { const clean = input.replace(/=+$/, ""); const out = new Uint8Array(Math.floor((clean.length * 3) / 4)); let o = 0; for (let i = 0; i < clean.length; i += 4) { const a = B64_LOOKUP[clean[i] ?? ""] ?? 0; const b = B64_LOOKUP[clean[i + 1] ?? ""] ?? 0; const c = i + 2 < clean.length ? (B64_LOOKUP[clean[i + 2] ?? ""] ?? 0) : 0; const d = i + 3 < clean.length ? (B64_LOOKUP[clean[i + 3] ?? ""] ?? 0) : 0; const n = (a << 18) | (b << 12) | (c << 6) | d; if (o < out.length) out[o] = (n >> 16) & 0xff; if (o + 1 < out.length) out[o + 1] = (n >> 8) & 0xff; if (o + 2 < out.length) out[o + 2] = n & 0xff; o += 3; } return out; } /** Encode the doc's full state as a base64 Yjs update (the `sync.state` payload). */ export function encodeDocAsUpdate(doc: Y.Doc): string { return bytesToBase64(Y.encodeStateAsUpdate(doc)); } /** Encode the doc's state vector as base64 (the `sync.request` payload). */ export function encodeDocStateVector(doc: Y.Doc): string { return bytesToBase64(Y.encodeStateVector(doc)); } /** Decode a base64 Yjs update / state vector back to bytes. */ export function decodeBase64Yjs(input: string): Uint8Array { return base64ToBytes(input); }