@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.
- package/dist/index.d.ts +109 -147
- package/dist/index.js +321 -210
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/__tests__/bind-constraints.test.ts +333 -0
- package/src/__tests__/bind-yjs.test.ts +53 -55
- package/src/__tests__/create.test.ts +71 -62
- package/src/__tests__/{store-reader.test.ts → reader.test.ts} +64 -90
- package/src/__tests__/record-text-spike.test.ts +38 -31
- package/src/__tests__/structural-merge.test.ts +362 -0
- package/src/__tests__/substrate.test.ts +65 -84
- package/src/__tests__/version.test.ts +82 -16
- package/src/bind-yjs.ts +115 -64
- package/src/change-mapping.ts +60 -84
- package/src/create.ts +33 -28
- package/src/index.ts +32 -51
- package/src/populate.ts +87 -92
- package/src/{store-reader.ts → reader.ts} +7 -12
- package/src/substrate.ts +186 -42
- package/src/sync.ts +26 -26
- package/src/version.ts +57 -4
- package/src/yjs-resolve.ts +5 -21
- package/src/yjs-escape.ts +0 -100
|
@@ -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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
exportSnapshot,
|
|
23
|
+
createYjsDocFromEntirety,
|
|
24
|
+
exportEntirety,
|
|
25
25
|
exportSince,
|
|
26
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
118
|
-
const docB =
|
|
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 =
|
|
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
|
-
|
|
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, () => {
|
|
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 =
|
|
269
|
-
const docB =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
337
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
})
|