@llui/lexical-loro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/dist/agent-write.d.ts +123 -0
  4. package/dist/agent-write.d.ts.map +1 -0
  5. package/dist/agent-write.js +499 -0
  6. package/dist/agent-write.js.map +1 -0
  7. package/dist/binding.d.ts +122 -0
  8. package/dist/binding.d.ts.map +1 -0
  9. package/dist/binding.js +114 -0
  10. package/dist/binding.js.map +1 -0
  11. package/dist/index.d.ts +69 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +69 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/mapping.d.ts +115 -0
  16. package/dist/mapping.d.ts.map +1 -0
  17. package/dist/mapping.js +181 -0
  18. package/dist/mapping.js.map +1 -0
  19. package/dist/order.d.ts +124 -0
  20. package/dist/order.d.ts.map +1 -0
  21. package/dist/order.js +187 -0
  22. package/dist/order.js.map +1 -0
  23. package/dist/schema.d.ts +343 -0
  24. package/dist/schema.d.ts.map +1 -0
  25. package/dist/schema.js +363 -0
  26. package/dist/schema.js.map +1 -0
  27. package/dist/seed.d.ts +72 -0
  28. package/dist/seed.d.ts.map +1 -0
  29. package/dist/seed.js +72 -0
  30. package/dist/seed.js.map +1 -0
  31. package/dist/text.d.ts +167 -0
  32. package/dist/text.d.ts.map +1 -0
  33. package/dist/text.js +289 -0
  34. package/dist/text.js.map +1 -0
  35. package/dist/to-lexical.d.ts +119 -0
  36. package/dist/to-lexical.d.ts.map +1 -0
  37. package/dist/to-lexical.js +636 -0
  38. package/dist/to-lexical.js.map +1 -0
  39. package/dist/to-loro.d.ts +154 -0
  40. package/dist/to-loro.d.ts.map +1 -0
  41. package/dist/to-loro.js +718 -0
  42. package/dist/to-loro.js.map +1 -0
  43. package/dist/undo.d.ts +94 -0
  44. package/dist/undo.d.ts.map +1 -0
  45. package/dist/undo.js +200 -0
  46. package/dist/undo.js.map +1 -0
  47. package/package.json +64 -0
  48. package/src/agent-write.ts +613 -0
  49. package/src/binding.ts +185 -0
  50. package/src/index.ts +176 -0
  51. package/src/mapping.ts +206 -0
  52. package/src/order.ts +205 -0
  53. package/src/schema.ts +509 -0
  54. package/src/seed.ts +112 -0
  55. package/src/text.ts +357 -0
  56. package/src/to-lexical.ts +792 -0
  57. package/src/to-loro.ts +914 -0
  58. package/src/undo.ts +269 -0
package/src/binding.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `loroCollab(config)` — the binding that drives both directions.
3
+ *
4
+ * It composes `to-loro.ts` (Lexical → Loro), `to-lexical.ts` (Loro → Lexical)
5
+ * and `seed.ts` (boot) into one `register(editor)` step you hand to
6
+ * `lexicalForeign({ history: false, seedMode: 'deferred', register })`. The
7
+ * returned handle satisfies `@llui/markdown-editor`'s `CollabBinding`
8
+ * structurally, so that package needs no Loro dependency of its own.
9
+ *
10
+ * ── Transport ──────────────────────────────────────────────────────────────
11
+ *
12
+ * There is none, deliberately. `LoroDoc` already exposes the whole wire surface
13
+ * (`subscribeLocalUpdates` / `import` / `export`), so a transport is a dozen
14
+ * lines the consumer writes against their own websocket, WebRTC channel or
15
+ * `loro-websocket` provider — and baking one in would make this package own a
16
+ * connection lifecycle it has no way to test honestly. Pass the same `LoroDoc`
17
+ * to your provider and to `loroCollab`.
18
+ *
19
+ * ── The three echo layers, and where each one lives ────────────────────────
20
+ *
21
+ * All three are required; any one missing produces an infinite loop or, worse,
22
+ * a silent data-loss bug:
23
+ *
24
+ * a. Loro → us. A LOCAL Loro batch is our own outbound write completing its
25
+ * commit. `applyLoroToLexical` drops it (`to-lexical.ts`). Without this the
26
+ * binding re-enters `editor.update` from inside a Lexical update listener.
27
+ * b. us → Lexical → us. Our inbound writeback is tagged `COLLABORATION_TAG`,
28
+ * which `syncLexicalToLoro` skips (`to-loro.ts`). Without this every remote
29
+ * keystroke is echoed straight back to its sender.
30
+ * c. THE SEAM. This binding NEVER emits `PROGRAMMATIC_TAG`.
31
+ * `packages/lexical/src/foreign.ts` treats that tag as "the host pushed new
32
+ * content — cancel pending outbound work and rebase", so a remote writeback
33
+ * carrying it would cancel the local user's in-flight debounced `onChange`
34
+ * and the host's persistence would go dark whenever a peer types. There is
35
+ * no code here to enforce this; there is simply no line that emits it, and
36
+ * `test/to-lexical.test.ts` pins that.
37
+ *
38
+ * ── Undo ───────────────────────────────────────────────────────────────────
39
+ *
40
+ * This binding OWNS undo, via `externalUndo` (`undo.ts`, a Loro `UndoManager`
41
+ * scoped to this peer). Hosts must therefore turn their built-in
42
+ * `@lexical/history` stack off — `lexicalForeign` does that for you the moment
43
+ * `externalUndo` is present, so the two can never both be live.
44
+ *
45
+ * Loro's manager is operation-based and PeerID-scoped: undo reverts exactly the
46
+ * local user's own last change and never a peer's, which snapshot-based history
47
+ * cannot do at any tag setting. See `undo.ts` for why, and for the echo seam the
48
+ * undo batches travel through.
49
+ */
50
+
51
+ import type { LexicalEditor } from 'lexical'
52
+ import { LoroDoc } from 'loro-crdt'
53
+
54
+ import { AGENT_WRITE_ORIGIN } from './agent-write.js'
55
+ import { ContainerNodeMap } from './mapping.js'
56
+ import { bootstrapDocument, type BootstrapOutcome } from './seed.js'
57
+ import { initDoc, type ElementContainer } from './schema.js'
58
+ import { LORO_TEXT_FORMATS } from './text.js'
59
+ import { applyLoroToLexical } from './to-lexical.js'
60
+ import { OUTBOUND_ORIGIN, syncLexicalToLoro } from './to-loro.js'
61
+ import { registerLoroUndo, UNDO_ORIGINS, type LoroUndoOptions } from './undo.js'
62
+
63
+ export interface LoroCollabConfig {
64
+ /** The shared document. Created if omitted — pass your provider's doc. */
65
+ readonly doc?: LoroDoc
66
+ /**
67
+ * Whether THIS peer may seed an empty shared document. Default `true`.
68
+ * Set `false` on peers that join rather than create, and on any peer whose
69
+ * transport has not completed its first sync — an unsynced document looks
70
+ * empty, and seeding one races the content about to arrive.
71
+ */
72
+ readonly shouldBootstrap?: boolean
73
+ /**
74
+ * Fill an empty shared document with this peer's default content. Runs once,
75
+ * inside a Lexical update. `@llui/markdown-editor` supplies this as
76
+ * `CollabHooks.seed`, which converts its `defaultValue` markdown.
77
+ */
78
+ readonly seed?: (editor: LexicalEditor) => void
79
+ /** Commit origin stamped on this binding's writes. Defaults to `'lexical-loro'`. */
80
+ readonly origin?: string
81
+ /** Called after boot with what happened — seeded, adopted, or still waiting. */
82
+ readonly onBootstrap?: (outcome: BootstrapOutcome) => void
83
+ /**
84
+ * Tuning for the peer-scoped undo manager installed by
85
+ * {@link LoroCollab.externalUndo} — merge window, stack depth, excluded
86
+ * origins. Defaults are in `undo.ts`; the `doc` is supplied by the binding.
87
+ */
88
+ readonly undo?: Omit<LoroUndoOptions, 'doc'>
89
+ }
90
+
91
+ /** Live handle returned by {@link loroCollab}. */
92
+ export interface LoroCollab {
93
+ /**
94
+ * Wire the binding onto an editor; pass as `lexicalForeign({ register })`.
95
+ * Returns a disposer that unsubscribes both directions.
96
+ *
97
+ * Satisfies `@llui/markdown-editor`'s `CollabBinding` structurally.
98
+ */
99
+ register: (editor: LexicalEditor) => () => void
100
+ /** The shared document. Hand this to your transport. */
101
+ readonly doc: LoroDoc
102
+ /** The root element container mirroring Lexical's `RootNode`. */
103
+ readonly root: ElementContainer
104
+ /** The ContainerID ↔ NodeKey registry. Exposed for tests and diagnostics. */
105
+ readonly mapping: ContainerNodeMap
106
+ /**
107
+ * Install this binding's CRDT-aware undo/redo on the editor; pass as
108
+ * `lexicalForeign({ externalUndo })`. Returns a disposer.
109
+ *
110
+ * Undo is LOCAL-ONLY: it reverts this peer's own commits and leaves every
111
+ * other peer's concurrent edits standing (`undo.ts`). Registering it forces
112
+ * `lexicalForeign`'s built-in `@lexical/history` stack off, which is the point
113
+ * — a snapshot-based local stack would rewind remote work for everyone.
114
+ *
115
+ * Registration is SEPARATE from {@link LoroCollab.register} so a host that
116
+ * genuinely wants its own undo owner can decline it. Do not register it twice.
117
+ */
118
+ readonly externalUndo: (editor: LexicalEditor) => () => void
119
+ /** Re-run the boot decision — call after your transport's first sync. */
120
+ bootstrap: (editor: LexicalEditor) => BootstrapOutcome
121
+ }
122
+
123
+ /**
124
+ * Build a collaborative-editing binding over a Loro document.
125
+ *
126
+ * The document is configured for this package's schema (`initDoc`) immediately,
127
+ * not at `register` time, so a transport may be attached to `collab.doc` before
128
+ * any editor exists.
129
+ */
130
+ export function loroCollab(config: LoroCollabConfig = {}): LoroCollab {
131
+ const doc = config.doc ?? new LoroDoc()
132
+ // Must run on EVERY peer: `configTextStyle` is local configuration, not
133
+ // replicated state, and a peer that skips it resolves marks under different
134
+ // expand rules and diverges. Idempotent, so a shared doc may be passed to
135
+ // more than one binding.
136
+ const root = initDoc(doc, LORO_TEXT_FORMATS)
137
+ const mapping = new ContainerNodeMap()
138
+ const origin = config.origin ?? OUTBOUND_ORIGIN
139
+
140
+ const bootstrap = (editor: LexicalEditor): BootstrapOutcome => {
141
+ const outcome = bootstrapDocument({
142
+ doc,
143
+ root,
144
+ mapping,
145
+ editor,
146
+ seed: config.seed,
147
+ shouldBootstrap: config.shouldBootstrap,
148
+ })
149
+ config.onBootstrap?.(outcome)
150
+ return outcome
151
+ }
152
+
153
+ const register = (editor: LexicalEditor): (() => void) => {
154
+ const outboundTarget = { doc, root, mapping, origin }
155
+ // `localOrigins` are the local batches the inbound path must still APPLY
156
+ // rather than drop as an echo (see `to-lexical.ts`). Both are passed
157
+ // unconditionally: only a Loro `UndoManager` produces `UNDO_ORIGINS` and only
158
+ // `reconcileTargetIntoLoro` produces `AGENT_WRITE_ORIGIN`, so listing them is
159
+ // inert until one occurs — and the inbound path stays independent of whether
160
+ // `externalUndo` was registered or an agent write ever happens.
161
+ const localOrigins = [...UNDO_ORIGINS, AGENT_WRITE_ORIGIN]
162
+ const inboundTarget = { doc, root, mapping, editor, localOrigins }
163
+
164
+ // Inbound BEFORE outbound: the subscription must be live before the
165
+ // bootstrap writes anything, or a document arriving mid-boot is missed.
166
+ const unsubscribe = doc.subscribe((batch) => {
167
+ applyLoroToLexical(inboundTarget, batch)
168
+ })
169
+ const unregister = editor.registerUpdateListener((payload) => {
170
+ syncLexicalToLoro(outboundTarget, payload)
171
+ })
172
+
173
+ bootstrap(editor)
174
+
175
+ return () => {
176
+ unregister()
177
+ unsubscribe()
178
+ }
179
+ }
180
+
181
+ const externalUndo = (editor: LexicalEditor): (() => void) =>
182
+ registerLoroUndo({ doc, ...config.undo }, editor)
183
+
184
+ return { register, doc, root, mapping, bootstrap, externalUndo }
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `@llui/lexical-loro` — Loro CRDT binding for the LLui ↔ Lexical editor.
3
+ *
4
+ * Start at {@link loroCollab}: it composes everything below into a
5
+ * `register(editor)` plus an `externalUndo(editor)` you hand to
6
+ * `lexicalForeign({ seedMode: 'deferred', register, externalUndo })`, and it
7
+ * satisfies `@llui/markdown-editor`'s `CollabBinding` structurally.
8
+ *
9
+ * - `schema.ts` — the Loro container shape mirroring a Lexical EditorState
10
+ * - `order.ts` — fractional indexing: sibling order as a sortable property
11
+ * - `mapping.ts` — the ContainerID ↔ NodeKey registry (the core invariant)
12
+ * - `text.ts` — format-bitmask ↔ named-mark conversion, the run diff, and
13
+ * the cursor-biased text diff
14
+ * - `to-loro.ts` — Lexical → Loro: the update-listener-driven mirror
15
+ * - `to-lexical.ts` — Loro → Lexical: the persistent, in-place mirror
16
+ * - `seed.ts` — boot: seed an empty shared document, or adopt a populated one
17
+ * - `undo.ts` — peer-scoped, CRDT-aware undo/redo over Loro's `UndoManager`
18
+ * - `binding.ts` — `loroCollab`: both directions plus the echo guards
19
+ *
20
+ * ── Scope ──────────────────────────────────────────────────────────────────
21
+ *
22
+ * DOCUMENT SYNC plus LOCAL-ONLY UNDO. Undo is owned by this binding
23
+ * (`LoroCollab.externalUndo`), so a host MUST disable `@lexical/history` —
24
+ * `lexicalForeign` does that automatically once `externalUndo` is passed. Still
25
+ * out of scope: presence and remote cursors (additive, over Loro's
26
+ * `EphemeralStore`), and text-node `style` / `mode` / `detail` — the run model
27
+ * is `{text, format}`.
28
+ *
29
+ * ── Echo suppression ───────────────────────────────────────────────────────
30
+ *
31
+ * Three independent layers are ALL required; see `binding.ts` for where each
32
+ * one lives and what breaks without it. The one with no code to enforce it:
33
+ * this binding NEVER emits `PROGRAMMATIC_TAG`, because
34
+ * `packages/lexical/src/foreign.ts` reads that tag as "the host pushed content —
35
+ * cancel pending outbound work", which would make the host's persistence go dark
36
+ * whenever a peer types.
37
+ *
38
+ * `test/expand-semantics.test.ts` (51 tests) is the specification for the
39
+ * text-format half; read it before touching `text.ts`. `test/network.ts` is the
40
+ * multi-peer convergence harness, driven by `test/convergence.test.ts`.
41
+ */
42
+
43
+ export {
44
+ ROOT_CONTAINER,
45
+ KEY_TYPE,
46
+ KEY_PROPS,
47
+ KEY_CHILDREN,
48
+ KEY_UUID,
49
+ KEY_POS,
50
+ KEY_KIND,
51
+ KEY_TEXT,
52
+ DECORATOR_TYPE,
53
+ KEY_BRIDGE_TYPE,
54
+ KEY_DATA,
55
+ ROOT_TYPE,
56
+ TEXT_MARK_EXPAND,
57
+ initDoc,
58
+ newUuid,
59
+ createElementChild,
60
+ createTextChild,
61
+ setChildPosition,
62
+ deleteChild,
63
+ orderedChildren,
64
+ childCount,
65
+ elementType,
66
+ elementProps,
67
+ elementChildren,
68
+ isElementContainer,
69
+ isTextContainer,
70
+ isDecoratorElement,
71
+ containerId,
72
+ containerIsLive,
73
+ type PropValue,
74
+ type PropsContainer,
75
+ type ElementContainer,
76
+ type ElementShape,
77
+ type ChildrenContainer,
78
+ type ChildContainer,
79
+ type ChildCarrier,
80
+ type CarrierShape,
81
+ type TextCarrier,
82
+ type TextCarrierShape,
83
+ type ChildEntry,
84
+ type ChildKind,
85
+ type ExpandType,
86
+ } from './schema.js'
87
+
88
+ export { DIGITS, between, allocate, allocateAt, jitterFor, comparePositions } from './order.js'
89
+
90
+ export { ContainerNodeMap, type MappingEntry, type LivenessProbe } from './mapping.js'
91
+
92
+ export {
93
+ LORO_TEXT_FORMATS,
94
+ FORMAT_BITS,
95
+ KNOWN_FORMAT_MASK,
96
+ formatBit,
97
+ formatsFromBitmask,
98
+ bitmaskFromFormats,
99
+ bitmaskFromAttributes,
100
+ runsText,
101
+ normalizeRuns,
102
+ runsFromDelta,
103
+ runsFromText,
104
+ diffRunFormats,
105
+ applyMarkOps,
106
+ diffTextWithCursor,
107
+ diffText,
108
+ applyTextDiff,
109
+ type LoroTextFormat,
110
+ type TextRun,
111
+ type TextDeltaItem,
112
+ type MarkOp,
113
+ type TextDiff,
114
+ } from './text.js'
115
+
116
+ export {
117
+ OUTBOUND_ORIGIN,
118
+ OUTBOUND_SKIP_TAGS,
119
+ syncLexicalToLoro,
120
+ seedLoroFromLexical,
121
+ longestIncreasingSubsequence,
122
+ type OutboundTarget,
123
+ type OutboundUpdate,
124
+ } from './to-loro.js'
125
+
126
+ export {
127
+ INBOUND_TAGS,
128
+ applyLoroToLexical,
129
+ adoptLoroDocument,
130
+ type InboundTarget,
131
+ } from './to-lexical.js'
132
+
133
+ export {
134
+ bootstrapDocument,
135
+ isSharedDocumentEmpty,
136
+ type BootstrapOutcome,
137
+ type BootstrapTarget,
138
+ } from './seed.js'
139
+
140
+ export {
141
+ LORO_UNDO_ORIGIN,
142
+ UNDO_ORIGINS,
143
+ DEFAULT_MERGE_INTERVAL,
144
+ DEFAULT_MAX_UNDO_STEPS,
145
+ registerLoroUndo,
146
+ type LoroUndoOptions,
147
+ } from './undo.js'
148
+
149
+ export { loroCollab, type LoroCollab, type LoroCollabConfig } from './binding.js'
150
+
151
+ /**
152
+ * Agent-write: apply an LLM's full-document markdown REWRITE to the shared
153
+ * document by CONTENT, so unchanged blocks keep their `ContainerID`s (hence their
154
+ * `NodeKey`s and mounted decorator sub-apps, and any concurrent peer edit into
155
+ * them) instead of being recreated wholesale.
156
+ *
157
+ * `reconcileTargetIntoLoro` is a SIBLING to `syncLexicalToLoro`: it writes Loro
158
+ * directly under {@link AGENT_WRITE_ORIGIN} and does NOT consult the
159
+ * `ContainerNodeMap` (content matching is the point; the mapping self-heals on
160
+ * the inbound bounce into any live editor). The caller owns the markdown → target
161
+ * parse with its own `@lexical/markdown` transformer set — this package never
162
+ * depends on `@lexical/markdown` — and projects the parsed editor state with
163
+ * {@link targetFromEditorState} / {@link projectTarget}.
164
+ *
165
+ * Guarantees and the documented DUPLICATE-BLOCK caveat live on the functions and
166
+ * in the README.
167
+ */
168
+ export {
169
+ AGENT_WRITE_ORIGIN,
170
+ reconcileTargetIntoLoro,
171
+ projectTarget,
172
+ targetFromEditorState,
173
+ type TargetElement,
174
+ type TargetText,
175
+ type TargetChild,
176
+ } from './agent-write.js'
package/src/mapping.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The ContainerID ↔ NodeKey registry — the core invariant of this package.
3
+ *
4
+ * ── Why it exists ──────────────────────────────────────────────────────────
5
+ *
6
+ * Lexical's `NodeKey` is a bare per-session counter
7
+ * (`LexicalUtils.ts`: `return '' + keyCounter++`). It is meaningless to a peer
8
+ * and unstable across reloads. Loro's `ContainerID` is the stable, replicated
9
+ * address. Every sync operation is therefore a translation between the two, and
10
+ * this registry is the only place that translation is allowed to happen.
11
+ *
12
+ * There is also NO stable node-object identity to key off: Lexical CLONES a node
13
+ * on every write, so `node === node` fails across an update. The NodeKey string
14
+ * survives cloning; the object does not. Hence a key↔id map, never a WeakMap.
15
+ *
16
+ * ── THE INVARIANT ──────────────────────────────────────────────────────────
17
+ *
18
+ * The two internal maps are always exact inverses of each other: a BIJECTION.
19
+ *
20
+ * byContainer.get(id) === key ⟺ byNode.get(key) === id
21
+ *
22
+ * No ContainerID ever addresses two NodeKeys, and no NodeKey ever addresses two
23
+ * ContainerIDs. `link()` enforces this by evicting BOTH stale directions before
24
+ * writing, so a re-link can never leave a half-entry behind. A violated
25
+ * bijection is the failure mode that silently corrupts a document: a stale
26
+ * reverse entry makes an inbound remote edit land on the wrong Lexical node.
27
+ *
28
+ * ── When it must be updated ────────────────────────────────────────────────
29
+ *
30
+ * - local create/delete — the user edited; mirror the change outbound
31
+ * - remote create/delete — an inbound event created/removed a container
32
+ * - TEXT NORMALIZATION — Lexical silently merges/splits adjacent TextNodes
33
+ * and reports it via `normalizedNodes`. This is the easiest one to forget and
34
+ * the one that corrupts the document: the surviving node keeps ONE key and the
35
+ * others are discarded, so their entries must be re-linked or dropped. This is
36
+ * also why inbound application runs `discrete: true` — without a synchronous
37
+ * flush the normalization happens after we have already read back keys, and
38
+ * the mapping drifts.
39
+ *
40
+ * Entries are never implicitly expired. Use `sweep()` after a structural change
41
+ * to drop entries whose container or node no longer exists.
42
+ */
43
+
44
+ import type { ContainerID } from 'loro-crdt'
45
+ import type { NodeKey } from 'lexical'
46
+
47
+ /** One direction of a mapping entry, as reported by iteration and sweeps. */
48
+ export interface MappingEntry {
49
+ readonly id: ContainerID
50
+ readonly key: NodeKey
51
+ }
52
+
53
+ /** Liveness probes used by {@link ContainerNodeMap.sweep}. */
54
+ export interface LivenessProbe {
55
+ /** True when the container still exists in the Loro document. */
56
+ readonly hasContainer: (id: ContainerID) => boolean
57
+ /** True when the node still exists in the Lexical editor state. */
58
+ readonly hasNode: (key: NodeKey) => boolean
59
+ }
60
+
61
+ /**
62
+ * A bijective, explicitly-invalidated registry mapping Loro `ContainerID`s to
63
+ * Lexical `NodeKey`s. See the file header for the invariant it maintains.
64
+ */
65
+ export class ContainerNodeMap {
66
+ readonly #byContainer = new Map<ContainerID, NodeKey>()
67
+ readonly #byNode = new Map<NodeKey, ContainerID>()
68
+
69
+ /** Number of live entries. Both directions always agree on this. */
70
+ get size(): number {
71
+ return this.#byContainer.size
72
+ }
73
+
74
+ /**
75
+ * Link a container to a node, replacing any previous link on EITHER side.
76
+ *
77
+ * Evicting both directions first is what preserves the bijection: linking
78
+ * `id → k2` when `id → k1` existed must also drop `k1 → id`, or `k1` keeps
79
+ * resolving to a container it no longer owns.
80
+ */
81
+ link(id: ContainerID, key: NodeKey): void {
82
+ const previousKey = this.#byContainer.get(id)
83
+ if (previousKey !== undefined && previousKey !== key) this.#byNode.delete(previousKey)
84
+ const previousId = this.#byNode.get(key)
85
+ if (previousId !== undefined && previousId !== id) this.#byContainer.delete(previousId)
86
+ this.#byContainer.set(id, key)
87
+ this.#byNode.set(key, id)
88
+ }
89
+
90
+ /** The NodeKey addressed by a container, or `undefined` if unmapped. */
91
+ nodeKey(id: ContainerID): NodeKey | undefined {
92
+ return this.#byContainer.get(id)
93
+ }
94
+
95
+ /** The ContainerID addressing a node, or `undefined` if unmapped. */
96
+ containerId(key: NodeKey): ContainerID | undefined {
97
+ return this.#byNode.get(key)
98
+ }
99
+
100
+ /**
101
+ * The NodeKey addressed by a container. Throws when unmapped.
102
+ *
103
+ * Prefer this on paths where a missing entry means the registry is already
104
+ * corrupt — failing loudly beats writing to the wrong node.
105
+ */
106
+ expectNodeKey(id: ContainerID): NodeKey {
107
+ const key = this.#byContainer.get(id)
108
+ if (key === undefined) throw new Error(`lexical-loro: no NodeKey mapped for container ${id}`)
109
+ return key
110
+ }
111
+
112
+ /** The ContainerID addressing a node. Throws when unmapped. */
113
+ expectContainerId(key: NodeKey): ContainerID {
114
+ const id = this.#byNode.get(key)
115
+ if (id === undefined) throw new Error(`lexical-loro: no ContainerID mapped for node ${key}`)
116
+ return id
117
+ }
118
+
119
+ hasContainer(id: ContainerID): boolean {
120
+ return this.#byContainer.has(id)
121
+ }
122
+
123
+ hasNode(key: NodeKey): boolean {
124
+ return this.#byNode.has(key)
125
+ }
126
+
127
+ /**
128
+ * Move a container's link to a different NodeKey, keeping the bijection.
129
+ *
130
+ * This is the TEXT-NORMALIZATION path: Lexical merged our run's node into a
131
+ * sibling, so the container must now address the survivor. Returns false when
132
+ * the container was not mapped (nothing to move).
133
+ */
134
+ rekey(id: ContainerID, key: NodeKey): boolean {
135
+ if (!this.#byContainer.has(id)) return false
136
+ this.link(id, key)
137
+ return true
138
+ }
139
+
140
+ /** Drop the entry for a container. Returns whether one existed. */
141
+ unlinkContainer(id: ContainerID): boolean {
142
+ const key = this.#byContainer.get(id)
143
+ if (key === undefined) return false
144
+ this.#byContainer.delete(id)
145
+ this.#byNode.delete(key)
146
+ return true
147
+ }
148
+
149
+ /** Drop the entry for a node. Returns whether one existed. */
150
+ unlinkNode(key: NodeKey): boolean {
151
+ const id = this.#byNode.get(key)
152
+ if (id === undefined) return false
153
+ this.#byNode.delete(key)
154
+ this.#byContainer.delete(id)
155
+ return true
156
+ }
157
+
158
+ /** Every live entry, in insertion order. */
159
+ entries(): MappingEntry[] {
160
+ return [...this.#byContainer].map(([id, key]) => ({ id, key }))
161
+ }
162
+
163
+ /**
164
+ * Drop every entry whose container or node no longer exists, and return the
165
+ * entries removed.
166
+ *
167
+ * Deleting a subtree removes many containers at once; rather than making every
168
+ * caller walk the removed subtree, sweep after the structural change. An entry
169
+ * is stale if EITHER side is gone — a half-live entry is exactly the corrupt
170
+ * state the invariant forbids.
171
+ */
172
+ sweep(probe: LivenessProbe): MappingEntry[] {
173
+ const removed: MappingEntry[] = []
174
+ for (const [id, key] of this.#byContainer) {
175
+ if (probe.hasContainer(id) && probe.hasNode(key)) continue
176
+ removed.push({ id, key })
177
+ }
178
+ for (const { id } of removed) this.unlinkContainer(id)
179
+ return removed
180
+ }
181
+
182
+ /** Drop every entry. Used when the document is re-seeded from scratch. */
183
+ clear(): void {
184
+ this.#byContainer.clear()
185
+ this.#byNode.clear()
186
+ }
187
+
188
+ /**
189
+ * Assert the bijection holds. A debugging/test aid: production paths maintain
190
+ * it structurally, so a failure here means a bug in this class, not a caller.
191
+ *
192
+ * @internal
193
+ */
194
+ assertBijective(): void {
195
+ if (this.#byContainer.size !== this.#byNode.size) {
196
+ throw new Error(
197
+ `lexical-loro: mapping is not bijective — ${this.#byContainer.size} containers vs ${this.#byNode.size} nodes`,
198
+ )
199
+ }
200
+ for (const [id, key] of this.#byContainer) {
201
+ if (this.#byNode.get(key) !== id) {
202
+ throw new Error(`lexical-loro: mapping is not bijective at ${id} ↔ ${key}`)
203
+ }
204
+ }
205
+ }
206
+ }