@kyneta/yjs-schema 1.0.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.
@@ -0,0 +1,252 @@
1
+ // substrate — YjsSubstrate implementation.
2
+ //
3
+ // Implements Substrate<YjsVersion> with:
4
+ // - Imperative local writes (prepare accumulates, onFlush applies in transact)
5
+ // - Persistent observeDeep event bridge for external changes
6
+ // - Single re-entrancy guard + transaction.origin check
7
+ //
8
+ // The event bridge contract: wrapping a Y.Doc in a kyneta substrate
9
+ // means subscribing to the kyneta doc observes ALL mutations to the
10
+ // underlying Y.Doc, regardless of source (local kyneta writes,
11
+ // importDelta, external Y.applyUpdate, external raw Yjs API mutations).
12
+
13
+ import type {
14
+ ChangeBase,
15
+ Path,
16
+ Schema as SchemaNode,
17
+ StoreReader,
18
+ Substrate,
19
+ SubstrateFactory,
20
+ SubstratePayload,
21
+ WritableContext,
22
+ } from "@kyneta/schema"
23
+ import { buildWritableContext, executeBatch } from "@kyneta/schema"
24
+ import * as Y from "yjs"
25
+ import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
26
+ import { ensureContainers } from "./populate.js"
27
+ import { yjsStoreReader } from "./store-reader.js"
28
+ import { YjsVersion } from "./version.js"
29
+ import { registerYjsSubstrate } from "./yjs-escape.js"
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Origin tag — used to suppress echo from our own transactions
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const KYNETA_ORIGIN = "kyneta-prepare"
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // createYjsSubstrate — wrap a user-provided Y.Doc
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Creates a `Substrate<YjsVersion>` wrapping a user-provided Y.Doc.
43
+ *
44
+ * This is the "bring your own doc" entry point. The user creates and
45
+ * manages the Y.Doc (possibly via a Yjs provider); this function wraps
46
+ * it with a schema-aware overlay providing typed reads, writes,
47
+ * versioning, and export/import through the standard Substrate interface.
48
+ *
49
+ * **Event bridge contract:** A persistent `observeDeep` handler is
50
+ * registered on the root Y.Map at construction time. All non-kyneta
51
+ * mutations to the Y.Doc (imports, external local writes) are bridged
52
+ * to the kyneta changefeed. Subscribing to the kyneta doc observes all
53
+ * mutations regardless of source.
54
+ *
55
+ * @param doc - The Y.Doc to wrap. The substrate does NOT own the doc;
56
+ * the caller is responsible for its lifecycle.
57
+ * @param schema - The root schema for the document.
58
+ */
59
+ export function createYjsSubstrate(
60
+ doc: Y.Doc,
61
+ schema: SchemaNode,
62
+ ): Substrate<YjsVersion> {
63
+ // --- Closure-scoped state ---
64
+
65
+ // Accumulated changes from prepare(), drained by onFlush().
66
+ const pendingChanges: Array<{ path: Path; change: ChangeBase }> = []
67
+
68
+ // Re-entrancy guard: set true around our doc.transact() in onFlush
69
+ // AND around executeBatch in the event bridge. When true, prepare()
70
+ // skips Yjs-side work (changes are already applied by Yjs or about
71
+ // to be), and onFlush() skips transact/commit.
72
+ let inOurTransaction = false
73
+
74
+ // Stashed origin from importDelta for the event bridge to pick up.
75
+ let pendingImportOrigin: string | undefined
76
+
77
+ // Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
78
+ let cachedCtx: WritableContext | undefined
79
+
80
+ // The root Y.Map — all schema fields are children of this single map.
81
+ const rootMap = doc.getMap("root")
82
+
83
+ // The StoreReader — live view over the Yjs shared type tree.
84
+ const reader: StoreReader = yjsStoreReader(doc, schema)
85
+
86
+ // --- Substrate object ---
87
+
88
+ const substrate: Substrate<YjsVersion> = {
89
+ store: reader,
90
+
91
+ prepare(path: Path, change: ChangeBase): void {
92
+ if (!inOurTransaction) {
93
+ // Local write: accumulate for flush.
94
+ // No Yjs side effects — mutations happen at flush time.
95
+ pendingChanges.push({ path, change })
96
+ }
97
+ // During event handler replay: no-op on Yjs side.
98
+ // wrappedPrepare (changefeed layer) still buffers the op.
99
+ },
100
+
101
+ onFlush(origin?: string): void {
102
+ if (!inOurTransaction && pendingChanges.length > 0) {
103
+ // Local write: apply accumulated changes within a single
104
+ // Yjs transaction tagged with our origin for echo suppression.
105
+ inOurTransaction = true
106
+ try {
107
+ doc.transact(() => {
108
+ for (const { path, change } of pendingChanges) {
109
+ applyChangeToYjs(rootMap, schema, path, change)
110
+ }
111
+ }, KYNETA_ORIGIN)
112
+ pendingChanges.length = 0
113
+ } finally {
114
+ inOurTransaction = false
115
+ }
116
+ }
117
+ // During event handler replay: no-op on Yjs side.
118
+ // wrappedFlush (changefeed layer) still delivers notifications.
119
+ },
120
+
121
+ context(): WritableContext {
122
+ if (!cachedCtx) {
123
+ cachedCtx = buildWritableContext(substrate)
124
+ }
125
+ return cachedCtx
126
+ },
127
+
128
+ version(): YjsVersion {
129
+ return new YjsVersion(Y.encodeStateVector(doc))
130
+ },
131
+
132
+ exportSnapshot(): SubstratePayload {
133
+ return {
134
+ encoding: "binary",
135
+ data: Y.encodeStateAsUpdate(doc),
136
+ }
137
+ },
138
+
139
+ exportSince(since: YjsVersion): SubstratePayload | null {
140
+ try {
141
+ const bytes = Y.encodeStateAsUpdate(doc, since.sv)
142
+ return { encoding: "binary", data: bytes }
143
+ } catch {
144
+ return null
145
+ }
146
+ },
147
+
148
+ importDelta(payload: SubstratePayload, origin?: string): void {
149
+ if (
150
+ payload.encoding !== "binary" ||
151
+ !(payload.data instanceof Uint8Array)
152
+ ) {
153
+ throw new Error(
154
+ "YjsSubstrate.importDelta only supports binary-encoded payloads",
155
+ )
156
+ }
157
+ // Stash origin for the event bridge to pick up
158
+ pendingImportOrigin = origin
159
+ try {
160
+ Y.applyUpdate(doc, payload.data, origin ?? "remote")
161
+ } finally {
162
+ pendingImportOrigin = undefined
163
+ }
164
+ // That's it — the observeDeep handler bridges events to the
165
+ // changefeed via executeBatch.
166
+ },
167
+ }
168
+
169
+ // --- Event bridge (registered once at construction) ---
170
+
171
+ rootMap.observeDeep((events, transaction) => {
172
+ // Ignore our own transactions (changefeed already captured via wrappedPrepare)
173
+ if (transaction.origin === KYNETA_ORIGIN) {
174
+ return
175
+ }
176
+
177
+ // Convert Yjs events → kyneta Ops
178
+ const ops = eventsToOps(events)
179
+ if (ops.length === 0) {
180
+ return
181
+ }
182
+
183
+ // Determine origin: prefer stashed kyneta origin (from importDelta),
184
+ // fall back to the transaction's origin if it's a string.
185
+ const origin =
186
+ pendingImportOrigin ??
187
+ (typeof transaction.origin === "string"
188
+ ? transaction.origin
189
+ : undefined)
190
+
191
+ // Lazily ensure the context is built
192
+ const ctx = substrate.context()
193
+
194
+ // Feed through executeBatch for changefeed delivery.
195
+ // The inOurTransaction guard prevents prepare/onFlush from doing
196
+ // Yjs-side work — the changes are already applied by Yjs.
197
+ inOurTransaction = true
198
+ try {
199
+ executeBatch(ctx, ops, origin)
200
+ } finally {
201
+ inOurTransaction = false
202
+ }
203
+ })
204
+
205
+ // Register for the yjs() escape hatch
206
+ registerYjsSubstrate(substrate, doc)
207
+
208
+ return substrate
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // yjsSubstrateFactory — SubstrateFactory<YjsVersion>
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Factory for constructing Yjs-backed substrates.
217
+ *
218
+ * - `create(schema)` — creates a fresh Y.Doc with empty containers
219
+ * matching the schema structure. No seed data — initial content
220
+ * should be applied via `change()` after construction.
221
+ * - `fromSnapshot(payload, schema)` — creates a Y.Doc from a snapshot
222
+ * payload, returns a substrate.
223
+ * - `parseVersion(serialized)` — deserializes a YjsVersion.
224
+ */
225
+ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
226
+ create(schema: SchemaNode): Substrate<YjsVersion> {
227
+ const doc = new Y.Doc()
228
+ ensureContainers(doc, schema)
229
+ return createYjsSubstrate(doc, schema)
230
+ },
231
+
232
+ fromSnapshot(
233
+ payload: SubstratePayload,
234
+ schema: SchemaNode,
235
+ ): Substrate<YjsVersion> {
236
+ if (
237
+ payload.encoding !== "binary" ||
238
+ !(payload.data instanceof Uint8Array)
239
+ ) {
240
+ throw new Error(
241
+ "YjsSubstrateFactory.fromSnapshot only supports binary-encoded payloads",
242
+ )
243
+ }
244
+ const doc = new Y.Doc()
245
+ Y.applyUpdate(doc, payload.data)
246
+ return createYjsSubstrate(doc, schema)
247
+ },
248
+
249
+ parseVersion(serialized: string): YjsVersion {
250
+ return YjsVersion.parse(serialized)
251
+ },
252
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,107 @@
1
+ // sync — sync primitives for YjsSubstrate-backed documents.
2
+ //
3
+ // These functions provide version tracking, snapshot export, and delta
4
+ // import for documents created via `createYjsDoc` or
5
+ // `createYjsDocFromSnapshot`. They discover the substrate via the
6
+ // module-scoped WeakMap in `create.ts`.
7
+ //
8
+ // Unlike PlainSubstrate's sync (which returns Op[] for deltas),
9
+ // YjsSubstrate's sync uses binary SubstratePayload for both snapshots
10
+ // and deltas — these are Yjs's native state-as-update bytes.
11
+
12
+ import type { SubstratePayload } from "@kyneta/schema"
13
+ import { YjsVersion } from "./version.js"
14
+ import { getSubstrate } from "./create.js"
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // version — current YjsVersion
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Current version as a `YjsVersion` (wrapping a Yjs state vector).
22
+ *
23
+ * Use `.serialize()` to get a text-safe string for embedding in HTML
24
+ * meta tags, URL parameters, etc.
25
+ *
26
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
27
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
28
+ */
29
+ export function version(doc: object): YjsVersion {
30
+ return getSubstrate(doc).version()
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // exportSnapshot — full state for reconstruction
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Export the full substrate snapshot — sufficient for a new peer to
39
+ * reconstruct an equivalent document via `createYjsDocFromSnapshot()`.
40
+ *
41
+ * Returns a binary `SubstratePayload` (Yjs state-as-update bytes).
42
+ *
43
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
44
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
45
+ */
46
+ export function exportSnapshot(doc: object): SubstratePayload {
47
+ return getSubstrate(doc).exportSnapshot()
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // exportSince — delta since a version
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Export a delta payload containing all changes since the given version.
56
+ *
57
+ * Returns a binary `SubstratePayload` (Yjs update bytes), or `null`
58
+ * if the delta cannot be computed.
59
+ *
60
+ * ```ts
61
+ * const v0 = version(docA)
62
+ * change(docA, d => d.title.insert(0, "Hi"))
63
+ * const delta = exportSince(docA, v0)
64
+ * importDelta(docB, delta!)
65
+ * ```
66
+ *
67
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
68
+ * @param since - The version to diff from.
69
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
70
+ */
71
+ export function exportSince(
72
+ doc: object,
73
+ since: YjsVersion,
74
+ ): SubstratePayload | null {
75
+ return getSubstrate(doc).exportSince(since)
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // importDelta — apply a delta from another peer
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Import a delta payload into a live document.
84
+ *
85
+ * The payload must have been produced by `exportSince()` or
86
+ * `exportSnapshot()` on a compatible document.
87
+ *
88
+ * After import, the changefeed fires for all subscribers — the event
89
+ * bridge handles this automatically.
90
+ *
91
+ * ```ts
92
+ * const delta = exportSince(docA, sinceVersion)
93
+ * importDelta(docB, delta!, "sync")
94
+ * ```
95
+ *
96
+ * @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
97
+ * @param payload - The delta or snapshot payload to import.
98
+ * @param origin - Optional provenance tag for the changeset.
99
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
100
+ */
101
+ export function importDelta(
102
+ doc: object,
103
+ payload: SubstratePayload,
104
+ origin?: string,
105
+ ): void {
106
+ getSubstrate(doc).importDelta(payload, origin)
107
+ }
package/src/version.ts ADDED
@@ -0,0 +1,138 @@
1
+ // YjsVersion — Version implementation wrapping Yjs state vectors.
2
+ //
3
+ // Yjs state vectors (`Y.encodeStateVector(doc)`) are the complete peer
4
+ // state used for sync diffing — matching the semantics of kyneta's
5
+ // Version interface.
6
+ //
7
+ // Serialization uses base64-encoded bytes for text-safe embedding in
8
+ // HTML meta tags, script tags, etc.
9
+ //
10
+ // Yjs does not export a state vector comparison function, so we
11
+ // implement standard version-vector partial-order comparison over
12
+ // decoded `Map<number, number>` (clientID → clock) maps ourselves.
13
+
14
+ import type { Version } from "@kyneta/schema"
15
+ import { decodeStateVector } from "yjs"
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Base64 helpers (platform-agnostic, no Node.js Buffer dependency)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function uint8ArrayToBase64(bytes: Uint8Array): string {
22
+ let binary = ""
23
+ for (let i = 0; i < bytes.length; i++) {
24
+ binary += String.fromCharCode(bytes[i]!)
25
+ }
26
+ return btoa(binary)
27
+ }
28
+
29
+ function base64ToUint8Array(base64: string): Uint8Array {
30
+ const binary = atob(base64)
31
+ const bytes = new Uint8Array(binary.length)
32
+ for (let i = 0; i < binary.length; i++) {
33
+ bytes[i] = binary.charCodeAt(i)
34
+ }
35
+ return bytes
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // YjsVersion
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * A Version wrapping a Yjs state vector.
44
+ *
45
+ * State vectors track the complete peer state — which operations from
46
+ * each client have been observed. This is the right abstraction for sync
47
+ * diffing: `exportSince(version)` uses the state vector to compute the
48
+ * minimal update payload via `Y.encodeStateAsUpdate(doc, sv)`.
49
+ *
50
+ * `serialize()` encodes to base64 for text-safe embedding.
51
+ * `compare()` decodes both state vectors and performs standard
52
+ * version-vector partial-order comparison over the client-clock maps.
53
+ */
54
+ export class YjsVersion implements Version {
55
+ readonly sv: Uint8Array
56
+
57
+ constructor(sv: Uint8Array) {
58
+ this.sv = sv
59
+ }
60
+
61
+ /**
62
+ * Serialize the state vector to a base64 string.
63
+ *
64
+ * The encoding is: raw state vector bytes → base64.
65
+ * This is text-safe for embedding in HTML meta tags, URL parameters, etc.
66
+ */
67
+ serialize(): string {
68
+ return uint8ArrayToBase64(this.sv)
69
+ }
70
+
71
+ /**
72
+ * Compare with another version using version-vector partial order.
73
+ *
74
+ * Decodes both state vectors via `Y.decodeStateVector()` to get
75
+ * `Map<number, number>` (clientID → clock), then compares:
76
+ *
77
+ * - Collect the union of all client IDs from both maps.
78
+ * - For each client, compare clocks (missing client = clock 0).
79
+ * - If all clocks in `this` ≤ `other` and at least one strictly less → `"behind"`
80
+ * - If all clocks in `this` ≥ `other` and at least one strictly greater → `"ahead"`
81
+ * - If all clocks equal → `"equal"`
82
+ * - Otherwise → `"concurrent"`
83
+ *
84
+ * Throws if `other` is not a `YjsVersion`.
85
+ */
86
+ compare(other: Version): "behind" | "equal" | "ahead" | "concurrent" {
87
+ if (!(other instanceof YjsVersion)) {
88
+ throw new Error(
89
+ "YjsVersion can only be compared with another YjsVersion",
90
+ )
91
+ }
92
+
93
+ const thisMap = decodeStateVector(this.sv)
94
+ const otherMap = decodeStateVector(other.sv)
95
+
96
+ // Collect the union of all client IDs
97
+ const allClients = new Set<number>()
98
+ for (const id of thisMap.keys()) allClients.add(id)
99
+ for (const id of otherMap.keys()) allClients.add(id)
100
+
101
+ let hasLess = false
102
+ let hasGreater = false
103
+
104
+ for (const clientId of allClients) {
105
+ const thisClock = thisMap.get(clientId) ?? 0
106
+ const otherClock = otherMap.get(clientId) ?? 0
107
+
108
+ if (thisClock < otherClock) {
109
+ hasLess = true
110
+ }
111
+ if (thisClock > otherClock) {
112
+ hasGreater = true
113
+ }
114
+
115
+ // Early exit: if we've seen both less and greater, it's concurrent
116
+ if (hasLess && hasGreater) {
117
+ return "concurrent"
118
+ }
119
+ }
120
+
121
+ if (hasLess && !hasGreater) return "behind"
122
+ if (hasGreater && !hasLess) return "ahead"
123
+ return "equal"
124
+ }
125
+
126
+ /**
127
+ * Parse a serialized YjsVersion string back into a YjsVersion.
128
+ *
129
+ * The inverse of `serialize()`: base64 → `Uint8Array`.
130
+ */
131
+ static parse(serialized: string): YjsVersion {
132
+ if (serialized === "") {
133
+ throw new Error("Invalid YjsVersion value: (empty string)")
134
+ }
135
+ const bytes = base64ToUint8Array(serialized)
136
+ return new YjsVersion(bytes)
137
+ }
138
+ }
@@ -0,0 +1,100 @@
1
+ // yjs-escape — Yjs-specific escape hatch for accessing the Y.Doc
2
+ // backing a ref.
3
+ //
4
+ // `yjs(ref)` returns the `Y.Doc` backing a root document ref.
5
+ //
6
+ // The mapping is maintained via a WeakMap from Substrate → Y.Doc,
7
+ // populated by `registerYjsSubstrate()` (called during substrate
8
+ // creation). The `yjs()` function uses `unwrap()` from `@kyneta/schema`
9
+ // to get the substrate, then looks up the Y.Doc.
10
+ //
11
+ // This two-step approach (ref → substrate → Y.Doc) avoids duplicating
12
+ // the ref-tracking WeakMap and composes cleanly with the general
13
+ // `unwrap()` escape hatch.
14
+ //
15
+ // Usage:
16
+ // import { yjs } from "@kyneta/yjs-schema"
17
+ //
18
+ // const doc = exchange.get("my-doc", TodoDoc)
19
+ // const yjsDoc = yjs(doc) // Y.Doc
20
+ // yjsDoc.getMap("root").toJSON() // raw Yjs inspection
21
+
22
+ import type { Doc as YDoc } from "yjs"
23
+ import type { Substrate } from "@kyneta/schema"
24
+ import { unwrap } from "@kyneta/schema"
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Substrate → Y.Doc mapping
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const substrateToYjsDoc = new WeakMap<Substrate<any>, YDoc>()
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // registerYjsSubstrate — called during substrate creation
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Register the Y.Doc backing a Yjs substrate.
38
+ *
39
+ * Called by `createYjsSubstrate()` and by `bindYjs`'s factory builder
40
+ * to enable the `yjs()` escape hatch. Must be called once per substrate
41
+ * at construction time.
42
+ */
43
+ export function registerYjsSubstrate(
44
+ substrate: Substrate<any>,
45
+ doc: YDoc,
46
+ ): void {
47
+ substrateToYjsDoc.set(substrate, doc)
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // yjs — Yjs-specific escape hatch
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Returns the `Y.Doc` backing the given ref.
56
+ *
57
+ * This is the Yjs-specific escape hatch for accessing substrate-level
58
+ * capabilities: raw Yjs API, y-prosemirror/y-codemirror bindings,
59
+ * undo manager, awareness protocol, Yjs providers (y-websocket,
60
+ * y-indexeddb, y-webrtc, Hocuspocus, Liveblocks), etc.
61
+ *
62
+ * Currently supports root document refs only. Child-level resolution
63
+ * (e.g. `yjs(doc.title)` → `Y.Text`) is future work.
64
+ *
65
+ * @param ref - A root document ref backed by a Yjs substrate
66
+ * @returns The `Y.Doc` backing the ref
67
+ * @throws If the ref is not backed by a Yjs substrate
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { yjs } from "@kyneta/yjs-schema"
72
+ *
73
+ * const doc = exchange.get("my-doc", TodoDoc)
74
+ * const yjsDoc = yjs(doc)
75
+ * console.log(yjsDoc.getMap("root").toJSON()) // raw state
76
+ * console.log(yjsDoc.clientID) // client ID
77
+ * ```
78
+ */
79
+ export function yjs(ref: object): YDoc {
80
+ let substrate: Substrate<any>
81
+ try {
82
+ substrate = unwrap(ref)
83
+ } catch {
84
+ throw new Error(
85
+ "yjs() requires a ref backed by a Yjs substrate. " +
86
+ "Use a doc created by exchange.get() with a bindYjs() schema, " +
87
+ "or by createYjsDoc().",
88
+ )
89
+ }
90
+
91
+ const doc = substrateToYjsDoc.get(substrate)
92
+ if (!doc) {
93
+ throw new Error(
94
+ "yjs() requires a ref backed by a Yjs substrate. " +
95
+ "The ref has a substrate but it is not a Yjs substrate. " +
96
+ "Use a doc created with a bindYjs() schema or createYjsDoc().",
97
+ )
98
+ }
99
+ return doc
100
+ }