@kyneta/yjs-schema 1.3.1 → 1.5.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.
@@ -6,13 +6,37 @@
6
6
  //
7
7
  // Context: jj:ptyzqoul (structural merge protocol)
8
8
 
9
- import { BACKING_DOC, Schema, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
9
+ import {
10
+ BACKING_DOC,
11
+ deriveIdentity,
12
+ deriveSchemaBinding,
13
+ KIND,
14
+ type ProductSchema,
15
+ Schema,
16
+ type SchemaBinding,
17
+ STRUCTURAL_YJS_CLIENT_ID,
18
+ } from "@kyneta/schema"
10
19
  import { describe, expect, it } from "vitest"
11
20
  import * as Y from "yjs"
12
21
  import { yjs } from "../bind-yjs.js"
13
22
  import { ensureContainers } from "../populate.js"
14
23
  import { yjsSubstrateFactory } from "../substrate.js"
15
24
 
25
+ // ===========================================================================
26
+ // Identity-keying helpers
27
+ // ===========================================================================
28
+
29
+ function trivialBinding(schema: any): SchemaBinding {
30
+ if (schema[KIND] === "product") {
31
+ return deriveSchemaBinding(schema as ProductSchema, {})
32
+ }
33
+ return { forward: new Map(), inverse: new Map() }
34
+ }
35
+
36
+ function id(fieldName: string): string {
37
+ return deriveIdentity(fieldName, 1)
38
+ }
39
+
16
40
  // ===========================================================================
17
41
  // Schemas used across tests
18
42
  // ===========================================================================
@@ -31,24 +55,26 @@ describe("structural merge protocol (Yjs)", () => {
31
55
  // ── Core invariant: two peers independently create, sync, no data loss ──
32
56
 
33
57
  it("two peers independently create same schema, sync, data preserved", () => {
58
+ const binding = trivialBinding(TestSchema)
59
+
34
60
  // Peer A creates doc and writes text
35
61
  const docA = new Y.Doc()
36
62
  docA.clientID = 100
37
- ensureContainers(docA, TestSchema)
63
+ ensureContainers(docA, TestSchema, false, binding)
38
64
  docA.transact(() => {
39
65
  const root = docA.getMap("root")
40
- ;(root.get("title") as Y.Text).insert(0, "Hello from A")
41
- root.set("count", 42)
66
+ ;(root.get(id("title")) as Y.Text).insert(0, "Hello from A")
67
+ root.set(id("count"), 42)
42
68
  })
43
69
 
44
70
  // Peer B independently creates same doc and writes different text
45
71
  const docB = new Y.Doc()
46
72
  docB.clientID = 200
47
- ensureContainers(docB, TestSchema)
73
+ ensureContainers(docB, TestSchema, false, binding)
48
74
  docB.transact(() => {
49
75
  const root = docB.getMap("root")
50
- ;(root.get("title") as Y.Text).insert(0, "Hello from B")
51
- root.set("count", 99)
76
+ ;(root.get(id("title")) as Y.Text).insert(0, "Hello from B")
77
+ root.set(id("count"), 99)
52
78
  })
53
79
 
54
80
  // Sync bidirectionally
@@ -62,25 +88,27 @@ describe("structural merge protocol (Yjs)", () => {
62
88
  const rootB = docB.getMap("root")
63
89
 
64
90
  // Text should contain content from both peers (no orphaned containers)
65
- const titleA = (rootA.get("title") as Y.Text).toString()
66
- const titleB = (rootB.get("title") as Y.Text).toString()
91
+ const titleA = (rootA.get(id("title")) as Y.Text).toString()
92
+ const titleB = (rootB.get(id("title")) as Y.Text).toString()
67
93
  expect(titleA).toBe(titleB)
68
94
  // Both texts should be present (merged, not lost)
69
95
  expect(titleA).toContain("Hello from A")
70
96
  expect(titleA).toContain("Hello from B")
71
97
 
72
98
  // Count: last writer wins — both converge to the same value
73
- expect(rootA.get("count")).toBe(rootB.get("count"))
99
+ expect(rootA.get(id("count"))).toBe(rootB.get(id("count")))
74
100
  })
75
101
 
76
102
  it("three peers independently create, sync, all converge", () => {
77
- const docs = [100, 200, 300].map(id => {
103
+ const binding = trivialBinding(TestSchema)
104
+
105
+ const docs = [100, 200, 300].map(cid => {
78
106
  const doc = new Y.Doc()
79
- doc.clientID = id
80
- ensureContainers(doc, TestSchema)
107
+ doc.clientID = cid
108
+ ensureContainers(doc, TestSchema, false, binding)
81
109
  doc.transact(() => {
82
110
  const root = doc.getMap("root")
83
- ;(root.get("title") as Y.Text).insert(0, `Peer${id}`)
111
+ ;(root.get(id("title")) as Y.Text).insert(0, `Peer${cid}`)
84
112
  })
85
113
  return doc
86
114
  })
@@ -89,14 +117,17 @@ describe("structural merge protocol (Yjs)", () => {
89
117
  for (let i = 0; i < docs.length; i++) {
90
118
  for (let j = 0; j < docs.length; j++) {
91
119
  if (i !== j) {
92
- Y.applyUpdate(docs[j]!, Y.encodeStateAsUpdate(docs[i]!))
120
+ const target = docs[j]
121
+ const source = docs[i]
122
+ if (target && source)
123
+ Y.applyUpdate(target, Y.encodeStateAsUpdate(source))
93
124
  }
94
125
  }
95
126
  }
96
127
 
97
128
  // All three converge to the same text
98
129
  const texts = docs.map(d =>
99
- (d.getMap("root").get("title") as Y.Text).toString(),
130
+ (d.getMap("root").get(id("title")) as Y.Text).toString(),
100
131
  )
101
132
  expect(texts[0]).toBe(texts[1])
102
133
  expect(texts[1]).toBe(texts[2])
@@ -109,14 +140,16 @@ describe("structural merge protocol (Yjs)", () => {
109
140
  // ── Persistence round-trip ──
110
141
 
111
142
  it("persist → hydrate → data preserved", () => {
143
+ const binding = trivialBinding(TestSchema)
144
+
112
145
  // Create and write
113
146
  const doc1 = new Y.Doc()
114
147
  doc1.clientID = 42
115
- ensureContainers(doc1, TestSchema)
148
+ ensureContainers(doc1, TestSchema, false, binding)
116
149
  doc1.transact(() => {
117
150
  const root = doc1.getMap("root")
118
- ;(root.get("title") as Y.Text).insert(0, "Persistent")
119
- root.set("count", 7)
151
+ ;(root.get(id("title")) as Y.Text).insert(0, "Persistent")
152
+ root.set(id("count"), 7)
120
153
  })
121
154
 
122
155
  // Export
@@ -126,12 +159,12 @@ describe("structural merge protocol (Yjs)", () => {
126
159
  const doc2 = new Y.Doc()
127
160
  doc2.clientID = 42
128
161
  Y.applyUpdate(doc2, snapshot)
129
- ensureContainers(doc2, TestSchema, true) // conditional
162
+ ensureContainers(doc2, TestSchema, true, binding) // conditional
130
163
 
131
164
  // Data preserved
132
165
  const root2 = doc2.getMap("root")
133
- expect((root2.get("title") as Y.Text).toString()).toBe("Persistent")
134
- expect(root2.get("count")).toBe(7)
166
+ expect((root2.get(id("title")) as Y.Text).toString()).toBe("Persistent")
167
+ expect(root2.get(id("count"))).toBe(7)
135
168
  })
136
169
 
137
170
  // ── Determinism: alphabetical sort ──
@@ -153,11 +186,14 @@ describe("structural merge protocol (Yjs)", () => {
153
186
  fields.beta = Schema.number()
154
187
  const schemaB = Schema.struct(fields)
155
188
 
189
+ const bindingA = trivialBinding(schemaA)
190
+ const bindingB = trivialBinding(schemaB)
191
+
156
192
  const docA = new Y.Doc()
157
- ensureContainers(docA, schemaA)
193
+ ensureContainers(docA, schemaA, false, bindingA)
158
194
 
159
195
  const docB = new Y.Doc()
160
- ensureContainers(docB, schemaB)
196
+ ensureContainers(docB, schemaB, false, bindingB)
161
197
 
162
198
  // Both should produce byte-identical structural state
163
199
  const stateA = Y.encodeStateAsUpdate(docA)
@@ -179,14 +215,17 @@ describe("structural merge protocol (Yjs)", () => {
179
215
  title: Schema.text(),
180
216
  })
181
217
 
218
+ const v1Binding = trivialBinding(v1Schema)
219
+ const v2Binding = trivialBinding(v2Schema)
220
+
182
221
  // Peer A: create v1, write data, export
183
222
  const docA = new Y.Doc()
184
223
  docA.clientID = 100
185
- ensureContainers(docA, v1Schema)
224
+ ensureContainers(docA, v1Schema, false, v1Binding)
186
225
  docA.transact(() => {
187
226
  const root = docA.getMap("root")
188
- ;(root.get("title") as Y.Text).insert(0, "Title")
189
- root.set("count", 5)
227
+ ;(root.get(id("title")) as Y.Text).insert(0, "Title")
228
+ root.set(id("count"), 5)
190
229
  })
191
230
  const v1State = Y.encodeStateAsUpdate(docA)
192
231
 
@@ -194,13 +233,13 @@ describe("structural merge protocol (Yjs)", () => {
194
233
  const docB = new Y.Doc()
195
234
  docB.clientID = 200
196
235
  Y.applyUpdate(docB, v1State)
197
- ensureContainers(docB, v2Schema, true) // conditional — only creates "notes"
236
+ ensureContainers(docB, v2Schema, true, v2Binding) // conditional — only creates "notes"
198
237
 
199
238
  // Another peer C: same thing independently
200
239
  const docC = new Y.Doc()
201
240
  docC.clientID = 300
202
241
  Y.applyUpdate(docC, v1State)
203
- ensureContainers(docC, v2Schema, true)
242
+ ensureContainers(docC, v2Schema, true, v2Binding)
204
243
 
205
244
  // B and C's structural ops for "notes" should be identical (both at clientID 0)
206
245
  const stateB = Y.encodeStateAsUpdate(docB)
@@ -214,22 +253,24 @@ describe("structural merge protocol (Yjs)", () => {
214
253
  const rootC = docC.getMap("root")
215
254
 
216
255
  // Original data preserved
217
- expect((rootB.get("title") as Y.Text).toString()).toBe("Title")
218
- expect(rootB.get("count")).toBe(5)
219
- expect((rootC.get("title") as Y.Text).toString()).toBe("Title")
220
- expect(rootC.get("count")).toBe(5)
256
+ expect((rootB.get(id("title")) as Y.Text).toString()).toBe("Title")
257
+ expect(rootB.get(id("count"))).toBe(5)
258
+ expect((rootC.get(id("title")) as Y.Text).toString()).toBe("Title")
259
+ expect(rootC.get(id("count"))).toBe(5)
221
260
 
222
261
  // New field exists on both
223
- expect(rootB.get("notes")).toBeInstanceOf(Y.Text)
224
- expect(rootC.get("notes")).toBeInstanceOf(Y.Text)
262
+ expect(rootB.get(id("notes"))).toBeInstanceOf(Y.Text)
263
+ expect(rootC.get(id("notes"))).toBeInstanceOf(Y.Text)
225
264
  })
226
265
 
227
266
  // ── Structural identity is clientID 0 ──
228
267
 
229
268
  it("ensureContainers uses clientID 0 for structural ops", () => {
269
+ const binding = trivialBinding(TestSchema)
270
+
230
271
  const doc = new Y.Doc()
231
272
  doc.clientID = 999
232
- ensureContainers(doc, TestSchema)
273
+ ensureContainers(doc, TestSchema, false, binding)
233
274
 
234
275
  // clientID should be restored
235
276
  expect(doc.clientID).toBe(999)
@@ -240,9 +281,11 @@ describe("structural merge protocol (Yjs)", () => {
240
281
  })
241
282
 
242
283
  it("ensureContainers does not leak caller clientID into structural ops", () => {
284
+ const binding = trivialBinding(TestSchema)
285
+
243
286
  const doc = new Y.Doc()
244
287
  doc.clientID = 777
245
- ensureContainers(doc, TestSchema)
288
+ ensureContainers(doc, TestSchema, false, binding)
246
289
 
247
290
  // The state vector should NOT contain the caller's clientID —
248
291
  // only STRUCTURAL_YJS_CLIENT_ID (0) should have produced ops.
@@ -269,8 +312,8 @@ describe("structural merge protocol (Yjs)", () => {
269
312
  const doc1 = (sub1 as any)[BACKING_DOC] as Y.Doc
270
313
  doc1.transact(() => {
271
314
  const root = doc1.getMap("root")
272
- ;(root.get("title") as Y.Text).insert(0, "Round-trip")
273
- root.set("count", 123)
315
+ ;(root.get(id("title")) as Y.Text).insert(0, "Round-trip")
316
+ root.set(id("count"), 123)
274
317
  })
275
318
 
276
319
  const payload = sub1.exportEntirety()
@@ -278,8 +321,8 @@ describe("structural merge protocol (Yjs)", () => {
278
321
  const doc2 = (sub2 as any)[BACKING_DOC] as Y.Doc
279
322
  const root2 = doc2.getMap("root")
280
323
 
281
- expect((root2.get("title") as Y.Text).toString()).toBe("Round-trip")
282
- expect(root2.get("count")).toBe(123)
324
+ expect((root2.get(id("title")) as Y.Text).toString()).toBe("Round-trip")
325
+ expect(root2.get(id("count"))).toBe(123)
283
326
  })
284
327
 
285
328
  // ── yjs.bind integration ──
@@ -287,8 +330,14 @@ describe("structural merge protocol (Yjs)", () => {
287
330
  it("yjs.bind factory produces deterministic structural ops across peers", () => {
288
331
  const bound = yjs.bind(TestSchema)
289
332
 
290
- const factoryA = bound.factory({ peerId: "alice" })
291
- const factoryB = bound.factory({ peerId: "bob" })
333
+ const factoryA = bound.factory({
334
+ peerId: "alice",
335
+ binding: bound.identityBinding,
336
+ })
337
+ const factoryB = bound.factory({
338
+ peerId: "bob",
339
+ binding: bound.identityBinding,
340
+ })
292
341
 
293
342
  const subA = factoryA.create(TestSchema)
294
343
  const subB = factoryB.create(TestSchema)
@@ -304,8 +353,14 @@ describe("structural merge protocol (Yjs)", () => {
304
353
  it("yjs.bind peers merge without structural conflict", () => {
305
354
  const bound = yjs.bind(TestSchema)
306
355
 
307
- const factoryA = bound.factory({ peerId: "alice" })
308
- const factoryB = bound.factory({ peerId: "bob" })
356
+ const factoryA = bound.factory({
357
+ peerId: "alice",
358
+ binding: bound.identityBinding,
359
+ })
360
+ const factoryB = bound.factory({
361
+ peerId: "bob",
362
+ binding: bound.identityBinding,
363
+ })
309
364
 
310
365
  const subA = factoryA.create(TestSchema)
311
366
  const subB = factoryB.create(TestSchema)
@@ -314,15 +369,15 @@ describe("structural merge protocol (Yjs)", () => {
314
369
  const docA = (subA as any)[BACKING_DOC] as Y.Doc
315
370
  docA.transact(() => {
316
371
  const root = docA.getMap("root")
317
- ;(root.get("title") as Y.Text).insert(0, "Alice's text")
318
- root.set("count", 10)
372
+ ;(root.get(id("title")) as Y.Text).insert(0, "Alice's text")
373
+ root.set(id("count"), 10)
319
374
  })
320
375
 
321
376
  const docB = (subB as any)[BACKING_DOC] as Y.Doc
322
377
  docB.transact(() => {
323
378
  const root = docB.getMap("root")
324
- ;(root.get("title") as Y.Text).insert(0, "Bob's text")
325
- root.set("count", 20)
379
+ ;(root.get(id("title")) as Y.Text).insert(0, "Bob's text")
380
+ root.set(id("count"), 20)
326
381
  })
327
382
 
328
383
  // Bidirectional merge — should not throw
@@ -335,26 +390,28 @@ describe("structural merge protocol (Yjs)", () => {
335
390
  const rootA = docA.getMap("root")
336
391
  const rootB = docB.getMap("root")
337
392
 
338
- const titleA = (rootA.get("title") as Y.Text).toString()
339
- const titleB = (rootB.get("title") as Y.Text).toString()
393
+ const titleA = (rootA.get(id("title")) as Y.Text).toString()
394
+ const titleB = (rootB.get(id("title")) as Y.Text).toString()
340
395
  expect(titleA).toBe(titleB)
341
396
  expect(titleA).toContain("Alice's text")
342
397
  expect(titleA).toContain("Bob's text")
343
398
 
344
- expect(rootA.get("count")).toBe(rootB.get("count"))
399
+ expect(rootA.get(id("count"))).toBe(rootB.get(id("count")))
345
400
  })
346
401
 
347
402
  // ── Conditional ensureContainers is idempotent ──
348
403
 
349
404
  it("conditional ensureContainers is idempotent on hydrated doc", () => {
405
+ const binding = trivialBinding(TestSchema)
406
+
350
407
  const doc = new Y.Doc()
351
408
  doc.clientID = 50
352
- ensureContainers(doc, TestSchema)
409
+ ensureContainers(doc, TestSchema, false, binding)
353
410
 
354
411
  const stateBefore = Y.encodeStateAsUpdate(doc)
355
412
 
356
413
  // Conditional call should not create new ops (everything already exists)
357
- ensureContainers(doc, TestSchema, true)
414
+ ensureContainers(doc, TestSchema, true, binding)
358
415
 
359
416
  const stateAfter = Y.encodeStateAsUpdate(doc)
360
417
  expect(stateAfter).toEqual(stateBefore)
@@ -182,6 +182,24 @@ describe("YjsSubstrate", () => {
182
182
  const parsed = YjsVersion.parse(serialized)
183
183
  expect(parsed.compare(v)).toBe("equal")
184
184
  })
185
+
186
+ it("version changes after a delete-only mutation", () => {
187
+ const doc = createDoc(yjs.bind(SimpleSchema))
188
+ change(doc, (d: any) => {
189
+ d.title.insert(0, "hello")
190
+ })
191
+ const vAfterInsert = version(doc)
192
+
193
+ change(doc, (d: any) => {
194
+ d.title.delete(1, 1)
195
+ })
196
+ const vAfterDelete = version(doc)
197
+
198
+ // The version must change after a delete — even though Yjs's state
199
+ // vector does not advance on delete. The snapshot-based YjsVersion
200
+ // detects the delete set change and returns "concurrent" (not "equal").
201
+ expect(vAfterInsert.compare(vAfterDelete)).not.toBe("equal")
202
+ })
185
203
  })
186
204
 
187
205
  // -------------------------------------------------------------------------
@@ -1,3 +1,4 @@
1
+ import { uint8ArrayToBase64 } from "@kyneta/schema"
1
2
  import { describe, expect, it } from "vitest"
2
3
  import * as Y from "yjs"
3
4
  import { YjsVersion } from "../version.js"
@@ -195,6 +196,92 @@ describe("YjsVersion", () => {
195
196
  })
196
197
  })
197
198
 
199
+ // ===========================================================================
200
+ // Snapshot-aware comparison (delete detection)
201
+ // ===========================================================================
202
+
203
+ describe("YjsVersion: snapshot-aware comparison (delete detection)", () => {
204
+ it("compare returns 'concurrent' when SVs match but delete sets differ", () => {
205
+ const doc = new Y.Doc()
206
+ doc.getText("body").insert(0, "hello")
207
+ const sv1 = Y.encodeStateVector(doc)
208
+ const snap1 = Y.encodeSnapshot(Y.snapshot(doc))
209
+ const v1 = new YjsVersion(sv1, snap1)
210
+
211
+ doc.getText("body").delete(1, 1)
212
+ const sv2 = Y.encodeStateVector(doc)
213
+ const snap2 = Y.encodeSnapshot(Y.snapshot(doc))
214
+ const v2 = new YjsVersion(sv2, snap2)
215
+
216
+ // SVs are identical (Yjs doesn't advance SV on delete)
217
+ expect(v1.compare(v2)).toBe("concurrent")
218
+ expect(v2.compare(v1)).toBe("concurrent")
219
+ })
220
+
221
+ it("compare returns 'equal' when both SV and delete set match", () => {
222
+ const doc = new Y.Doc()
223
+ doc.getText("body").insert(0, "hello")
224
+ doc.getText("body").delete(1, 1)
225
+ const sv = Y.encodeStateVector(doc)
226
+ const snap = Y.encodeSnapshot(Y.snapshot(doc))
227
+
228
+ const v1 = new YjsVersion(sv, snap)
229
+ const v2 = new YjsVersion(sv, snap)
230
+ expect(v1.compare(v2)).toBe("equal")
231
+ })
232
+
233
+ it("serialize/parse round-trips snapshot bytes", () => {
234
+ const doc = new Y.Doc()
235
+ doc.getText("body").insert(0, "hello")
236
+ doc.getText("body").delete(1, 1)
237
+ const sv = Y.encodeStateVector(doc)
238
+ const snap = Y.encodeSnapshot(Y.snapshot(doc))
239
+ const v = new YjsVersion(sv, snap)
240
+
241
+ const parsed = YjsVersion.parse(v.serialize())
242
+ expect(parsed.compare(v)).toBe("equal")
243
+ // Snapshot bytes preserved through round-trip
244
+ expect(parsed.snapshotBytes.length).toBe(snap.length)
245
+ })
246
+
247
+ it("SV-ahead still returns 'ahead' even with different snapshots", () => {
248
+ const doc = new Y.Doc()
249
+ doc.getText("body").insert(0, "hello")
250
+ const sv1 = Y.encodeStateVector(doc)
251
+ const snap1 = Y.encodeSnapshot(Y.snapshot(doc))
252
+ const v1 = new YjsVersion(sv1, snap1)
253
+
254
+ doc.getText("body").insert(5, " world")
255
+ doc.getText("body").delete(0, 1)
256
+ const sv2 = Y.encodeStateVector(doc)
257
+ const snap2 = Y.encodeSnapshot(Y.snapshot(doc))
258
+ const v2 = new YjsVersion(sv2, snap2)
259
+
260
+ // v2 has more inserts (SV advanced), snapshot check is moot
261
+ expect(v2.compare(v1)).toBe("ahead")
262
+ expect(v1.compare(v2)).toBe("behind")
263
+ })
264
+
265
+ it("legacy parse (no dot) treats SV bytes as snapshot", () => {
266
+ // Legacy serialized format: just base64(sv), no "."
267
+ const doc = new Y.Doc()
268
+ doc.getText("body").insert(0, "hello")
269
+ const sv = Y.encodeStateVector(doc)
270
+
271
+ const legacy = new YjsVersion(sv) // no snapshot arg
272
+ const serialized = legacy.serialize()
273
+ // Should have a "." separator in new format
274
+ expect(serialized).toContain(".")
275
+
276
+ // But parsing old-format strings without "." should work
277
+ const oldFormat = uint8ArrayToBase64(sv) // no dot
278
+ const parsed = YjsVersion.parse(oldFormat)
279
+ // snapshotBytes defaults to sv bytes
280
+ expect(parsed.sv.length).toBe(sv.length)
281
+ expect(parsed.snapshotBytes.length).toBe(sv.length)
282
+ })
283
+ })
284
+
198
285
  // -------------------------------------------------------------------------
199
286
  // compare after round-trip
200
287
  // -------------------------------------------------------------------------
package/src/bind-yjs.ts CHANGED
@@ -1,8 +1,10 @@
1
- // bind-yjs — Yjs CRDT substrate namespace and factory.
1
+ // bind-yjs — Yjs CRDT binding target and factory internals.
2
2
  //
3
- // Provides the `yjs` substrate namespace (`yjs.bind()`, `yjs.replica()`)
4
- // and the internal factory builder that injects a deterministic numeric
5
- // Yjs clientID derived from the exchange's peerId.
3
+ // The `yjs` binding target provides `yjs.bind()` and `yjs.replica()` for
4
+ // binding schemas to the Yjs substrate with collaborative sync protocol.
5
+ // The factory builder accepts { peerId } and returns a SubstrateFactory
6
+ // that calls doc.clientID = hashPeerId(peerId) on every new Y.Doc,
7
+ // ensuring deterministic peer identity across all documents in an exchange.
6
8
  //
7
9
  // Yjs clientID is a uint32 number. We use FNV-1a hash truncated to
8
10
  // 32 bits, mirroring the Loro binding's hashPeerId pattern but
@@ -19,18 +21,19 @@
19
21
  // const doc = exchange.get("my-doc", TodoDoc)
20
22
 
21
23
  import type {
22
- CrdtStrategy,
24
+ BindingTarget,
23
25
  Replica,
26
+ SchemaBinding,
24
27
  Schema as SchemaNode,
25
28
  Substrate,
26
29
  SubstrateFactory,
27
- SubstrateNamespace,
28
30
  SubstratePayload,
29
31
  } from "@kyneta/schema"
30
32
  import {
31
33
  BACKING_DOC,
32
- createSubstrateNamespace,
34
+ createBindingTarget,
33
35
  STRUCTURAL_YJS_CLIENT_ID,
36
+ SYNC_COLLABORATIVE,
34
37
  } from "@kyneta/schema"
35
38
  import * as Y from "yjs"
36
39
  import type { YjsNativeMap } from "./native-map.js"
@@ -79,7 +82,10 @@ function hashPeerId(peerId: string): number {
79
82
  * on every new Y.Doc with a deterministic uint32 clientID derived
80
83
  * from the exchange's string peerId.
81
84
  */
82
- function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
85
+ function createYjsFactory(
86
+ peerId: string,
87
+ binding: SchemaBinding,
88
+ ): SubstrateFactory<YjsVersion> {
83
89
  const numericClientId = hashPeerId(peerId)
84
90
 
85
91
  return {
@@ -101,16 +107,16 @@ function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
101
107
  doc.clientID = numericClientId
102
108
  // Conditional ensureContainers: skip fields that already exist
103
109
  // from hydrated state (each set() is a CRDT write).
104
- ensureContainers(doc, schema, true)
105
- return createYjsSubstrate(doc, schema)
110
+ ensureContainers(doc, schema, true, binding)
111
+ return createYjsSubstrate(doc, schema, binding)
106
112
  },
107
113
 
108
114
  create(schema: SchemaNode): Substrate<YjsVersion> {
109
115
  // Fresh doc — set identity immediately, unconditional containers.
110
116
  const doc = new Y.Doc()
111
117
  doc.clientID = numericClientId
112
- ensureContainers(doc, schema)
113
- return createYjsSubstrate(doc, schema)
118
+ ensureContainers(doc, schema, false, binding)
119
+ return createYjsSubstrate(doc, schema, binding)
114
120
  },
115
121
 
116
122
  fromEntirety(
@@ -132,36 +138,37 @@ function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
132
138
  }
133
139
 
134
140
  // ---------------------------------------------------------------------------
135
- // yjs — the Yjs CRDT substrate namespace
141
+ // yjs — the Yjs CRDT binding target
136
142
  // ---------------------------------------------------------------------------
137
143
 
138
144
  /**
139
- * The Yjs CRDT substrate namespace.
145
+ * Yjs composition-law tags — the set of concurrent composition laws
146
+ * that the Yjs substrate faithfully implements.
147
+ */
148
+ export type YjsLaws =
149
+ | "lww"
150
+ | "positional-ot"
151
+ | "lww-per-key"
152
+ | "lww-tag-replaced"
153
+
154
+ /**
155
+ * The Yjs CRDT binding target.
140
156
  *
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
157
+ * - `yjs.bind(schema)` — bind a schema to Yjs with collaborative sync
158
+ * - `yjs.replica()` — create a collaborative replica
145
159
  *
146
- * Strategy is constrained to `CrdtStrategy` (`"collaborative" | "ephemeral"`).
147
- * Passing `"authoritative"` is a compile error.
160
+ * Laws are constrained to `YjsLaws` schemas requiring composition laws
161
+ * outside this set (e.g. `"additive"` from `Schema.counter()`,
162
+ * `"positional-ot-move"` from `Schema.movableList()`) are rejected at
163
+ * compile time.
148
164
  *
149
165
  * To access the underlying Y.Doc, use `unwrap(ref)` from `@kyneta/schema`.
150
166
  */
151
- /** The closed set of capability tags that the Yjs substrate supports. */
152
- export type YjsCaps = "text" | "json"
153
-
154
- export const yjs: SubstrateNamespace<CrdtStrategy, YjsCaps, YjsNativeMap> =
155
- createSubstrateNamespace<CrdtStrategy, YjsCaps, YjsNativeMap>({
156
- strategies: {
157
- collaborative: {
158
- factory: ctx => createYjsFactory(ctx.peerId),
159
- replicaFactory: yjsReplicaFactory,
160
- },
161
- ephemeral: {
162
- factory: ctx => createYjsFactory(ctx.peerId),
163
- replicaFactory: yjsReplicaFactory,
164
- },
165
- },
166
- defaultStrategy: "collaborative",
167
- })
167
+ export const yjs: BindingTarget<YjsLaws, YjsNativeMap> = createBindingTarget<
168
+ YjsLaws,
169
+ YjsNativeMap
170
+ >({
171
+ factory: ctx => createYjsFactory(ctx.peerId, ctx.binding),
172
+ replicaFactory: yjsReplicaFactory,
173
+ syncProtocol: SYNC_COLLABORATIVE,
174
+ })