@kyneta/yjs-schema 1.0.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,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() }))
@@ -18,32 +18,31 @@
18
18
 
19
19
  import { describe, expect, it } from "vitest"
20
20
  import {
21
+ change,
21
22
  createYjsDoc,
22
- createYjsDocFromSnapshot,
23
- version,
24
- exportSnapshot,
23
+ createYjsDocFromEntirety,
24
+ exportEntirety,
25
25
  exportSince,
26
- importDelta,
27
- change,
28
- subscribe,
29
- text,
26
+ merge,
30
27
  Schema,
28
+ subscribe,
29
+ version,
31
30
  } from "../index.js"
32
31
 
33
32
  // ===========================================================================
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
  })
@@ -114,8 +113,8 @@ describe("record-of-struct (plain baseline)", () => {
114
113
  d.profiles.set("alice", { displayName: "Alice", age: 30 })
115
114
  })
116
115
 
117
- const snapshot = exportSnapshot(docA)
118
- const docB = createYjsDocFromSnapshot(PlainRecordSchema, snapshot)
116
+ const snapshot = exportEntirety(docA)
117
+ const docB = createYjsDocFromEntirety(PlainRecordSchema, snapshot)
119
118
 
120
119
  expect(docB.profiles()).toEqual({
121
120
  alice: { displayName: "Alice", age: 30 },
@@ -130,7 +129,10 @@ describe("record-of-struct (plain baseline)", () => {
130
129
  })
131
130
 
132
131
  // Establish docB from snapshot (avoids Yjs clientID collision)
133
- const docB = createYjsDocFromSnapshot(PlainRecordSchema, exportSnapshot(docA))
132
+ const docB = createYjsDocFromEntirety(
133
+ PlainRecordSchema,
134
+ exportEntirety(docA),
135
+ )
134
136
 
135
137
  const v0 = version(docB)
136
138
 
@@ -140,7 +142,7 @@ describe("record-of-struct (plain baseline)", () => {
140
142
 
141
143
  const delta = exportSince(docA, v0)
142
144
  expect(delta).not.toBeNull()
143
- importDelta(docB, delta!, "sync")
145
+ merge(docB, delta!, "sync")
144
146
 
145
147
  expect(docB.profiles()).toEqual({
146
148
  alice: { displayName: "Alice", age: 30 },
@@ -246,7 +248,9 @@ describe("text-inside-struct-inside-record", () => {
246
248
  })
247
249
 
248
250
  let fired = false
249
- subscribe(doc, () => { fired = true })
251
+ subscribe(doc, () => {
252
+ fired = true
253
+ })
250
254
 
251
255
  change(doc, (d: any) => {
252
256
  d.profiles.at("alice").bio.insert(0, "Hello")
@@ -265,8 +269,8 @@ describe("text-inside-struct-inside-record", () => {
265
269
  d.profiles.at("alice").bio.insert(0, "Collaborative bio")
266
270
  })
267
271
 
268
- const snapshot = exportSnapshot(docA)
269
- const docB = createYjsDocFromSnapshot(ProfileSchema, snapshot)
272
+ const snapshot = exportEntirety(docA)
273
+ const docB = createYjsDocFromEntirety(ProfileSchema, snapshot)
270
274
 
271
275
  expect(docB.profiles()).toEqual({
272
276
  alice: { displayName: "Alice", bio: "Collaborative bio" },
@@ -286,7 +290,7 @@ describe("text-inside-struct-inside-record", () => {
286
290
  // Establish docB from snapshot (consistent with Yjs test patterns —
287
291
  // two independently-created Y.Docs may share a clientID, causing
288
292
  // silent update drops)
289
- const docB = createYjsDocFromSnapshot(ProfileSchema, exportSnapshot(docA))
293
+ const docB = createYjsDocFromEntirety(ProfileSchema, exportEntirety(docA))
290
294
  const v0 = version(docB)
291
295
 
292
296
  change(docA, (d: any) => {
@@ -295,7 +299,7 @@ describe("text-inside-struct-inside-record", () => {
295
299
 
296
300
  const delta = exportSince(docA, v0)
297
301
  expect(delta).not.toBeNull()
298
- importDelta(docB, delta!, "sync")
302
+ merge(docB, delta!, "sync")
299
303
 
300
304
  expect(docB.profiles()).toEqual({
301
305
  alice: { displayName: "Alice", bio: "Hello from A" },
@@ -315,7 +319,7 @@ describe("text-inside-struct-inside-record", () => {
315
319
  change(docA, (d: any) => {
316
320
  d.profiles.set("alice", { displayName: "Alice" })
317
321
  })
318
- const docB = createYjsDocFromSnapshot(ProfileSchema, exportSnapshot(docA))
322
+ const docB = createYjsDocFromEntirety(ProfileSchema, exportEntirety(docA))
319
323
 
320
324
  // Both peers edit concurrently
321
325
  const vA = version(docA)
@@ -333,8 +337,8 @@ describe("text-inside-struct-inside-record", () => {
333
337
  const deltaBA = exportSince(docB, vA)
334
338
  expect(deltaAB).not.toBeNull()
335
339
  expect(deltaBA).not.toBeNull()
336
- importDelta(docB, deltaAB!, "sync")
337
- importDelta(docA, deltaBA!, "sync")
340
+ merge(docB, deltaAB!, "sync")
341
+ merge(docA, deltaBA!, "sync")
338
342
 
339
343
  // Both converge to the same value (order depends on client IDs)
340
344
  expect((docA as any).profiles.at("alice").bio()).toBe(
@@ -411,7 +415,10 @@ describe("text-inside-struct-inside-list", () => {
411
415
  })
412
416
 
413
417
  // Establish docB from snapshot (avoids Yjs clientID collision)
414
- const docB = createYjsDocFromSnapshot(ListProfileSchema, exportSnapshot(docA))
418
+ const docB = createYjsDocFromEntirety(
419
+ ListProfileSchema,
420
+ exportEntirety(docA),
421
+ )
415
422
  const v0 = version(docB)
416
423
 
417
424
  change(docA, (d: any) => {
@@ -420,10 +427,10 @@ describe("text-inside-struct-inside-list", () => {
420
427
 
421
428
  const delta = exportSince(docA, v0)
422
429
  expect(delta).not.toBeNull()
423
- importDelta(docB, delta!, "sync")
430
+ merge(docB, delta!, "sync")
424
431
 
425
432
  expect(docB.players.length).toBe(1)
426
433
  expect((docB as any).players.at(0).name()).toBe("Alice")
427
434
  expect((docB as any).players.at(0).bio()).toBe("Synced bio")
428
435
  })
429
- })
436
+ })
@@ -0,0 +1,362 @@
1
+ // structural-merge — Yjs structural merge protocol tests.
2
+ //
3
+ // Validates the core invariant: all peers using the same schema produce
4
+ // byte-identical structural ops via STRUCTURAL_YJS_CLIENT_ID (0),
5
+ // which Yjs deduplicates on merge instead of conflicting.
6
+ //
7
+ // Context: jj:ptyzqoul (structural merge protocol)
8
+
9
+ import { BACKING_DOC, Schema, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
10
+ import { describe, expect, it } from "vitest"
11
+ import * as Y from "yjs"
12
+ import { yjs } from "../bind-yjs.js"
13
+ import { ensureContainers } from "../populate.js"
14
+ import { yjsSubstrateFactory } from "../substrate.js"
15
+
16
+ // ===========================================================================
17
+ // Schemas used across tests
18
+ // ===========================================================================
19
+
20
+ const TestSchema = Schema.struct({
21
+ title: Schema.text(),
22
+ count: Schema.number(),
23
+ items: Schema.list(Schema.string()),
24
+ })
25
+
26
+ // ===========================================================================
27
+ // Tests
28
+ // ===========================================================================
29
+
30
+ describe("structural merge protocol (Yjs)", () => {
31
+ // ── Core invariant: two peers independently create, sync, no data loss ──
32
+
33
+ it("two peers independently create same schema, sync, data preserved", () => {
34
+ // Peer A creates doc and writes text
35
+ const docA = new Y.Doc()
36
+ docA.clientID = 100
37
+ ensureContainers(docA, TestSchema)
38
+ docA.transact(() => {
39
+ const root = docA.getMap("root")
40
+ ;(root.get("title") as Y.Text).insert(0, "Hello from A")
41
+ root.set("count", 42)
42
+ })
43
+
44
+ // Peer B independently creates same doc and writes different text
45
+ const docB = new Y.Doc()
46
+ docB.clientID = 200
47
+ ensureContainers(docB, TestSchema)
48
+ docB.transact(() => {
49
+ const root = docB.getMap("root")
50
+ ;(root.get("title") as Y.Text).insert(0, "Hello from B")
51
+ root.set("count", 99)
52
+ })
53
+
54
+ // Sync bidirectionally
55
+ const updateA = Y.encodeStateAsUpdate(docA)
56
+ const updateB = Y.encodeStateAsUpdate(docB)
57
+ Y.applyUpdate(docB, updateA)
58
+ Y.applyUpdate(docA, updateB)
59
+
60
+ // Both see the same state — no data loss
61
+ const rootA = docA.getMap("root")
62
+ const rootB = docB.getMap("root")
63
+
64
+ // 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()
67
+ expect(titleA).toBe(titleB)
68
+ // Both texts should be present (merged, not lost)
69
+ expect(titleA).toContain("Hello from A")
70
+ expect(titleA).toContain("Hello from B")
71
+
72
+ // Count: last writer wins — both converge to the same value
73
+ expect(rootA.get("count")).toBe(rootB.get("count"))
74
+ })
75
+
76
+ it("three peers independently create, sync, all converge", () => {
77
+ const docs = [100, 200, 300].map(id => {
78
+ const doc = new Y.Doc()
79
+ doc.clientID = id
80
+ ensureContainers(doc, TestSchema)
81
+ doc.transact(() => {
82
+ const root = doc.getMap("root")
83
+ ;(root.get("title") as Y.Text).insert(0, `Peer${id}`)
84
+ })
85
+ return doc
86
+ })
87
+
88
+ // Full mesh sync
89
+ for (let i = 0; i < docs.length; i++) {
90
+ for (let j = 0; j < docs.length; j++) {
91
+ if (i !== j) {
92
+ Y.applyUpdate(docs[j]!, Y.encodeStateAsUpdate(docs[i]!))
93
+ }
94
+ }
95
+ }
96
+
97
+ // All three converge to the same text
98
+ const texts = docs.map(d =>
99
+ (d.getMap("root").get("title") as Y.Text).toString(),
100
+ )
101
+ expect(texts[0]).toBe(texts[1])
102
+ expect(texts[1]).toBe(texts[2])
103
+ // All three peer contributions present
104
+ expect(texts[0]).toContain("Peer100")
105
+ expect(texts[0]).toContain("Peer200")
106
+ expect(texts[0]).toContain("Peer300")
107
+ })
108
+
109
+ // ── Persistence round-trip ──
110
+
111
+ it("persist → hydrate → data preserved", () => {
112
+ // Create and write
113
+ const doc1 = new Y.Doc()
114
+ doc1.clientID = 42
115
+ ensureContainers(doc1, TestSchema)
116
+ doc1.transact(() => {
117
+ const root = doc1.getMap("root")
118
+ ;(root.get("title") as Y.Text).insert(0, "Persistent")
119
+ root.set("count", 7)
120
+ })
121
+
122
+ // Export
123
+ const snapshot = Y.encodeStateAsUpdate(doc1)
124
+
125
+ // Hydrate into fresh doc
126
+ const doc2 = new Y.Doc()
127
+ doc2.clientID = 42
128
+ Y.applyUpdate(doc2, snapshot)
129
+ ensureContainers(doc2, TestSchema, true) // conditional
130
+
131
+ // Data preserved
132
+ const root2 = doc2.getMap("root")
133
+ expect((root2.get("title") as Y.Text).toString()).toBe("Persistent")
134
+ expect(root2.get("count")).toBe(7)
135
+ })
136
+
137
+ // ── Determinism: alphabetical sort ──
138
+
139
+ it("field reordering in source doesn't affect structural ops", () => {
140
+ // Schema A: fields in one order
141
+ const schemaA = Schema.struct({
142
+ alpha: Schema.string(),
143
+ beta: Schema.number(),
144
+ gamma: Schema.text(),
145
+ })
146
+
147
+ // Schema B: same fields, different insertion order
148
+ // JavaScript objects preserve insertion order, so we construct
149
+ // with a different order to verify alphabetical sort overrides it.
150
+ const fields: Record<string, any> = {}
151
+ fields.gamma = Schema.text()
152
+ fields.alpha = Schema.string()
153
+ fields.beta = Schema.number()
154
+ const schemaB = Schema.struct(fields)
155
+
156
+ const docA = new Y.Doc()
157
+ ensureContainers(docA, schemaA)
158
+
159
+ const docB = new Y.Doc()
160
+ ensureContainers(docB, schemaB)
161
+
162
+ // Both should produce byte-identical structural state
163
+ const stateA = Y.encodeStateAsUpdate(docA)
164
+ const stateB = Y.encodeStateAsUpdate(docB)
165
+ expect(stateA).toEqual(stateB)
166
+ })
167
+
168
+ // ── Schema evolution ──
169
+
170
+ it("add field after hydration, structural ops extend correctly", () => {
171
+ const v1Schema = Schema.struct({
172
+ title: Schema.text(),
173
+ count: Schema.number(),
174
+ })
175
+
176
+ const v2Schema = Schema.struct({
177
+ count: Schema.number(),
178
+ notes: Schema.text(), // new field
179
+ title: Schema.text(),
180
+ })
181
+
182
+ // Peer A: create v1, write data, export
183
+ const docA = new Y.Doc()
184
+ docA.clientID = 100
185
+ ensureContainers(docA, v1Schema)
186
+ docA.transact(() => {
187
+ const root = docA.getMap("root")
188
+ ;(root.get("title") as Y.Text).insert(0, "Title")
189
+ root.set("count", 5)
190
+ })
191
+ const v1State = Y.encodeStateAsUpdate(docA)
192
+
193
+ // Peer B: independently create v2, hydrate v1 data, conditional containers
194
+ const docB = new Y.Doc()
195
+ docB.clientID = 200
196
+ Y.applyUpdate(docB, v1State)
197
+ ensureContainers(docB, v2Schema, true) // conditional — only creates "notes"
198
+
199
+ // Another peer C: same thing independently
200
+ const docC = new Y.Doc()
201
+ docC.clientID = 300
202
+ Y.applyUpdate(docC, v1State)
203
+ ensureContainers(docC, v2Schema, true)
204
+
205
+ // B and C's structural ops for "notes" should be identical (both at clientID 0)
206
+ const stateB = Y.encodeStateAsUpdate(docB)
207
+ const stateC = Y.encodeStateAsUpdate(docC)
208
+
209
+ // Merge B into C and C into B — should converge without conflict
210
+ Y.applyUpdate(docC, stateB)
211
+ Y.applyUpdate(docB, stateC)
212
+
213
+ const rootB = docB.getMap("root")
214
+ const rootC = docC.getMap("root")
215
+
216
+ // 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)
221
+
222
+ // New field exists on both
223
+ expect(rootB.get("notes")).toBeInstanceOf(Y.Text)
224
+ expect(rootC.get("notes")).toBeInstanceOf(Y.Text)
225
+ })
226
+
227
+ // ── Structural identity is clientID 0 ──
228
+
229
+ it("ensureContainers uses clientID 0 for structural ops", () => {
230
+ const doc = new Y.Doc()
231
+ doc.clientID = 999
232
+ ensureContainers(doc, TestSchema)
233
+
234
+ // clientID should be restored
235
+ expect(doc.clientID).toBe(999)
236
+
237
+ // Structural ops should be at clientID 0
238
+ const sv = Y.decodeStateVector(Y.encodeStateVector(doc))
239
+ expect(sv.get(STRUCTURAL_YJS_CLIENT_ID)).toBeGreaterThan(0)
240
+ })
241
+
242
+ it("ensureContainers does not leak caller clientID into structural ops", () => {
243
+ const doc = new Y.Doc()
244
+ doc.clientID = 777
245
+ ensureContainers(doc, TestSchema)
246
+
247
+ // The state vector should NOT contain the caller's clientID —
248
+ // only STRUCTURAL_YJS_CLIENT_ID (0) should have produced ops.
249
+ const sv = Y.decodeStateVector(Y.encodeStateVector(doc))
250
+ expect(sv.has(STRUCTURAL_YJS_CLIENT_ID)).toBe(true)
251
+ expect(sv.has(777)).toBe(false)
252
+ })
253
+
254
+ // ── SubstrateFactory integration ──
255
+
256
+ it("yjsSubstrateFactory.create produces deterministic structural ops", () => {
257
+ const sub1 = yjsSubstrateFactory.create(TestSchema)
258
+ const sub2 = yjsSubstrateFactory.create(TestSchema)
259
+
260
+ const state1 = sub1.exportEntirety()
261
+ const state2 = sub2.exportEntirety()
262
+
263
+ // Byte-identical structural state
264
+ expect(state1.data).toEqual(state2.data)
265
+ })
266
+
267
+ it("yjsSubstrateFactory.fromEntirety preserves data through round-trip", () => {
268
+ const sub1 = yjsSubstrateFactory.create(TestSchema)
269
+ const doc1 = (sub1 as any)[BACKING_DOC] as Y.Doc
270
+ doc1.transact(() => {
271
+ const root = doc1.getMap("root")
272
+ ;(root.get("title") as Y.Text).insert(0, "Round-trip")
273
+ root.set("count", 123)
274
+ })
275
+
276
+ const payload = sub1.exportEntirety()
277
+ const sub2 = yjsSubstrateFactory.fromEntirety(payload, TestSchema)
278
+ const doc2 = (sub2 as any)[BACKING_DOC] as Y.Doc
279
+ const root2 = doc2.getMap("root")
280
+
281
+ expect((root2.get("title") as Y.Text).toString()).toBe("Round-trip")
282
+ expect(root2.get("count")).toBe(123)
283
+ })
284
+
285
+ // ── yjs.bind integration ──
286
+
287
+ it("yjs.bind factory produces deterministic structural ops across peers", () => {
288
+ const bound = yjs.bind(TestSchema)
289
+
290
+ const factoryA = bound.factory({ peerId: "alice" })
291
+ const factoryB = bound.factory({ peerId: "bob" })
292
+
293
+ const subA = factoryA.create(TestSchema)
294
+ const subB = factoryB.create(TestSchema)
295
+
296
+ // Different peerIds but same structural ops
297
+ const stateA = subA.exportEntirety()
298
+ const stateB = subB.exportEntirety()
299
+
300
+ // Structural state is byte-identical (same schema → same containers at clientID 0)
301
+ expect(stateA.data).toEqual(stateB.data)
302
+ })
303
+
304
+ it("yjs.bind peers merge without structural conflict", () => {
305
+ const bound = yjs.bind(TestSchema)
306
+
307
+ const factoryA = bound.factory({ peerId: "alice" })
308
+ const factoryB = bound.factory({ peerId: "bob" })
309
+
310
+ const subA = factoryA.create(TestSchema)
311
+ const subB = factoryB.create(TestSchema)
312
+
313
+ // Write different data on each peer
314
+ const docA = (subA as any)[BACKING_DOC] as Y.Doc
315
+ docA.transact(() => {
316
+ const root = docA.getMap("root")
317
+ ;(root.get("title") as Y.Text).insert(0, "Alice's text")
318
+ root.set("count", 10)
319
+ })
320
+
321
+ const docB = (subB as any)[BACKING_DOC] as Y.Doc
322
+ docB.transact(() => {
323
+ const root = docB.getMap("root")
324
+ ;(root.get("title") as Y.Text).insert(0, "Bob's text")
325
+ root.set("count", 20)
326
+ })
327
+
328
+ // Bidirectional merge — should not throw
329
+ const payloadA = subA.exportEntirety()
330
+ const payloadB = subB.exportEntirety()
331
+ subA.merge(payloadB, "sync")
332
+ subB.merge(payloadA, "sync")
333
+
334
+ // Both converge
335
+ const rootA = docA.getMap("root")
336
+ const rootB = docB.getMap("root")
337
+
338
+ const titleA = (rootA.get("title") as Y.Text).toString()
339
+ const titleB = (rootB.get("title") as Y.Text).toString()
340
+ expect(titleA).toBe(titleB)
341
+ expect(titleA).toContain("Alice's text")
342
+ expect(titleA).toContain("Bob's text")
343
+
344
+ expect(rootA.get("count")).toBe(rootB.get("count"))
345
+ })
346
+
347
+ // ── Conditional ensureContainers is idempotent ──
348
+
349
+ it("conditional ensureContainers is idempotent on hydrated doc", () => {
350
+ const doc = new Y.Doc()
351
+ doc.clientID = 50
352
+ ensureContainers(doc, TestSchema)
353
+
354
+ const stateBefore = Y.encodeStateAsUpdate(doc)
355
+
356
+ // Conditional call should not create new ops (everything already exists)
357
+ ensureContainers(doc, TestSchema, true)
358
+
359
+ const stateAfter = Y.encodeStateAsUpdate(doc)
360
+ expect(stateAfter).toEqual(stateBefore)
361
+ })
362
+ })