@kyneta/yjs-schema 1.1.0 → 1.3.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.
package/src/substrate.ts CHANGED
@@ -22,12 +22,18 @@ import type {
22
22
  SubstratePayload,
23
23
  WritableContext,
24
24
  } from "@kyneta/schema"
25
- import { BACKING_DOC, buildWritableContext, executeBatch } from "@kyneta/schema"
25
+ import {
26
+ BACKING_DOC,
27
+ buildWritableContext,
28
+ executeBatch,
29
+ KIND,
30
+ } from "@kyneta/schema"
26
31
  import * as Y from "yjs"
27
32
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
28
33
  import { ensureContainers } from "./populate.js"
29
34
  import { yjsReader } from "./reader.js"
30
35
  import { YjsVersion } from "./version.js"
36
+ import { resolveYjsType } from "./yjs-resolve.js"
31
37
 
32
38
  // ---------------------------------------------------------------------------
33
39
  // Origin tag — used to suppress echo from our own transactions
@@ -101,7 +107,7 @@ export function createYjsSubstrate(
101
107
  // wrappedPrepare (changefeed layer) still buffers the op.
102
108
  },
103
109
 
104
- onFlush(origin?: string): void {
110
+ onFlush(_origin?: string): void {
105
111
  if (!inOurTransaction && pendingChanges.length > 0) {
106
112
  // Local write: apply accumulated changes within a single
107
113
  // Yjs transaction tagged with our origin for echo suppression.
@@ -124,6 +130,17 @@ export function createYjsSubstrate(
124
130
  context(): WritableContext {
125
131
  if (!cachedCtx) {
126
132
  cachedCtx = buildWritableContext(substrate)
133
+ // Attach nativeResolver — used by interpretImpl to set [NATIVE]
134
+ // on every ref. The resolver maps schema positions to Yjs shared types.
135
+ ;(cachedCtx as any).nativeResolver = (
136
+ nodeSchema: SchemaNode,
137
+ path: { segments: readonly unknown[] },
138
+ ) => {
139
+ if (path.segments.length === 0) return doc
140
+ if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
141
+ return undefined
142
+ return resolveYjsType(rootMap, schema, path as any)
143
+ }
127
144
  }
128
145
  return cachedCtx
129
146
  },
@@ -132,6 +149,18 @@ export function createYjsSubstrate(
132
149
  return new YjsVersion(Y.encodeStateVector(doc))
133
150
  },
134
151
 
152
+ baseVersion(): YjsVersion {
153
+ // Yjs substrate: base is always the initial state (no advance supported).
154
+ return new YjsVersion(new Uint8Array([0]))
155
+ },
156
+
157
+ advance(_to: YjsVersion): void {
158
+ throw new Error(
159
+ "advance() on a live Yjs substrate is not yet supported. " +
160
+ "Use advance() on a YjsReplica instead.",
161
+ )
162
+ },
163
+
135
164
  exportEntirety(): SubstratePayload {
136
165
  return {
137
166
  kind: "entirety",
@@ -180,7 +209,7 @@ export function createYjsSubstrate(
180
209
  }
181
210
 
182
211
  // Convert Yjs events → kyneta Ops
183
- const ops = eventsToOps(events)
212
+ const ops = eventsToOps(events, schema)
184
213
  if (ops.length === 0) {
185
214
  return
186
215
  }
@@ -239,24 +268,55 @@ export function createYjsSubstrate(
239
268
  * storage without ever interpreting document fields.
240
269
  */
241
270
  export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
271
+ let currentDoc = doc
272
+ let currentBase: YjsVersion = new YjsVersion(Y.encodeStateVector(new Y.Doc()))
273
+
242
274
  return {
243
- [BACKING_DOC]: doc,
275
+ get [BACKING_DOC]() {
276
+ return currentDoc
277
+ },
244
278
 
245
279
  version(): YjsVersion {
246
- return new YjsVersion(Y.encodeStateVector(doc))
280
+ return new YjsVersion(Y.encodeStateVector(currentDoc))
281
+ },
282
+
283
+ baseVersion(): YjsVersion {
284
+ return currentBase
285
+ },
286
+
287
+ advance(to: YjsVersion): void {
288
+ const baseCmp = currentBase.compare(to)
289
+ if (baseCmp === "ahead") {
290
+ throw new Error("advance(): target is behind base version")
291
+ }
292
+ const currentCmp = to.compare(this.version())
293
+ if (currentCmp === "ahead") {
294
+ throw new Error("advance(): target is ahead of current version")
295
+ }
296
+
297
+ // Yjs can only do full projection (to = version).
298
+ // For any to < version, it's a no-op — undershoot contract.
299
+ if (currentCmp !== "equal") return
300
+
301
+ // Full projection: create a new doc with current state, no history.
302
+ const update = Y.encodeStateAsUpdate(currentDoc)
303
+ const newDoc = new Y.Doc()
304
+ Y.applyUpdate(newDoc, update)
305
+ currentDoc = newDoc
306
+ currentBase = new YjsVersion(Y.encodeStateVector(currentDoc))
247
307
  },
248
308
 
249
309
  exportEntirety(): SubstratePayload {
250
310
  return {
251
311
  kind: "entirety",
252
312
  encoding: "binary",
253
- data: Y.encodeStateAsUpdate(doc),
313
+ data: Y.encodeStateAsUpdate(currentDoc),
254
314
  }
255
315
  },
256
316
 
257
317
  exportSince(since: YjsVersion): SubstratePayload | null {
258
318
  try {
259
- const bytes = Y.encodeStateAsUpdate(doc, since.sv)
319
+ const bytes = Y.encodeStateAsUpdate(currentDoc, since.sv)
260
320
  return { kind: "since", encoding: "binary", data: bytes }
261
321
  } catch {
262
322
  return null
@@ -273,7 +333,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
273
333
  "If you recently switched CRDT backends, stale clients may be sending incompatible data.",
274
334
  )
275
335
  }
276
- Y.applyUpdate(doc, payload.data)
336
+ Y.applyUpdate(currentDoc, payload.data)
277
337
  },
278
338
  } as Replica<YjsVersion>
279
339
  }
package/src/version.ts CHANGED
@@ -12,27 +12,45 @@
12
12
  // decoded `Map<number, number>` (clientID → clock) maps ourselves.
13
13
 
14
14
  import type { Version } from "@kyneta/schema"
15
+ import {
16
+ base64ToUint8Array,
17
+ uint8ArrayToBase64,
18
+ versionVectorCompare,
19
+ versionVectorMeet,
20
+ } from "@kyneta/schema"
15
21
  import { decodeStateVector } from "yjs"
16
22
 
17
23
  // ---------------------------------------------------------------------------
18
- // Base64 helpers (platform-agnostic, no Node.js Buffer dependency)
24
+ // State vector encoding manual varint (unsigned LEB128)
19
25
  // ---------------------------------------------------------------------------
20
26
 
21
- function uint8ArrayToBase64(bytes: Uint8Array): string {
22
- let binary = ""
23
- for (let i = 0; i < bytes.length; i++) {
24
- binary += String.fromCharCode(bytes[i]!)
27
+ /**
28
+ * Encode a state vector map to Yjs's binary state vector format.
29
+ *
30
+ * Yjs does not export `encodeStateVector(map)` — only `Y.encodeStateVector(doc)`
31
+ * which requires a full doc. This implements the same binary format directly:
32
+ * `[entryCount: varint, (clientId: varint, clock: varint)*]`
33
+ *
34
+ * Each value is encoded as an unsigned LEB128 varint.
35
+ */
36
+ function encodeStateVector(map: Map<number, number>): Uint8Array {
37
+ const bytes: number[] = []
38
+
39
+ function writeVarUint(value: number): void {
40
+ while (value > 0x7f) {
41
+ bytes.push((value & 0x7f) | 0x80)
42
+ value >>>= 7
43
+ }
44
+ bytes.push(value & 0x7f)
25
45
  }
26
- return btoa(binary)
27
- }
28
46
 
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)
47
+ writeVarUint(map.size)
48
+ for (const [clientId, clock] of map) {
49
+ writeVarUint(clientId)
50
+ writeVarUint(clock)
34
51
  }
35
- return bytes
52
+
53
+ return new Uint8Array(bytes)
36
54
  }
37
55
 
38
56
  // ---------------------------------------------------------------------------
@@ -71,54 +89,38 @@ export class YjsVersion implements Version {
71
89
  /**
72
90
  * Compare with another version using version-vector partial order.
73
91
  *
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"`
92
+ * Delegates to the shared `versionVectorCompare` utility after decoding
93
+ * both state vectors via `Y.decodeStateVector()`.
83
94
  *
84
- * Throws if `other` is not a `YjsVersion`.
95
+ * @throws If `other` is not a `YjsVersion`.
85
96
  */
86
97
  compare(other: Version): "behind" | "equal" | "ahead" | "concurrent" {
87
98
  if (!(other instanceof YjsVersion)) {
88
99
  throw new Error("YjsVersion can only be compared with another YjsVersion")
89
100
  }
101
+ return versionVectorCompare(
102
+ decodeStateVector(this.sv),
103
+ decodeStateVector(other.sv),
104
+ )
105
+ }
90
106
 
107
+ /**
108
+ * Greatest lower bound (lattice meet) of two Yjs versions.
109
+ *
110
+ * Decodes both state vectors, computes the component-wise minimum
111
+ * via the shared `versionVectorMeet` utility, and encodes the result
112
+ * back to a Yjs state vector.
113
+ *
114
+ * @throws If `other` is not a `YjsVersion`.
115
+ */
116
+ meet(other: Version): YjsVersion {
117
+ if (!(other instanceof YjsVersion)) {
118
+ throw new Error("YjsVersion can only be meet'd with another YjsVersion")
119
+ }
91
120
  const thisMap = decodeStateVector(this.sv)
92
121
  const otherMap = decodeStateVector(other.sv)
93
-
94
- // Collect the union of all client IDs
95
- const allClients = new Set<number>()
96
- for (const id of thisMap.keys()) allClients.add(id)
97
- for (const id of otherMap.keys()) allClients.add(id)
98
-
99
- let hasLess = false
100
- let hasGreater = false
101
-
102
- for (const clientId of allClients) {
103
- const thisClock = thisMap.get(clientId) ?? 0
104
- const otherClock = otherMap.get(clientId) ?? 0
105
-
106
- if (thisClock < otherClock) {
107
- hasLess = true
108
- }
109
- if (thisClock > otherClock) {
110
- hasGreater = true
111
- }
112
-
113
- // Early exit: if we've seen both less and greater, it's concurrent
114
- if (hasLess && hasGreater) {
115
- return "concurrent"
116
- }
117
- }
118
-
119
- if (hasLess && !hasGreater) return "behind"
120
- if (hasGreater && !hasLess) return "ahead"
121
- return "equal"
122
+ const result = versionVectorMeet(thisMap, otherMap)
123
+ return new YjsVersion(encodeStateVector(result))
122
124
  }
123
125
 
124
126
  /**
@@ -78,16 +78,6 @@ export function resolveYjsType(
78
78
  let current: unknown = rootMap
79
79
  let schema = rootSchema
80
80
 
81
- // Unwrap the root annotation (e.g. annotated("doc", product))
82
- // to reach the product schema whose fields are the root map's children.
83
- let rootProduct = rootSchema
84
- while (
85
- rootProduct._kind === "annotated" &&
86
- rootProduct.schema !== undefined
87
- ) {
88
- rootProduct = rootProduct.schema
89
- }
90
-
91
81
  for (let i = 0; i < path.length; i++) {
92
82
  const seg = path.segments[i]!
93
83
  const nextSchema = advanceSchema(schema, seg)
package/src/create.ts DELETED
@@ -1,177 +0,0 @@
1
- // create — batteries-included document construction backed by YjsSubstrate.
2
- //
3
- // Provides `createYjsDoc` and `createYjsDocFromEntirety` functions that
4
- // hide the interpret pipeline and layer composition behind a single call.
5
- //
6
- // Internally tracks substrates via a module-scoped WeakMap so that sync
7
- // primitives (`version`, `exportEntirety`, `merge` in sync.ts)
8
- // can retrieve the substrate from just a doc ref.
9
- //
10
- // `getSubstrate` is exported for use by `sync.ts` but is NOT re-exported
11
- // from the barrel (`index.ts`). It is an internal cross-module helper.
12
- //
13
- // Two forms for `createYjsDoc`:
14
- // createYjsDoc(schema, yjsDoc) — "bring your own doc" (wrap existing)
15
- // createYjsDoc(schema) — create a fresh empty Y.Doc
16
-
17
- import type {
18
- Ref,
19
- Schema as SchemaType,
20
- Substrate,
21
- SubstratePayload,
22
- } from "@kyneta/schema"
23
- import {
24
- changefeed,
25
- interpret,
26
- readable,
27
- registerSubstrate,
28
- writable,
29
- } from "@kyneta/schema"
30
- import type * as Y from "yjs"
31
- import { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
32
- import type { YjsVersion } from "./version.js"
33
-
34
- // ---------------------------------------------------------------------------
35
- // Substrate tracking (module-scoped)
36
- // ---------------------------------------------------------------------------
37
-
38
- const substrates = new WeakMap<object, Substrate<YjsVersion>>()
39
-
40
- /**
41
- * Retrieve the substrate associated with a doc created by `createYjsDoc`
42
- * or `createYjsDocFromEntirety`.
43
- *
44
- * Exported for `sync.ts` — NOT re-exported from the barrel.
45
- *
46
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
47
- */
48
- export function getSubstrate(doc: object): Substrate<YjsVersion> {
49
- const s = substrates.get(doc)
50
- if (!s) {
51
- throw new Error(
52
- "version/exportEntirety/merge called on an object without a YjsSubstrate. " +
53
- "Use a doc created by createYjsDoc() or createYjsDocFromEntirety().",
54
- )
55
- }
56
- return s
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // registerDoc — internal helper (interpret + WeakMap registration)
61
- // ---------------------------------------------------------------------------
62
-
63
- function registerDoc(
64
- schema: SchemaType,
65
- substrate: Substrate<YjsVersion>,
66
- ): any {
67
- // The `as any` on the builder avoids TS2589 — interpret's fluent API
68
- // produces deeply recursive types when S is the abstract SchemaType.
69
- // The public createYjsDoc/createYjsDocFromEntirety signatures provide
70
- // the correct Ref<S> return type via interface call signature patterns.
71
- const doc: any = (interpret as any)(schema, substrate.context())
72
- .with(readable)
73
- .with(writable)
74
- .with(changefeed)
75
- .done()
76
- substrates.set(doc, substrate)
77
- // Also register in the general unwrap() registry so that the
78
- // yjs() escape hatch can discover the substrate from the ref.
79
- registerSubstrate(doc, substrate)
80
- return doc
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // isYDoc — runtime check for Y.Doc
85
- // ---------------------------------------------------------------------------
86
-
87
- function isYDoc(value: unknown): value is Y.Doc {
88
- return (
89
- value !== null &&
90
- value !== undefined &&
91
- typeof value === "object" &&
92
- "getMap" in value &&
93
- "getText" in value &&
94
- "getArray" in value &&
95
- "transact" in value &&
96
- typeof (value as any).transact === "function" &&
97
- // Y.Doc has clientID; distinguish from other objects
98
- "clientID" in value &&
99
- typeof (value as any).clientID === "number"
100
- )
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // createYjsDoc
105
- // ---------------------------------------------------------------------------
106
-
107
- // Interface call signature avoids TS2589 on Ref<S> when S is generic.
108
-
109
- /**
110
- * Create a live Yjs-backed document.
111
- *
112
- * **Form 1 — bring your own doc:**
113
- * ```ts
114
- * const yjsDoc = new Y.Doc()
115
- * const doc = createYjsDoc(mySchema, yjsDoc)
116
- * ```
117
- *
118
- * **Form 2 — fresh empty doc:**
119
- * ```ts
120
- * const doc = createYjsDoc(mySchema)
121
- *
122
- * // Apply initial content via change():
123
- * change(doc, d => {
124
- * d.title.insert(0, "Hello")
125
- * d.items.push({ name: "First item" })
126
- * })
127
- * ```
128
- *
129
- * Returns a full-stack `Ref<S>` — callable, navigable, writable,
130
- * transactable, and observable. Backed by a `YjsSubstrate` with
131
- * CRDT collaboration support.
132
- *
133
- * The returned ref observes **all** mutations to the underlying Y.Doc,
134
- * regardless of source (local kyneta writes, merge, external
135
- * `Y.applyUpdate()`, external raw Yjs API mutations).
136
- *
137
- * @param schema - The schema describing the document structure.
138
- * @param doc - Optional `Y.Doc` instance to wrap. If omitted, a fresh
139
- * empty Y.Doc is created with containers matching the schema.
140
- */
141
- type CreateYjsDoc = <S extends SchemaType>(schema: S, doc?: Y.Doc) => Ref<S>
142
-
143
- export const createYjsDoc: CreateYjsDoc = (schema, doc) => {
144
- if (doc !== undefined && isYDoc(doc)) {
145
- // Bring your own doc — wrap the existing Y.Doc
146
- return registerDoc(schema, createYjsSubstrate(doc, schema))
147
- }
148
- // Fresh empty doc
149
- return registerDoc(schema, yjsSubstrateFactory.create(schema))
150
- }
151
-
152
- // ---------------------------------------------------------------------------
153
- // createYjsDocFromEntirety
154
- // ---------------------------------------------------------------------------
155
-
156
- type CreateYjsDocFromEntirety = <S extends SchemaType>(
157
- schema: S,
158
- payload: SubstratePayload,
159
- ) => Ref<S>
160
-
161
- /**
162
- * Reconstruct a live Yjs-backed document from a substrate entirety payload.
163
- *
164
- * The payload must have been produced by `exportEntirety()` on a
165
- * compatible document. This is the entry point for SSR hydration
166
- * and reconnection past log compaction.
167
- *
168
- * ```ts
169
- * const payload = exportEntirety(docA)
170
- * const docB = createYjsDocFromEntirety(MySchema, payload)
171
- * // docB has the same state as docA at the time of export
172
- * ```
173
- */
174
- export const createYjsDocFromEntirety: CreateYjsDocFromEntirety = (
175
- schema,
176
- payload,
177
- ) => registerDoc(schema, yjsSubstrateFactory.fromEntirety(payload, schema))
package/src/sync.ts DELETED
@@ -1,107 +0,0 @@
1
- // sync — sync primitives for YjsSubstrate-backed documents.
2
- //
3
- // These functions provide version tracking, entirety export, and merge
4
- // for documents created via `createYjsDoc` or
5
- // `createYjsDocFromEntirety`. 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 entireties
10
- // and deltas — these are Yjs's native state-as-update bytes.
11
-
12
- import type { SubstratePayload } from "@kyneta/schema"
13
- import { getSubstrate } from "./create.js"
14
- import type { YjsVersion } from "./version.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 `createYjsDocFromEntirety`.
27
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
28
- */
29
- export function version(doc: object): YjsVersion {
30
- return getSubstrate(doc).version()
31
- }
32
-
33
- // ---------------------------------------------------------------------------
34
- // exportEntirety — full state for reconstruction
35
- // ---------------------------------------------------------------------------
36
-
37
- /**
38
- * Export the full substrate entirety — sufficient for a new peer to
39
- * reconstruct an equivalent document via `createYjsDocFromEntirety()`.
40
- *
41
- * Returns a binary `SubstratePayload` (Yjs state-as-update bytes).
42
- *
43
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
44
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
45
- */
46
- export function exportEntirety(doc: object): SubstratePayload {
47
- return getSubstrate(doc).exportEntirety()
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
- * merge(docB, delta!)
65
- * ```
66
- *
67
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
68
- * @param since - The version to diff from.
69
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
70
- */
71
- export function exportSince(
72
- doc: object,
73
- since: YjsVersion,
74
- ): SubstratePayload | null {
75
- return getSubstrate(doc).exportSince(since)
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // merge — 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
- * `exportEntirety()` 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
- * merge(docB, delta!, "sync")
94
- * ```
95
- *
96
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
97
- * @param payload - The delta or entirety payload to merge.
98
- * @param origin - Optional provenance tag for the changeset.
99
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
100
- */
101
- export function merge(
102
- doc: object,
103
- payload: SubstratePayload,
104
- origin?: string,
105
- ): void {
106
- getSubstrate(doc).merge(payload, origin)
107
- }
package/src/yjs-escape.ts DELETED
@@ -1,84 +0,0 @@
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 substrate exposes its backing Y.Doc via the `BACKING_DOC` symbol
7
- // (from `@kyneta/schema`). The `yjs()` function uses `unwrap()` to get
8
- // the substrate, then reads `[BACKING_DOC]` to get the Y.Doc.
9
- //
10
- // This two-step approach (ref → substrate → Y.Doc) avoids duplicating
11
- // the ref-tracking WeakMap and composes cleanly with the general
12
- // `unwrap()` escape hatch.
13
- //
14
- // Context: jj:smmulzkm (BACKING_DOC replaces WeakMap + registerYjsSubstrate)
15
- //
16
- // Usage:
17
- // import { yjs } from "@kyneta/yjs-schema"
18
- //
19
- // const doc = exchange.get("my-doc", TodoDoc)
20
- // const yjsDoc = yjs(doc) // Y.Doc
21
- // yjsDoc.getMap("root").toJSON() // raw Yjs inspection
22
-
23
- import { BACKING_DOC, unwrap } from "@kyneta/schema"
24
- import type { Doc as YDoc } from "yjs"
25
-
26
- // ---------------------------------------------------------------------------
27
- // yjs — Yjs-specific escape hatch
28
- // ---------------------------------------------------------------------------
29
-
30
- /**
31
- * Returns the `Y.Doc` backing the given ref.
32
- *
33
- * This is the Yjs-specific escape hatch for accessing substrate-level
34
- * capabilities: raw Yjs API, y-prosemirror/y-codemirror bindings,
35
- * undo manager, awareness protocol, Yjs providers (y-websocket,
36
- * y-indexeddb, y-webrtc, Hocuspocus, Liveblocks), etc.
37
- *
38
- * Currently supports root document refs only. Child-level resolution
39
- * (e.g. `yjs(doc.title)` → `Y.Text`) is future work.
40
- *
41
- * @param ref - A root document ref backed by a Yjs substrate
42
- * @returns The `Y.Doc` backing the ref
43
- * @throws If the ref is not backed by a Yjs substrate
44
- *
45
- * @example
46
- * ```ts
47
- * import { yjs } from "@kyneta/yjs-schema"
48
- *
49
- * const doc = exchange.get("my-doc", TodoDoc)
50
- * const yjsDoc = yjs(doc)
51
- * console.log(yjsDoc.getMap("root").toJSON()) // raw state
52
- * console.log(yjsDoc.clientID) // client ID
53
- * ```
54
- */
55
- export function yjs(ref: object): YDoc {
56
- let substrate: any
57
- try {
58
- substrate = unwrap(ref)
59
- } catch {
60
- throw new Error(
61
- "yjs() requires a ref backed by a Yjs substrate. " +
62
- "Use a doc created by exchange.get() with a bindYjs() schema, " +
63
- "or by createYjsDoc().",
64
- )
65
- }
66
-
67
- const doc = substrate[BACKING_DOC]
68
- // Duck-type check: Y.Doc has getMap, encodeStateVector-compatible API,
69
- // and a numeric clientID. A PlainState (plain object) or LoroDoc would
70
- // not have getMap as a function.
71
- if (
72
- !doc ||
73
- typeof doc !== "object" ||
74
- typeof (doc as any).getMap !== "function" ||
75
- typeof (doc as any).clientID !== "number"
76
- ) {
77
- throw new Error(
78
- "yjs() requires a ref backed by a Yjs substrate. " +
79
- "The ref has a substrate but it is not a Yjs substrate. " +
80
- "Use a doc created with a bindYjs() schema or createYjsDoc().",
81
- )
82
- }
83
- return doc as YDoc
84
- }