From 7ad42826518ffee45694390b648c56b3f659b0ec Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 26 Jun 2026 15:51:17 +0300 Subject: [PATCH] =?UTF-8?q?fix(git-sync):=20normalize=20merge=20key=20agai?= =?UTF-8?q?nst=20schema=20defaults=20=E2=80=94=20cover=20all=20node/mark?= =?UTF-8?q?=20default-attr=20duplication=20triggers=20(image,=20link,=20hi?= =?UTF-8?q?ghlight,=20=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The point-fix (7a7b840e) excluded only `indent: 0` via a hardcoded one-attribute denylist (`DEFAULT_KEY_ATTRS`) applied solely to ELEMENT attributes. The same divergence recurs for every attribute whose editor-ext (server) schema default the LIVE Yjs doc materializes (`TiptapTransformer.toYdoc(tiptapExtensions)`) but the git round-trip does not: the engine's `markdownToProseMirror` emits those attrs as explicit `null` (verified live: link mark `internal: null`, heading/paragraph `indent: null`), which `y-prosemirror` then drops — so the same block keys differently on the two sides, the three-way merge anchors on nothing, and the body is re-appended every reconcile cycle (unbounded, no client connected). The denylist also could not reach MARK attributes at all (marks are serialized raw in the XmlText delta), so the link mark's `internal` mismatch survived. Replace the denylist with a normalization derived from the ACTUAL ProseMirror schema (`getSchema(tiptapExtensions)`, memoized): in `serializeXmlNode`, drop any ELEMENT attribute whose value equals its node's schema default (or is null/undefined), and normalize each XmlText delta op's MARK attributes the same way against `schema.marks[name].spec.attrs`. The volatile block `id` stays excluded and genuine non-default values (a real `indent: 2`, `align: "left"`, `link.href`, highlight color) stay in the key. This is general — it covers indent, image.align, link.internal, highlight.colorName, youtube/pdf and any future node/mark — not another per-attribute denylist. Schema build is wrapped so a degenerate test stub (`tiptapExtensions: []`) degrades to dropping only null/undefined. Tests: new `yjs-body-merge.schema-defaults.spec.ts` models image/link/highlight both hand-built and through the REAL `TiptapTransformer.toYdoc` materialization (live defaults vs engine-style explicit nulls, base stale-by-one) — RED before (4 ops / growth), GREEN after (0 ops). Existing idempotency + open-editor convergence suites still pass (261 server collab+git-sync tests, tsc clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../yjs-body-merge.idempotency.spec.ts | 24 +- .../yjs-body-merge.schema-defaults.spec.ts | 316 ++++++++++++++++++ .../git-sync/services/yjs-body-merge.ts | 146 +++++--- 3 files changed, 439 insertions(+), 47 deletions(-) create mode 100644 apps/server/src/integrations/git-sync/services/yjs-body-merge.schema-defaults.spec.ts diff --git a/apps/server/src/integrations/git-sync/services/yjs-body-merge.idempotency.spec.ts b/apps/server/src/integrations/git-sync/services/yjs-body-merge.idempotency.spec.ts index 03e33377..b9d67297 100644 --- a/apps/server/src/integrations/git-sync/services/yjs-body-merge.idempotency.spec.ts +++ b/apps/server/src/integrations/git-sync/services/yjs-body-merge.idempotency.spec.ts @@ -1,9 +1,6 @@ import * as Y from 'yjs'; -import { - mergeXmlFragments, - mergeXmlFragments3Way, -} from './yjs-body-merge'; +import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge'; /** * Regression for the HIGH-severity runaway whole-body duplication: a page body @@ -22,8 +19,11 @@ import { * more unit — a self-sustaining loop. * * The fix normalizes the materialized default (`indent: 0`) out of the block key - * (see `DEFAULT_KEY_ATTRS` in yjs-body-merge.ts), so a live block compares equal - * to its git-round-tripped twin and the resync is a true no-op. + * (the schema-derived `serializeXmlNode` normalization in yjs-body-merge.ts drops + * every attr equal to its ProseMirror-schema default; `indent: 0` is one such), + * so a live block compares equal to its git-round-tripped twin and the resync is + * a true no-op. The sibling `yjs-body-merge.schema-defaults.spec.ts` covers the + * rest of the bug class (image.align, link mark internal, …). * * These tests model that EXACTLY at the Yjs level: a LIVE fragment whose blocks * carry `indent: 0` + block ids, versus a git-derived fragment of the SAME @@ -35,7 +35,11 @@ import { type Attrs = Record; -function el(name: string, attrs: Attrs, children: (Y.XmlElement | Y.XmlText)[]) { +function el( + name: string, + attrs: Attrs, + children: (Y.XmlElement | Y.XmlText)[], +) { const e = new Y.XmlElement(name); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string); if (children.length) e.insert(0, children); @@ -55,7 +59,11 @@ function text(s: string): Y.XmlText { * per-block `id`. `n` makes each unit's ids unique (as the editor would stamp) * while keeping the visible CONTENT byte-identical across units. */ -function unit(live: boolean, n: number, headingText = 'Big Heading'): Y.XmlElement[] { +function unit( + live: boolean, + n: number, + headingText = 'Big Heading', +): Y.XmlElement[] { const ind: Attrs = live ? { indent: 0 } : {}; const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {}); const para = (attrs: Attrs, s: string) => diff --git a/apps/server/src/integrations/git-sync/services/yjs-body-merge.schema-defaults.spec.ts b/apps/server/src/integrations/git-sync/services/yjs-body-merge.schema-defaults.spec.ts new file mode 100644 index 00000000..a9e5dbe9 --- /dev/null +++ b/apps/server/src/integrations/git-sync/services/yjs-body-merge.schema-defaults.spec.ts @@ -0,0 +1,316 @@ +import { TiptapTransformer } from '@hocuspocus/transformer'; +import * as Y from 'yjs'; + +import { tiptapExtensions } from '../../../collaboration/collaboration.util'; +import { mergeXmlFragments, mergeXmlFragments3Way } from './yjs-body-merge'; + +/** + * Regression for the BUG CLASS behind the runaway whole-body duplication: the + * point-fix (7a7b840e) only normalized `indent: 0`, but the SAME divergence + * recurs for every attribute whose editor-ext (server) schema default the live + * Yjs doc MATERIALIZES while the git round-trip — which comes through the engine + * schema (different, usually null, defaults) plus `y-prosemirror`'s null-attr + * dropping — does NOT carry. Confirmed triggers beyond `indent`: + * + * - `image.align` : editor-ext default "center" (materialized) vs engine + * default null (dropped) -> element-attr divergence. + * - link mark `internal`: editor-ext default false (materialized) vs engine + * default null -> MARK-attr divergence (the prior denylist + * could not reach marks at all — they are serialized raw in + * the XmlText delta). + * + * `highlight.colorName` is normalized too (defense-in-depth); it is NOT a strong + * real-world trigger because BOTH schemas default it to null, but the schema- + * derived normalization handles it for free and stays idempotent. + * + * The fix derives the defaults from the ACTUAL ProseMirror schema (getSchema of + * the server tiptapExtensions) and drops any element- OR mark-attribute equal to + * its schema default (or null/undefined) from the block comparison key — so a + * live block compares equal to its git-round-tripped twin and an unchanged + * resync applies 0 ops. RED before the fix (keys diverge -> ops > 0 / growth), + * GREEN after. + */ + +type Attrs = Record; + +function el( + name: string, + attrs: Attrs, + children: (Y.XmlElement | Y.XmlText)[], +): Y.XmlElement { + const e = new Y.XmlElement(name); + for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v as string); + if (children.length) e.insert(0, children); + return e; +} + +/** Text carrying marks, as the live Yjs doc stores them (XmlText format ops). */ +function markedText(s: string, marks: Record): Y.XmlText { + const t = new Y.XmlText(); + t.insert(0, s, marks); + return t; +} + +/** + * One byte-identical RICH unit: a paragraph with a LINK, a top-level IMAGE, and + * a paragraph with a HIGHLIGHT. `live` toggles exactly what the editor + * materializes but a git round-trip does not: block `id`, `indent: 0`, + * `image.align: "center"`, the link mark's `internal: false`, and the + * highlight's `colorName: null`. + */ +function richUnit(live: boolean, n: number): Y.XmlElement[] { + const ind: Attrs = live ? { indent: 0 } : {}; + const id = (base: string): Attrs => (live ? { id: `${base}${n}` } : {}); + + const linkMarks = live + ? { + link: { + href: 'https://example.com', + target: '_blank', + rel: 'noopener noreferrer nofollow', + class: null, + title: null, + internal: false, // editor-ext default, materialized + }, + } + : { + link: { + href: 'https://example.com', + target: '_blank', + rel: 'noopener noreferrer nofollow', + internal: null, // engine default + }, + }; + + const hlMarks = live + ? { highlight: { color: '#ffd43b', colorName: null } } + : { highlight: { color: '#ffd43b' } }; + + const imageAttrs: Attrs = live + ? { src: 'https://img.example.com/a.png', align: 'center' } // materialized + : { src: 'https://img.example.com/a.png' }; // align:null dropped on git side + + return [ + el('paragraph', { ...id('lp'), ...ind }, [ + markedText('click here', linkMarks), + ]), + el('image', imageAttrs, []), + el('paragraph', { ...id('hp'), ...ind }, [markedText('hot', hlMarks)]), + ]; +} + +function fragmentOf(units: Y.XmlElement[][]): { + doc: Y.Doc; + frag: Y.XmlFragment; +} { + const doc = new Y.Doc(); + const frag = doc.getXmlFragment('default'); + const blocks = units.flat(); + if (blocks.length) frag.insert(0, blocks); + return { doc, frag }; +} + +const blockCount = (frag: Y.XmlFragment): number => frag.toArray().length; + +describe('git-sync reconcile is idempotent for schema-default attrs (image/link/highlight)', () => { + const UNITS = 3; + + it('3-way: live carries image.align/link.internal/indent defaults, base stale-by-one -> 0 ops', () => { + const { doc: liveDoc, frag: live } = fragmentOf( + Array.from({ length: UNITS }, (_, i) => richUnit(true, i)), + ); + const { frag: incoming } = fragmentOf( + Array.from({ length: UNITS }, (_, i) => richUnit(false, i)), + ); + const { frag: base } = fragmentOf( + Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)), + ); + + const before = blockCount(live); + let applied = -1; + liveDoc.transact(() => { + applied = mergeXmlFragments3Way(live, incoming, base); + }); + + expect(applied).toBe(0); + expect(blockCount(live)).toBe(before); + }); + + it('2-way: live carries the materialized defaults -> 0 ops, no growth', () => { + const { doc: liveDoc, frag: live } = fragmentOf( + Array.from({ length: UNITS }, (_, i) => richUnit(true, i)), + ); + const { frag: incoming } = fragmentOf( + Array.from({ length: UNITS }, (_, i) => richUnit(false, i)), + ); + + const before = blockCount(live); + let applied = -1; + liveDoc.transact(() => { + applied = mergeXmlFragments(live, incoming); + }); + + expect(applied).toBe(0); + expect(blockCount(live)).toBe(before); + }); + + it('is a fixpoint across repeated cycles (does not grow)', () => { + const { doc: liveDoc, frag: live } = fragmentOf( + Array.from({ length: UNITS }, (_, i) => richUnit(true, i)), + ); + const incoming = () => + fragmentOf(Array.from({ length: UNITS }, (_, i) => richUnit(false, i))) + .frag; + const base = () => + fragmentOf( + Array.from({ length: UNITS - 1 }, (_, i) => richUnit(false, i)), + ).frag; + + const before = blockCount(live); + for (let cycle = 0; cycle < 5; cycle++) { + let applied = -1; + liveDoc.transact(() => { + applied = mergeXmlFragments3Way(live, incoming(), base()); + }); + expect(applied).toBe(0); + expect(blockCount(live)).toBe(before); + } + }); + + it('does NOT regress a genuine non-default value (a real link.href / image.align:left still diffs)', () => { + const { doc: liveDoc, frag: live } = fragmentOf([richUnit(true, 0)]); + const base = fragmentOf([richUnit(false, 0)]).frag; + // git genuinely changes the image alignment to a NON-default value. + const incomingUnit = richUnit(false, 0); + (incomingUnit[1] as Y.XmlElement).setAttribute('align', 'left'); + const incoming = fragmentOf([incomingUnit]).frag; + + liveDoc.transact(() => { + mergeXmlFragments3Way(live, incoming, base); + }); + + const img = live + .toArray() + .find((b) => (b as Y.XmlElement).nodeName === 'image') as Y.XmlElement; + expect(img.getAttribute('align')).toBe('left'); + }); +}); + +/** + * FAITHFUL end-to-end proof through the REAL server transformer: build the live + * doc the way the collaboration server does (defaults omitted in the JSON -> + * TiptapTransformer.toYdoc MATERIALIZES image.align:"center", link.internal:false, + * indent:0) versus the git-derived doc (engine-style: defaults emitted as + * explicit null, no block ids). An unchanged resync must apply 0 ops. + */ +describe('git-sync reconcile is idempotent through the real toYdoc materialization', () => { + const liveContent = [ + { + type: 'paragraph', + attrs: { id: 'p1' }, + content: [ + { + type: 'text', + text: 'click here', + marks: [{ type: 'link', attrs: { href: 'https://example.com' } }], + }, + ], + }, + { type: 'image', attrs: { src: 'https://img.example.com/a.png' } }, + { + type: 'paragraph', + attrs: { id: 'p2' }, + content: [ + { + type: 'text', + text: 'hot', + marks: [{ type: 'highlight', attrs: { color: '#ffd43b' } }], + }, + ], + }, + ]; + + // git/engine-style: explicit nulls for the engine-default attrs, no ids. + const gitContent = [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'click here', + marks: [ + { + type: 'link', + attrs: { + href: 'https://example.com', + target: '_blank', + rel: 'noopener noreferrer nofollow', + class: null, + title: null, + internal: null, + }, + }, + ], + }, + ], + }, + { + type: 'image', + attrs: { src: 'https://img.example.com/a.png', align: null }, + }, + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'hot', + marks: [ + { type: 'highlight', attrs: { color: '#ffd43b', colorName: null } }, + ], + }, + ], + }, + ]; + + const toYdoc = (content: unknown[]) => + TiptapTransformer.toYdoc( + { type: 'doc', content }, + 'default', + tiptapExtensions as any, + ); + + it('3-way: materialized-default live vs engine-style git, base stale-by-one -> 0 ops', () => { + const liveDoc = toYdoc(liveContent); + const targetDoc = toYdoc(gitContent); + const baseDoc = toYdoc(gitContent.slice(0, gitContent.length - 1)); + + const live = liveDoc.getXmlFragment('default'); + const before = live.toArray().length; + let applied = -1; + liveDoc.transact(() => { + applied = mergeXmlFragments3Way( + live, + targetDoc.getXmlFragment('default'), + baseDoc.getXmlFragment('default'), + ); + }); + + expect(applied).toBe(0); + expect(live.toArray().length).toBe(before); + }); + + it('2-way: materialized-default live vs engine-style git -> 0 ops', () => { + const liveDoc = toYdoc(liveContent); + const targetDoc = toYdoc(gitContent); + + const live = liveDoc.getXmlFragment('default'); + const before = live.toArray().length; + let applied = -1; + liveDoc.transact(() => { + applied = mergeXmlFragments(live, targetDoc.getXmlFragment('default')); + }); + + expect(applied).toBe(0); + expect(live.toArray().length).toBe(before); + }); +}); diff --git a/apps/server/src/integrations/git-sync/services/yjs-body-merge.ts b/apps/server/src/integrations/git-sync/services/yjs-body-merge.ts index 19493b14..7652e595 100644 --- a/apps/server/src/integrations/git-sync/services/yjs-body-merge.ts +++ b/apps/server/src/integrations/git-sync/services/yjs-body-merge.ts @@ -1,5 +1,8 @@ import * as Y from 'yjs'; +import { getSchema } from '@tiptap/core'; +import type { Schema } from '@tiptap/pm/model'; +import { tiptapExtensions } from '../../../collaboration/collaboration.util'; import { diff3Plan } from './three-way-merge'; import { buildLcsTable } from './lcs'; @@ -59,59 +62,127 @@ type XmlNode = Y.XmlElement | Y.XmlText | Y.XmlHook; const VOLATILE_KEY_ATTRS = new Set(['id']); /** - * Editor-schema attribute DEFAULTS that the live Yjs document MATERIALIZES on - * every block but a git round-trip does NOT carry — so they must be normalized - * out of the block key, otherwise an unchanged block fails to compare equal - * across `DB doc -> markdown (export) -> ProseMirror (re-import)`. + * The editor (ProseMirror) schema, built ONCE from the same `tiptapExtensions` + * the collaboration server uses to materialize Yjs docs. Memoized: building the + * schema is non-trivial and the block key is computed per block per cycle. * - * `indent: 0` is the one that bites in practice (HIGH-severity runaway whole-body - * duplication, see below). The editor's indent extension declares - * `indent.default = 0` (`packages/editor-ext/src/lib/indent.ts`), and - * `TiptapTransformer.toYdoc` STAMPS that default onto every `paragraph`/`heading` - * Yjs node — so a body that originated in the UI carries `indent: 0` on every - * block (and on the paragraph inside every list item, callout, and table cell). - * `markdownToProseMirror`, parsing clean markdown, produces NO indent attribute - * (the extension's `renderHTML` even omits it when `<= min`), so a re-imported - * body has `a: {}` where the live body has `a: { indent: 0 }`. + * Why the schema (not a hardcoded denylist): the LIVE Yjs document is produced by + * `TiptapTransformer.toYdoc(pm, 'default', tiptapExtensions)`, which STAMPS every + * schema-default attribute onto every node and mark — `indent: 0` on every + * paragraph/heading, `image.align: "center"`, the link mark's `internal: false`, + * `highlight.colorName: null`, and so on for youtube/pdf/any future node. A body + * re-imported from git comes through the engine's `markdownToProseMirror`, whose + * schema declares those attrs with DIFFERENT (usually null) defaults; the + * resulting null/absent element attrs are then DROPPED by `y-prosemirror`'s + * toYdoc. So the SAME block carries materialized defaults on the live side and + * nothing on the git side, its key diverges, the three-way merge anchors on + * NOTHING, and the whole body is RE-APPENDED every reconcile cycle — an unbounded + * duplication loop with no client connected. * - * Without this normalization EVERY live block's key differs from the same block - * re-imported from git, so the three-way merge can anchor on NOTHING: the whole - * body becomes one unanchored region, and any trailing unit that git's export - * already contains (but the merge can't match against the identical live tail) - * is RE-APPENDED on every reconcile cycle — an unbounded, self-sustaining - * whole-body duplication loop with no client connected (each grown export - * diverges from the last-pushed base by one more block). Dropping the default - * makes a live `indent: 0` block compare equal to its git-round-tripped twin, so - * the body anchors and the resync is a true no-op. - * - * Only the DEFAULT value is dropped: a genuine `indent: 2` is content and stays - * in the key (so a real indentation edit still diffs and lands). + * Deriving the defaults from the actual schema normalizes ALL such attributes + * generally (it is not another per-attribute denylist): any attribute whose value + * equals the schema default — or is null/undefined — is dropped from the key, on + * BOTH element attributes and the mark attributes inside each XmlText delta, so a + * live block compares equal to its git-round-tripped twin and an unchanged resync + * applies zero ops. Genuinely non-default values (a real `indent: 2`, an + * `align: "left"`, a real `link.href`, a real highlight color) are content and + * stay in the key, so real edits still diff and land. */ -const DEFAULT_KEY_ATTRS: ReadonlyArray = [ - ['indent', 0], -]; +let memoSchema: Schema | null = null; +let memoSchemaTried = false; +function getMergeSchema(): Schema | null { + if (!memoSchemaTried) { + memoSchemaTried = true; + try { + memoSchema = getSchema(tiptapExtensions as any); + } catch { + // Defensive: if the schema can't be built (e.g. a degenerate extension + // set in a unit test that stubs `tiptapExtensions`), fall back to dropping + // only null/undefined attrs. The real server always builds it fine. + memoSchema = null; + } + } + return memoSchema; +} + +/** True if `value` is the schema default for `attrName` of `attrSpecs`, or is + * null/undefined (which a git round-trip drops). Such attributes are excluded + * from the comparison key. `attrSpecs` is a ProseMirror node/mark spec attr map + * (`{ [name]: { default } }`); a missing map (unknown node/mark) only drops + * null/undefined. (A non-null value matching an attr declared without a default + * cannot occur — `spec.default === value` is then `undefined === value`, false.) */ +function isDefaultAttr( + attrSpecs: Record | undefined | null, + attrName: string, + value: unknown, +): boolean { + if (value === null || value === undefined) return true; + const spec = attrSpecs?.[attrName]; + return !!spec && spec.default === value; +} + +/** + * Normalize one XmlText delta op's mark attributes: drop every mark-attr whose + * value equals the mark's schema default (or is null/undefined), so the link + * mark's materialized `internal: false`/`target: "_blank"` and a highlight's + * `colorName: null` no longer diverge from a git round-trip that carries neither. + * The text (op.insert) and genuinely-set mark attrs (a real `href`, a real + * highlight color) are preserved verbatim. `attributes` maps markName -> mark + * attrs object (or `true`/boolean for attr-less marks); each is handled safely. + */ +function normalizeDelta(delta: any[]): any[] { + const schema = getMergeSchema(); + return delta.map((op) => { + if (!op || op.attributes == null || typeof op.attributes !== 'object') { + return op; + } + const marks: Record = {}; + for (const markName of Object.keys(op.attributes).sort()) { + const markVal = op.attributes[markName]; + if (markVal === null || markVal === undefined) continue; + if (typeof markVal !== 'object') { + // attr-less mark stored as a primitive (e.g. `true`) — keep as-is. + marks[markName] = markVal; + continue; + } + const markSpec = schema?.marks[markName]?.spec.attrs as + | Record + | undefined; + const cleaned: Record = {}; + for (const ak of Object.keys(markVal as object).sort()) { + const av = (markVal as Record)[ak]; + if (isDefaultAttr(markSpec, ak, av)) continue; + cleaned[ak] = av; + } + marks[markName] = cleaned; + } + return { ...op, attributes: marks }; + }); +} /** * Canonical, comparable serialization of a Yjs XML node (structure + text + * marks + attributes), with attribute keys sorted so equal blocks always produce * an identical string regardless of attribute insertion order. The volatile - * block `id` (see `VOLATILE_KEY_ATTRS`) and editor-materialized schema defaults - * (see `DEFAULT_KEY_ATTRS`) are excluded at every level so a block compares equal - * by CONTENT across the git round-trip (which carries neither) — keeping the + * block `id` (see `VOLATILE_KEY_ATTRS`) and every schema-default attribute (see + * `getMergeSchema`) are excluded at every level — on element attributes AND on + * the mark attributes inside each XmlText delta — so a block compares equal by + * CONTENT across the git round-trip (which materializes neither), keeping the * merge anchor-able and idempotent. */ export function serializeXmlNode(node: unknown): unknown { if (node instanceof Y.XmlText) { - return { t: node.toDelta() }; + return { t: normalizeDelta(node.toDelta()) }; } if (node instanceof Y.XmlElement) { const attrs = node.getAttributes() as Record; + const attrSpecs = getMergeSchema()?.nodes[node.nodeName]?.spec.attrs as + | Record + | undefined; const sorted: Record = {}; for (const k of Object.keys(attrs).sort()) { if (VOLATILE_KEY_ATTRS.has(k)) continue; - if (DEFAULT_KEY_ATTRS.some(([dk, dv]) => dk === k && attrs[k] === dv)) { - continue; - } + if (isDefaultAttr(attrSpecs, k, attrs[k])) continue; sorted[k] = attrs[k]; } return { @@ -153,10 +224,7 @@ export function cloneXmlNode(node: XmlNode): Y.XmlElement | Y.XmlText { return new Y.XmlElement('paragraph'); } -type Op = - | { op: 'keep' } - | { op: 'del' } - | { op: 'ins'; bi: number }; +type Op = { op: 'keep' } | { op: 'del' } | { op: 'ins'; bi: number }; /** * LCS-based edit script turning sequence `a` (live block keys) into `b` (incoming