@kyneta/yjs-schema 1.1.0 → 1.2.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.
@@ -1,4 +1,4 @@
1
- import { RawPath, Schema } from "@kyneta/schema"
1
+ import { KIND, RawPath, Schema } from "@kyneta/schema"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import * as Y from "yjs"
4
4
  import { ensureContainers } from "../populate.js"
@@ -16,7 +16,7 @@ import { yjsReader } from "../reader.js"
16
16
  * values. We populate values via raw Yjs API within a single transact.
17
17
  */
18
18
  function setup(
19
- schema: ReturnType<typeof Schema.doc>,
19
+ schema: any,
20
20
  seed?: Record<string, unknown>,
21
21
  ) {
22
22
  const doc = new Y.Doc()
@@ -42,11 +42,11 @@ function setup(
42
42
  */
43
43
  function populateSeed(
44
44
  ymap: Y.Map<unknown>,
45
- schema: ReturnType<typeof Schema.doc>,
45
+ schema: any,
46
46
  seed: Record<string, unknown>,
47
47
  ) {
48
- const rootProduct = unwrapToProduct(schema)
49
- if (!rootProduct) return
48
+ if (schema[KIND] !== "product") return
49
+ const rootProduct = schema
50
50
 
51
51
  for (const [key, value] of Object.entries(seed)) {
52
52
  if (value === undefined) continue
@@ -63,20 +63,15 @@ function populateField(
63
63
  fieldSchema: any,
64
64
  value: unknown,
65
65
  ) {
66
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
67
-
68
- if (tag === "text") {
69
- // Text field the Y.Text was already created by ensureContainers
70
- const text = ymap.get(key) as Y.Text
71
- if (text && typeof value === "string" && value.length > 0) {
72
- text.insert(0, value)
66
+ switch (fieldSchema[KIND]) {
67
+ case "text": {
68
+ // Text field the Y.Text was already created by ensureContainers
69
+ const text = ymap.get(key) as Y.Text
70
+ if (text && typeof value === "string" && value.length > 0) {
71
+ text.insert(0, value)
72
+ }
73
+ return
73
74
  }
74
- return
75
- }
76
-
77
- const structural = unwrapAnnotations(fieldSchema)
78
-
79
- switch (structural._kind) {
80
75
  case "product": {
81
76
  // Struct — recurse into the existing Y.Map
82
77
  const childMap = ymap.get(key) as Y.Map<unknown>
@@ -84,7 +79,7 @@ function populateField(
84
79
  for (const [childKey, childValue] of Object.entries(
85
80
  value as Record<string, unknown>,
86
81
  )) {
87
- const childFieldSchema = (structural.fields as Record<string, any>)[
82
+ const childFieldSchema = (fieldSchema.fields as Record<string, any>)[
88
83
  childKey
89
84
  ]
90
85
  if (!childFieldSchema) continue
@@ -99,11 +94,11 @@ function populateField(
99
94
  const arr = ymap.get(key) as Y.Array<unknown>
100
95
  if (arr && Array.isArray(value)) {
101
96
  for (const item of value) {
102
- const itemSchema = structural.item
103
- if (itemSchema && unwrapAnnotations(itemSchema)._kind === "product") {
97
+ const itemSchema = fieldSchema.item
98
+ if (itemSchema && itemSchema[KIND] === "product") {
104
99
  // Struct items: create a Y.Map for each
105
100
  const itemMap = buildStructMap(
106
- unwrapAnnotations(itemSchema),
101
+ itemSchema,
107
102
  item as Record<string, unknown>,
108
103
  )
109
104
  arr.push([itemMap])
@@ -150,22 +145,19 @@ function buildStructMap(
150
145
  const value = seed[key]
151
146
  if (value === undefined) continue
152
147
 
153
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
154
- if (tag === "text") {
155
- const text = new Y.Text()
156
- if (typeof value === "string" && value.length > 0) {
157
- text.insert(0, value)
148
+ switch (fieldSchema[KIND]) {
149
+ case "text": {
150
+ const text = new Y.Text()
151
+ if (typeof value === "string" && value.length > 0) {
152
+ text.insert(0, value)
153
+ }
154
+ map.set(key, text)
155
+ break
158
156
  }
159
- map.set(key, text)
160
- continue
161
- }
162
-
163
- const structural = unwrapAnnotations(fieldSchema)
164
- switch (structural._kind) {
165
157
  case "product": {
166
158
  map.set(
167
159
  key,
168
- buildStructMap(structural, value as Record<string, unknown>),
160
+ buildStructMap(fieldSchema, value as Record<string, unknown>),
169
161
  )
170
162
  break
171
163
  }
@@ -173,14 +165,14 @@ function buildStructMap(
173
165
  const arr = new Y.Array()
174
166
  if (Array.isArray(value)) {
175
167
  for (const item of value) {
176
- const itemSchema = structural.element ?? structural.schema
168
+ const itemSchema = fieldSchema.item
177
169
  if (
178
170
  itemSchema &&
179
- unwrapAnnotations(itemSchema)._kind === "product"
171
+ itemSchema[KIND] === "product"
180
172
  ) {
181
173
  arr.push([
182
174
  buildStructMap(
183
- unwrapAnnotations(itemSchema),
175
+ itemSchema,
184
176
  item as Record<string, unknown>,
185
177
  ),
186
178
  ])
@@ -212,22 +204,7 @@ function buildStructMap(
212
204
  return map
213
205
  }
214
206
 
215
- function unwrapToProduct(schema: any): any {
216
- let s = schema
217
- while (s._kind === "annotated" && s.schema !== undefined) {
218
- s = s.schema
219
- }
220
- if (s._kind === "product") return s
221
- return null
222
- }
223
207
 
224
- function unwrapAnnotations(schema: any): any {
225
- let s = schema
226
- while (s._kind === "annotated" && s.schema !== undefined) {
227
- s = s.schema
228
- }
229
- return s
230
- }
231
208
 
232
209
  /** Build a RawPath from variadic key/index segments. */
233
210
  function p(...segs: (string | number)[]): RawPath {
@@ -242,18 +219,18 @@ function p(...segs: (string | number)[]): RawPath {
242
219
  // Schemas used across tests
243
220
  // ===========================================================================
244
221
 
245
- const TextSchema = Schema.doc({
246
- title: Schema.annotated("text"),
247
- subtitle: Schema.annotated("text"),
222
+ const TextSchema = Schema.struct({
223
+ title: Schema.text(),
224
+ subtitle: Schema.text(),
248
225
  })
249
226
 
250
- const ScalarSchema = Schema.doc({
227
+ const ScalarSchema = Schema.struct({
251
228
  name: Schema.string(),
252
229
  count: Schema.number(),
253
230
  active: Schema.boolean(),
254
231
  })
255
232
 
256
- const NestedStructSchema = Schema.doc({
233
+ const NestedStructSchema = Schema.struct({
257
234
  profile: Schema.struct({
258
235
  first: Schema.string(),
259
236
  last: Schema.string(),
@@ -264,7 +241,7 @@ const NestedStructSchema = Schema.doc({
264
241
  }),
265
242
  })
266
243
 
267
- const ListSchema = Schema.doc({
244
+ const ListSchema = Schema.struct({
268
245
  items: Schema.list(Schema.string()),
269
246
  structs: Schema.list(
270
247
  Schema.struct({
@@ -274,12 +251,12 @@ const ListSchema = Schema.doc({
274
251
  ),
275
252
  })
276
253
 
277
- const MapSchema = Schema.doc({
254
+ const MapSchema = Schema.struct({
278
255
  labels: Schema.record(Schema.string()),
279
256
  })
280
257
 
281
- const MixedSchema = Schema.doc({
282
- title: Schema.annotated("text"),
258
+ const MixedSchema = Schema.struct({
259
+ title: Schema.text(),
283
260
  count: Schema.number(),
284
261
  items: Schema.list(
285
262
  Schema.struct({
@@ -1,10 +1,10 @@
1
1
  // record-text-spike — validate text-inside-struct patterns for Yjs backend.
2
2
  //
3
3
  // The Yjs analog of the Loro counter-in-record spike. Yjs doesn't support
4
- // counter annotations, but it DOES support text annotations. The same
5
- // structural bug exists: when a struct is dynamically inserted into a
6
- // record or list via .set() or .push(), text fields declared in the schema
7
- // but missing from the value object don't get Y.Text containers created.
4
+ // counters, but it DOES support text. The same structural bug exists: when
5
+ // a struct is dynamically inserted into a record or list via .set() or
6
+ // .push(), text fields declared in the schema but missing from the value
7
+ // object don't get Y.Text containers created.
8
8
  //
9
9
  // This spike tests:
10
10
  // 1. record(struct({ name: string(), bio: text() }))
@@ -26,7 +26,6 @@ import {
26
26
  merge,
27
27
  Schema,
28
28
  subscribe,
29
- text,
30
29
  version,
31
30
  } from "../index.js"
32
31
 
@@ -34,16 +33,16 @@ import {
34
33
  // Schemas
35
34
  // ===========================================================================
36
35
 
37
- const ProfileSchema = Schema.doc({
36
+ const ProfileSchema = Schema.struct({
38
37
  profiles: Schema.record(
39
38
  Schema.struct({
40
39
  displayName: Schema.string(),
41
- bio: text(),
40
+ bio: Schema.text(),
42
41
  }),
43
42
  ),
44
43
  })
45
44
 
46
- const PlainRecordSchema = Schema.doc({
45
+ const PlainRecordSchema = Schema.struct({
47
46
  profiles: Schema.record(
48
47
  Schema.struct({
49
48
  displayName: Schema.string(),
@@ -52,11 +51,11 @@ const PlainRecordSchema = Schema.doc({
52
51
  ),
53
52
  })
54
53
 
55
- const ListProfileSchema = Schema.doc({
54
+ const ListProfileSchema = Schema.struct({
56
55
  players: Schema.list(
57
56
  Schema.struct({
58
57
  name: Schema.string(),
59
- bio: text(),
58
+ bio: Schema.text(),
60
59
  }),
61
60
  ),
62
61
  })
@@ -9,16 +9,16 @@
9
9
  import { BACKING_DOC, Schema, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
10
10
  import { describe, expect, it } from "vitest"
11
11
  import * as Y from "yjs"
12
- import { bindYjs } from "../bind-yjs.js"
12
+ import { yjs } from "../bind-yjs.js"
13
13
  import { ensureContainers } from "../populate.js"
14
- import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
14
+ import { yjsSubstrateFactory } from "../substrate.js"
15
15
 
16
16
  // ===========================================================================
17
17
  // Schemas used across tests
18
18
  // ===========================================================================
19
19
 
20
- const TestSchema = Schema.doc({
21
- title: Schema.annotated("text"),
20
+ const TestSchema = Schema.struct({
21
+ title: Schema.text(),
22
22
  count: Schema.number(),
23
23
  items: Schema.list(Schema.string()),
24
24
  })
@@ -138,20 +138,20 @@ describe("structural merge protocol (Yjs)", () => {
138
138
 
139
139
  it("field reordering in source doesn't affect structural ops", () => {
140
140
  // Schema A: fields in one order
141
- const schemaA = Schema.doc({
141
+ const schemaA = Schema.struct({
142
142
  alpha: Schema.string(),
143
143
  beta: Schema.number(),
144
- gamma: Schema.annotated("text"),
144
+ gamma: Schema.text(),
145
145
  })
146
146
 
147
147
  // Schema B: same fields, different insertion order
148
148
  // JavaScript objects preserve insertion order, so we construct
149
149
  // with a different order to verify alphabetical sort overrides it.
150
150
  const fields: Record<string, any> = {}
151
- fields.gamma = Schema.annotated("text")
151
+ fields.gamma = Schema.text()
152
152
  fields.alpha = Schema.string()
153
153
  fields.beta = Schema.number()
154
- const schemaB = Schema.doc(fields)
154
+ const schemaB = Schema.struct(fields)
155
155
 
156
156
  const docA = new Y.Doc()
157
157
  ensureContainers(docA, schemaA)
@@ -168,15 +168,15 @@ describe("structural merge protocol (Yjs)", () => {
168
168
  // ── Schema evolution ──
169
169
 
170
170
  it("add field after hydration, structural ops extend correctly", () => {
171
- const v1Schema = Schema.doc({
172
- title: Schema.annotated("text"),
171
+ const v1Schema = Schema.struct({
172
+ title: Schema.text(),
173
173
  count: Schema.number(),
174
174
  })
175
175
 
176
- const v2Schema = Schema.doc({
176
+ const v2Schema = Schema.struct({
177
177
  count: Schema.number(),
178
- notes: Schema.annotated("text"), // new field
179
- title: Schema.annotated("text"),
178
+ notes: Schema.text(), // new field
179
+ title: Schema.text(),
180
180
  })
181
181
 
182
182
  // Peer A: create v1, write data, export
@@ -282,10 +282,10 @@ describe("structural merge protocol (Yjs)", () => {
282
282
  expect(root2.get("count")).toBe(123)
283
283
  })
284
284
 
285
- // ── bindYjs integration ──
285
+ // ── yjs.bind integration ──
286
286
 
287
- it("bindYjs factory produces deterministic structural ops across peers", () => {
288
- const bound = bindYjs(TestSchema)
287
+ it("yjs.bind factory produces deterministic structural ops across peers", () => {
288
+ const bound = yjs.bind(TestSchema)
289
289
 
290
290
  const factoryA = bound.factory({ peerId: "alice" })
291
291
  const factoryB = bound.factory({ peerId: "bob" })
@@ -301,8 +301,8 @@ describe("structural merge protocol (Yjs)", () => {
301
301
  expect(stateA.data).toEqual(stateB.data)
302
302
  })
303
303
 
304
- it("bindYjs peers merge without structural conflict", () => {
305
- const bound = bindYjs(TestSchema)
304
+ it("yjs.bind peers merge without structural conflict", () => {
305
+ const bound = yjs.bind(TestSchema)
306
306
 
307
307
  const factoryA = bound.factory({ peerId: "alice" })
308
308
  const factoryB = bound.factory({ peerId: "bob" })
@@ -1,9 +1,9 @@
1
1
  import { change, RawPath, Schema, subscribe } from "@kyneta/schema"
2
- import { describe, expect, it, vi } from "vitest"
2
+ import { describe, expect, it } from "vitest"
3
3
  import * as Y from "yjs"
4
4
  import { createYjsDoc, createYjsDocFromEntirety } from "../create.js"
5
5
  import { ensureContainers } from "../populate.js"
6
- import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
6
+ import { yjsSubstrateFactory } from "../substrate.js"
7
7
  import { exportEntirety, exportSince, merge, version } from "../sync.js"
8
8
  import { YjsVersion } from "../version.js"
9
9
 
@@ -15,13 +15,13 @@ import { YjsVersion } from "../version.js"
15
15
  // Schemas used across tests
16
16
  // ===========================================================================
17
17
 
18
- const SimpleSchema = Schema.doc({
19
- title: Schema.annotated("text"),
18
+ const SimpleSchema = Schema.struct({
19
+ title: Schema.text(),
20
20
  count: Schema.number(),
21
21
  items: Schema.list(Schema.string()),
22
22
  })
23
23
 
24
- const StructListSchema = Schema.doc({
24
+ const StructListSchema = Schema.struct({
25
25
  tasks: Schema.list(
26
26
  Schema.struct({
27
27
  name: Schema.string(),
@@ -30,8 +30,8 @@ const StructListSchema = Schema.doc({
30
30
  ),
31
31
  })
32
32
 
33
- const FullSchema = Schema.doc({
34
- title: Schema.annotated("text"),
33
+ const FullSchema = Schema.struct({
34
+ title: Schema.text(),
35
35
  count: Schema.number(),
36
36
  active: Schema.boolean(),
37
37
  items: Schema.list(Schema.string()),
@@ -346,7 +346,7 @@ describe("YjsSubstrate", () => {
346
346
 
347
347
  it("nested struct field changefeed fires on merge", () => {
348
348
  const doc1 = createYjsDoc(StructListSchema)
349
- const doc2 = createYjsDocFromEntirety(
349
+ const _doc2 = createYjsDocFromEntirety(
350
350
  StructListSchema,
351
351
  exportEntirety(doc1),
352
352
  )
@@ -512,29 +512,26 @@ describe("YjsSubstrate", () => {
512
512
  // Counter annotation throws
513
513
  // -------------------------------------------------------------------------
514
514
 
515
- describe("unsupported annotations", () => {
516
- it("counter annotation throws clear error at construction", () => {
517
- const CounterSchema = Schema.doc({
518
- count: Schema.annotated("counter"),
515
+ describe("unsupported kinds", () => {
516
+ it("counter throws clear error at construction", () => {
517
+ const CounterSchema = Schema.struct({
518
+ count: Schema.counter(),
519
519
  })
520
520
 
521
521
  expect(() => yjsSubstrateFactory.create(CounterSchema)).toThrow("counter")
522
522
  })
523
523
 
524
- it("movable annotation throws clear error at construction", () => {
525
- const MovableSchema = Schema.doc({
526
- items: Schema.annotated("movable", Schema.list(Schema.string())),
524
+ it("movableList throws clear error at construction", () => {
525
+ const MovableSchema = Schema.struct({
526
+ items: Schema.movableList(Schema.string()),
527
527
  })
528
528
 
529
529
  expect(() => yjsSubstrateFactory.create(MovableSchema)).toThrow("movable")
530
530
  })
531
531
 
532
- it("tree annotation throws clear error at construction", () => {
533
- const TreeSchema = Schema.doc({
534
- tree: Schema.annotated(
535
- "tree",
536
- Schema.struct({ label: Schema.string() }),
537
- ),
532
+ it("tree throws clear error at construction", () => {
533
+ const TreeSchema = Schema.struct({
534
+ tree: Schema.tree(Schema.struct({ label: Schema.string() })),
538
535
  })
539
536
 
540
537
  expect(() => yjsSubstrateFactory.create(TreeSchema)).toThrow("tree")
@@ -187,6 +187,7 @@ describe("YjsVersion", () => {
187
187
  const fake = {
188
188
  serialize: () => "fake",
189
189
  compare: () => "equal" as const,
190
+ meet: () => fake,
190
191
  }
191
192
  expect(() => v.compare(fake)).toThrow(
192
193
  "YjsVersion can only be compared with another YjsVersion",
@@ -215,4 +216,78 @@ describe("YjsVersion", () => {
215
216
  expect(late.compare(earlyParsed)).toBe("ahead")
216
217
  })
217
218
  })
219
+
220
+ // -------------------------------------------------------------------------
221
+ // meet
222
+ // -------------------------------------------------------------------------
223
+
224
+ describe("YjsVersion.meet()", () => {
225
+ it("meet of concurrent versions produces component-wise minimum", () => {
226
+ // Create two docs with independent edits
227
+ const doc1 = new Y.Doc()
228
+ const doc2 = new Y.Doc()
229
+
230
+ doc1.getMap("root").set("a", 1)
231
+ doc1.getMap("root").set("b", 2)
232
+ doc2.getMap("root").set("c", 3)
233
+
234
+ const v1 = new YjsVersion(Y.encodeStateVector(doc1))
235
+ const v2 = new YjsVersion(Y.encodeStateVector(doc2))
236
+
237
+ // meet of concurrent versions — result ≤ both
238
+ const meet = v1.meet(v2) as YjsVersion
239
+ expect(meet.compare(v1)).not.toBe("ahead")
240
+ expect(meet.compare(v2)).not.toBe("ahead")
241
+ })
242
+
243
+ it("meet of identical versions returns an equal version", () => {
244
+ const doc = new Y.Doc()
245
+ doc.getMap("root").set("x", 1)
246
+ const v = new YjsVersion(Y.encodeStateVector(doc))
247
+
248
+ const meet = v.meet(v) as YjsVersion
249
+ expect(meet.compare(v)).toBe("equal")
250
+ })
251
+
252
+ it("meet round-trips through Yjs decode correctly", () => {
253
+ // The custom encodeStateVector must produce bytes that Yjs can decode
254
+ const doc1 = new Y.Doc()
255
+ const doc2 = new Y.Doc()
256
+
257
+ doc1.getMap("root").set("x", 1)
258
+ doc1.getMap("root").set("y", 2)
259
+
260
+ // Sync doc1 → doc2, then doc2 makes independent edits
261
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
262
+ doc2.getMap("root").set("z", 3)
263
+
264
+ const v1 = new YjsVersion(Y.encodeStateVector(doc1))
265
+ const v2 = new YjsVersion(Y.encodeStateVector(doc2))
266
+
267
+ // v1 is behind v2 (v2 has all of v1's ops plus its own)
268
+ expect(v1.compare(v2)).toBe("behind")
269
+
270
+ // meet(v1, v2) should equal v1 (the behind one)
271
+ const meet = v1.meet(v2) as YjsVersion
272
+ expect(meet.compare(v1)).toBe("equal")
273
+
274
+ // The meet's state vector bytes can be decoded by Yjs
275
+ const decoded = Y.decodeStateVector(meet.sv)
276
+ expect(decoded.size).toBeGreaterThan(0)
277
+ })
278
+
279
+ it("meet of two behind-ahead versions gives the behind one", () => {
280
+ const doc = new Y.Doc()
281
+ doc.getMap("root").set("a", 1)
282
+ const early = new YjsVersion(Y.encodeStateVector(doc))
283
+
284
+ doc.getMap("root").set("b", 2)
285
+ const late = new YjsVersion(Y.encodeStateVector(doc))
286
+
287
+ expect(early.compare(late)).toBe("behind")
288
+
289
+ const meet = early.meet(late) as YjsVersion
290
+ expect(meet.compare(early)).toBe("equal")
291
+ })
292
+ })
218
293
  })
package/src/bind-yjs.ts CHANGED
@@ -1,33 +1,38 @@
1
- // bind-yjs — bindYjs() convenience wrapper for Yjs CRDT substrate.
1
+ // bind-yjs — Yjs CRDT substrate namespace and factory.
2
2
  //
3
- // Binds a schema to the Yjs substrate with causal merge strategy.
4
- // The factory builder accepts { peerId } and returns a SubstrateFactory
5
- // that sets doc.clientID on every new Y.Doc, ensuring deterministic
6
- // peer identity across all documents in an exchange.
3
+ // Provides the `yjs` substrate namespace (`yjs.bind()`, `yjs.replica()`,
4
+ // `yjs.unwrap()`) and the internal factory builder that injects a
5
+ // deterministic numeric Yjs clientID derived from the exchange's peerId.
7
6
  //
8
7
  // Yjs clientID is a uint32 number. We use FNV-1a hash truncated to
9
8
  // 32 bits, mirroring the Loro binding's hashPeerId pattern but
10
9
  // targeting Yjs's number type (not Loro's bigint/53-bit PeerID).
11
10
  //
12
11
  // Usage:
13
- // import { bindYjs } from "@kyneta/yjs-schema"
12
+ // import { yjs } from "@kyneta/yjs-schema"
14
13
  //
15
- // const TodoDoc = bindYjs(Schema.doc({
16
- // title: Schema.annotated("text"),
14
+ // const TodoDoc = yjs.bind(Schema.struct({
15
+ // title: Schema.text(),
17
16
  // items: Schema.list(Schema.struct({ name: Schema.string() })),
18
17
  // }))
19
18
  //
20
19
  // const doc = exchange.get("my-doc", TodoDoc)
21
20
 
22
21
  import type {
23
- BoundSchema,
22
+ CrdtStrategy,
24
23
  Replica,
25
24
  Schema as SchemaNode,
26
25
  Substrate,
27
26
  SubstrateFactory,
27
+ SubstrateNamespace,
28
28
  SubstratePayload,
29
29
  } from "@kyneta/schema"
30
- import { BACKING_DOC, bind, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
30
+ import {
31
+ BACKING_DOC,
32
+ createSubstrateNamespace,
33
+ STRUCTURAL_YJS_CLIENT_ID,
34
+ unwrap,
35
+ } from "@kyneta/schema"
31
36
  import * as Y from "yjs"
32
37
  import { ensureContainers } from "./populate.js"
33
38
  import {
@@ -127,42 +132,67 @@ function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
127
132
  }
128
133
 
129
134
  // ---------------------------------------------------------------------------
130
- // bindYjs — the convenience wrapper
135
+ // yjs — the Yjs CRDT substrate namespace
131
136
  // ---------------------------------------------------------------------------
132
137
 
133
138
  /**
134
- * Bind a schema to the Yjs CRDT substrate with causal merge strategy.
135
- *
136
- * This is the recommended way to declare a Yjs-backed document type.
137
- * The factory builder injects a deterministic numeric Yjs clientID derived
138
- * from the exchange's string peerId, ensuring consistent change attribution
139
- * across all documents and sessions.
140
- *
141
- * **Unsupported annotations:** Yjs has no native counter, movable list,
142
- * or tree types. Schemas passed to `bindYjs` must not contain
143
- * `Schema.annotated("counter")`, `Schema.annotated("movable")`, or
144
- * `Schema.annotated("tree")`. These will throw at construction time.
145
- *
146
- * @example
147
- * ```ts
148
- * import { bindYjs } from "@kyneta/yjs-schema"
149
- * import { Schema } from "@kyneta/schema"
139
+ * The Yjs CRDT substrate namespace.
150
140
  *
151
- * const TodoDoc = bindYjs(Schema.doc({
152
- * title: Schema.annotated("text"),
153
- * items: Schema.list(Schema.struct({
154
- * name: Schema.string(),
155
- * done: Schema.boolean(),
156
- * })),
157
- * }))
141
+ * - `yjs.bind(schema)` collaborative sync (default)
142
+ * - `yjs.bind(schema, "ephemeral")` — ephemeral/presence broadcast
143
+ * - `yjs.replica()` — collaborative replication (default)
144
+ * - `yjs.replica("ephemeral")` — ephemeral replication
145
+ * - `yjs.unwrap(ref)` — access the underlying Y.Doc
158
146
  *
159
- * const doc = exchange.get("my-todos", TodoDoc)
160
- * ```
147
+ * Strategy is constrained to `CrdtStrategy` (`"collaborative" | "ephemeral"`).
148
+ * Passing `"authoritative"` is a compile error.
161
149
  */
162
- export function bindYjs<S extends SchemaNode>(schema: S): BoundSchema<S> {
163
- return bind({
164
- schema,
165
- factory: ctx => createYjsFactory(ctx.peerId),
166
- strategy: "causal",
167
- })
150
+ /** The closed set of capability tags that the Yjs substrate supports. */
151
+ export type YjsCaps = "text" | "json"
152
+
153
+ export const yjs: SubstrateNamespace<CrdtStrategy, YjsCaps> & {
154
+ /** Access the underlying `Y.Doc` backing a ref. */
155
+ unwrap(ref: object): Y.Doc
156
+ } = {
157
+ ...createSubstrateNamespace<CrdtStrategy, YjsCaps>({
158
+ strategies: {
159
+ collaborative: {
160
+ factory: ctx => createYjsFactory(ctx.peerId),
161
+ replicaFactory: yjsReplicaFactory,
162
+ },
163
+ ephemeral: {
164
+ factory: ctx => createYjsFactory(ctx.peerId),
165
+ replicaFactory: yjsReplicaFactory,
166
+ },
167
+ },
168
+ defaultStrategy: "collaborative",
169
+ }),
170
+
171
+ unwrap(ref: object): Y.Doc {
172
+ let substrate: any
173
+ try {
174
+ substrate = unwrap(ref)
175
+ } catch {
176
+ throw new Error(
177
+ "yjs.unwrap() requires a ref backed by a Yjs substrate. " +
178
+ "Use a doc created by exchange.get() with a yjs.bind() schema, " +
179
+ "or by createYjsDoc().",
180
+ )
181
+ }
182
+
183
+ const doc = substrate[BACKING_DOC]
184
+ if (
185
+ !doc ||
186
+ typeof doc !== "object" ||
187
+ typeof (doc as any).getMap !== "function" ||
188
+ typeof (doc as any).clientID !== "number"
189
+ ) {
190
+ throw new Error(
191
+ "yjs.unwrap() requires a ref backed by a Yjs substrate. " +
192
+ "The ref has a substrate but it is not a Yjs substrate. " +
193
+ "Use a doc created with a yjs.bind() schema or createYjsDoc().",
194
+ )
195
+ }
196
+ return doc as Y.Doc
197
+ },
168
198
  }