@kyneta/yjs-schema 1.2.0 → 1.3.1
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/README.md +3 -3
- package/dist/index.d.ts +82 -170
- package/dist/index.js +176 -260
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/bind-constraints.test.ts +5 -13
- package/src/__tests__/bind-yjs.test.ts +57 -46
- package/src/__tests__/create.test.ts +80 -56
- package/src/__tests__/reader.test.ts +3 -14
- package/src/__tests__/record-text-spike.test.ts +38 -36
- package/src/__tests__/substrate.test.ts +47 -40
- package/src/bind-yjs.ts +9 -40
- package/src/change-mapping.ts +7 -2
- package/src/index.ts +24 -26
- package/src/native-map.ts +37 -0
- package/src/populate.ts +1 -1
- package/src/substrate.ts +19 -4
- package/src/version.ts +14 -67
- package/src/yjs-resolve.ts +1 -1
- package/src/create.ts +0 -177
- package/src/sync.ts +0 -107
|
@@ -16,18 +16,18 @@
|
|
|
16
16
|
// 3. Reading back: doc.profiles() should return { [key]: { name, bio } }
|
|
17
17
|
// 4. Sync: two peers should converge after exchanging deltas
|
|
18
18
|
|
|
19
|
-
import { describe, expect, it } from "vitest"
|
|
20
19
|
import {
|
|
21
20
|
change,
|
|
22
|
-
|
|
23
|
-
createYjsDocFromEntirety,
|
|
21
|
+
createDoc,
|
|
24
22
|
exportEntirety,
|
|
25
23
|
exportSince,
|
|
26
24
|
merge,
|
|
27
25
|
Schema,
|
|
28
26
|
subscribe,
|
|
29
27
|
version,
|
|
30
|
-
} from "
|
|
28
|
+
} from "@kyneta/schema"
|
|
29
|
+
import { describe, expect, it } from "vitest"
|
|
30
|
+
import { yjs } from "../bind-yjs.js"
|
|
31
31
|
|
|
32
32
|
// ===========================================================================
|
|
33
33
|
// Schemas
|
|
@@ -60,13 +60,21 @@ const ListProfileSchema = Schema.struct({
|
|
|
60
60
|
),
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
// ===========================================================================
|
|
64
|
+
// Bound schemas
|
|
65
|
+
// ===========================================================================
|
|
66
|
+
|
|
67
|
+
const BoundProfile = yjs.bind(ProfileSchema)
|
|
68
|
+
const BoundPlainRecord = yjs.bind(PlainRecordSchema)
|
|
69
|
+
const BoundListProfile = yjs.bind(ListProfileSchema)
|
|
70
|
+
|
|
63
71
|
// ===========================================================================
|
|
64
72
|
// Baseline: record-of-struct (plain, no text)
|
|
65
73
|
// ===========================================================================
|
|
66
74
|
|
|
67
75
|
describe("record-of-struct (plain baseline)", () => {
|
|
68
76
|
it("set a record entry and read it back", () => {
|
|
69
|
-
const doc =
|
|
77
|
+
const doc = createDoc(BoundPlainRecord)
|
|
70
78
|
|
|
71
79
|
change(doc, (d: any) => {
|
|
72
80
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
@@ -79,7 +87,7 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
79
87
|
})
|
|
80
88
|
|
|
81
89
|
it("set multiple entries and read all back", () => {
|
|
82
|
-
const doc =
|
|
90
|
+
const doc = createDoc(BoundPlainRecord)
|
|
83
91
|
|
|
84
92
|
change(doc, (d: any) => {
|
|
85
93
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
@@ -94,7 +102,7 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
94
102
|
})
|
|
95
103
|
|
|
96
104
|
it("navigate into a record entry via .at()", () => {
|
|
97
|
-
const doc =
|
|
105
|
+
const doc = createDoc(BoundPlainRecord)
|
|
98
106
|
|
|
99
107
|
change(doc, (d: any) => {
|
|
100
108
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
@@ -107,14 +115,14 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
107
115
|
})
|
|
108
116
|
|
|
109
117
|
it("syncs record-of-struct between two peers via snapshot", () => {
|
|
110
|
-
const docA =
|
|
118
|
+
const docA = createDoc(BoundPlainRecord)
|
|
111
119
|
|
|
112
120
|
change(docA, (d: any) => {
|
|
113
121
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
114
122
|
})
|
|
115
123
|
|
|
116
124
|
const snapshot = exportEntirety(docA)
|
|
117
|
-
const docB =
|
|
125
|
+
const docB = createDoc(BoundPlainRecord, snapshot)
|
|
118
126
|
|
|
119
127
|
expect(docB.profiles()).toEqual({
|
|
120
128
|
alice: { displayName: "Alice", age: 30 },
|
|
@@ -122,17 +130,14 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
122
130
|
})
|
|
123
131
|
|
|
124
132
|
it("syncs record-of-struct between two peers via delta", () => {
|
|
125
|
-
const docA =
|
|
133
|
+
const docA = createDoc(BoundPlainRecord)
|
|
126
134
|
|
|
127
135
|
change(docA, (d: any) => {
|
|
128
136
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
129
137
|
})
|
|
130
138
|
|
|
131
139
|
// Establish docB from snapshot (avoids Yjs clientID collision)
|
|
132
|
-
const docB =
|
|
133
|
-
PlainRecordSchema,
|
|
134
|
-
exportEntirety(docA),
|
|
135
|
-
)
|
|
140
|
+
const docB = createDoc(BoundPlainRecord, exportEntirety(docA))
|
|
136
141
|
|
|
137
142
|
const v0 = version(docB)
|
|
138
143
|
|
|
@@ -157,7 +162,7 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
157
162
|
|
|
158
163
|
describe("text-inside-struct-inside-record", () => {
|
|
159
164
|
it("set a record entry with text field omitted and read it back", () => {
|
|
160
|
-
const doc =
|
|
165
|
+
const doc = createDoc(BoundProfile)
|
|
161
166
|
|
|
162
167
|
change(doc, (d: any) => {
|
|
163
168
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -170,7 +175,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
170
175
|
})
|
|
171
176
|
|
|
172
177
|
it("set a record entry with text field provided and read it back", () => {
|
|
173
|
-
const doc =
|
|
178
|
+
const doc = createDoc(BoundProfile)
|
|
174
179
|
|
|
175
180
|
change(doc, (d: any) => {
|
|
176
181
|
d.profiles.set("alice", { displayName: "Alice", bio: "Hello world" })
|
|
@@ -183,7 +188,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
183
188
|
})
|
|
184
189
|
|
|
185
190
|
it("navigate into a record entry and read the text", () => {
|
|
186
|
-
const doc =
|
|
191
|
+
const doc = createDoc(BoundProfile)
|
|
187
192
|
|
|
188
193
|
change(doc, (d: any) => {
|
|
189
194
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -196,7 +201,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
196
201
|
})
|
|
197
202
|
|
|
198
203
|
it("insert text into a text field inside a record entry (field omitted at creation)", () => {
|
|
199
|
-
const doc =
|
|
204
|
+
const doc = createDoc(BoundProfile)
|
|
200
205
|
|
|
201
206
|
change(doc, (d: any) => {
|
|
202
207
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -210,7 +215,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
210
215
|
})
|
|
211
216
|
|
|
212
217
|
it("insert text into a text field inside a record entry (field provided at creation)", () => {
|
|
213
|
-
const doc =
|
|
218
|
+
const doc = createDoc(BoundProfile)
|
|
214
219
|
|
|
215
220
|
change(doc, (d: any) => {
|
|
216
221
|
d.profiles.set("alice", { displayName: "Alice", bio: "Initial" })
|
|
@@ -224,7 +229,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
224
229
|
})
|
|
225
230
|
|
|
226
231
|
it("set multiple entries and edit text independently", () => {
|
|
227
|
-
const doc =
|
|
232
|
+
const doc = createDoc(BoundProfile)
|
|
228
233
|
|
|
229
234
|
change(doc, (d: any) => {
|
|
230
235
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -241,7 +246,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
241
246
|
})
|
|
242
247
|
|
|
243
248
|
it("subscribe fires on text edit inside record entry", () => {
|
|
244
|
-
const doc =
|
|
249
|
+
const doc = createDoc(BoundProfile)
|
|
245
250
|
|
|
246
251
|
change(doc, (d: any) => {
|
|
247
252
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -260,7 +265,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
260
265
|
})
|
|
261
266
|
|
|
262
267
|
it("syncs text-inside-record via snapshot", () => {
|
|
263
|
-
const docA =
|
|
268
|
+
const docA = createDoc(BoundProfile)
|
|
264
269
|
|
|
265
270
|
change(docA, (d: any) => {
|
|
266
271
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -270,7 +275,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
270
275
|
})
|
|
271
276
|
|
|
272
277
|
const snapshot = exportEntirety(docA)
|
|
273
|
-
const docB =
|
|
278
|
+
const docB = createDoc(BoundProfile, snapshot)
|
|
274
279
|
|
|
275
280
|
expect(docB.profiles()).toEqual({
|
|
276
281
|
alice: { displayName: "Alice", bio: "Collaborative bio" },
|
|
@@ -281,7 +286,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
281
286
|
})
|
|
282
287
|
|
|
283
288
|
it("syncs text-inside-record via delta", () => {
|
|
284
|
-
const docA =
|
|
289
|
+
const docA = createDoc(BoundProfile)
|
|
285
290
|
|
|
286
291
|
change(docA, (d: any) => {
|
|
287
292
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -290,7 +295,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
290
295
|
// Establish docB from snapshot (consistent with Yjs test patterns —
|
|
291
296
|
// two independently-created Y.Docs may share a clientID, causing
|
|
292
297
|
// silent update drops)
|
|
293
|
-
const docB =
|
|
298
|
+
const docB = createDoc(BoundProfile, exportEntirety(docA))
|
|
294
299
|
const v0 = version(docB)
|
|
295
300
|
|
|
296
301
|
change(docA, (d: any) => {
|
|
@@ -313,13 +318,13 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
313
318
|
})
|
|
314
319
|
|
|
315
320
|
it("concurrent text edits inside record entries converge", () => {
|
|
316
|
-
const docA =
|
|
321
|
+
const docA = createDoc(BoundProfile)
|
|
317
322
|
|
|
318
323
|
// Sync initial state: A creates the entry, B starts from snapshot
|
|
319
324
|
change(docA, (d: any) => {
|
|
320
325
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
321
326
|
})
|
|
322
|
-
const docB =
|
|
327
|
+
const docB = createDoc(BoundProfile, exportEntirety(docA))
|
|
323
328
|
|
|
324
329
|
// Both peers edit concurrently
|
|
325
330
|
const vA = version(docA)
|
|
@@ -353,7 +358,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
353
358
|
|
|
354
359
|
describe("text-inside-struct-inside-list", () => {
|
|
355
360
|
it("push a struct with text field omitted and read it back", () => {
|
|
356
|
-
const doc =
|
|
361
|
+
const doc = createDoc(BoundListProfile)
|
|
357
362
|
|
|
358
363
|
change(doc, (d: any) => {
|
|
359
364
|
d.players.push({ name: "Alice" })
|
|
@@ -365,7 +370,7 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
365
370
|
})
|
|
366
371
|
|
|
367
372
|
it("push a struct with text field provided and read it back", () => {
|
|
368
|
-
const doc =
|
|
373
|
+
const doc = createDoc(BoundListProfile)
|
|
369
374
|
|
|
370
375
|
change(doc, (d: any) => {
|
|
371
376
|
d.players.push({ name: "Alice", bio: "Hi there" })
|
|
@@ -377,7 +382,7 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
377
382
|
})
|
|
378
383
|
|
|
379
384
|
it("insert text into a text field inside a list item (field omitted at creation)", () => {
|
|
380
|
-
const doc =
|
|
385
|
+
const doc = createDoc(BoundListProfile)
|
|
381
386
|
|
|
382
387
|
change(doc, (d: any) => {
|
|
383
388
|
d.players.push({ name: "Alice" })
|
|
@@ -391,7 +396,7 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
391
396
|
})
|
|
392
397
|
|
|
393
398
|
it("multiple list items with independent text fields", () => {
|
|
394
|
-
const doc =
|
|
399
|
+
const doc = createDoc(BoundListProfile)
|
|
395
400
|
|
|
396
401
|
change(doc, (d: any) => {
|
|
397
402
|
d.players.push({ name: "Alice" })
|
|
@@ -408,17 +413,14 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
408
413
|
})
|
|
409
414
|
|
|
410
415
|
it("syncs list-of-struct-with-text via delta", () => {
|
|
411
|
-
const docA =
|
|
416
|
+
const docA = createDoc(BoundListProfile)
|
|
412
417
|
|
|
413
418
|
change(docA, (d: any) => {
|
|
414
419
|
d.players.push({ name: "Alice" })
|
|
415
420
|
})
|
|
416
421
|
|
|
417
422
|
// Establish docB from snapshot (avoids Yjs clientID collision)
|
|
418
|
-
const docB =
|
|
419
|
-
ListProfileSchema,
|
|
420
|
-
exportEntirety(docA),
|
|
421
|
-
)
|
|
423
|
+
const docB = createDoc(BoundListProfile, exportEntirety(docA))
|
|
422
424
|
const v0 = version(docB)
|
|
423
425
|
|
|
424
426
|
change(docA, (d: any) => {
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
change,
|
|
3
|
+
createDoc,
|
|
4
|
+
createRef,
|
|
5
|
+
exportEntirety,
|
|
6
|
+
exportSince,
|
|
7
|
+
merge,
|
|
8
|
+
RawPath,
|
|
9
|
+
Schema,
|
|
10
|
+
subscribe,
|
|
11
|
+
version,
|
|
12
|
+
} from "@kyneta/schema"
|
|
2
13
|
import { describe, expect, it } from "vitest"
|
|
3
14
|
import * as Y from "yjs"
|
|
4
|
-
import {
|
|
15
|
+
import { yjs } from "../bind-yjs.js"
|
|
5
16
|
import { ensureContainers } from "../populate.js"
|
|
6
|
-
import { yjsSubstrateFactory } from "../substrate.js"
|
|
7
|
-
import { exportEntirety, exportSince, merge, version } from "../sync.js"
|
|
17
|
+
import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
|
|
8
18
|
import { YjsVersion } from "../version.js"
|
|
9
19
|
|
|
10
20
|
// ===========================================================================
|
|
@@ -66,7 +76,7 @@ describe("YjsSubstrate", () => {
|
|
|
66
76
|
})
|
|
67
77
|
|
|
68
78
|
it("creates a substrate and populates via change()", () => {
|
|
69
|
-
const doc =
|
|
79
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
70
80
|
change(doc, (d: any) => {
|
|
71
81
|
d.title.insert(0, "Hello")
|
|
72
82
|
d.count.set(42)
|
|
@@ -81,7 +91,7 @@ describe("YjsSubstrate", () => {
|
|
|
81
91
|
})
|
|
82
92
|
|
|
83
93
|
it("creates a substrate with partial values (unset fields stay empty)", () => {
|
|
84
|
-
const doc =
|
|
94
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
85
95
|
change(doc, (d: any) => {
|
|
86
96
|
d.title.insert(0, "Partial")
|
|
87
97
|
})
|
|
@@ -91,7 +101,7 @@ describe("YjsSubstrate", () => {
|
|
|
91
101
|
})
|
|
92
102
|
|
|
93
103
|
it("creates a substrate with nested struct values via change()", () => {
|
|
94
|
-
const doc =
|
|
104
|
+
const doc = createDoc(yjs.bind(FullSchema))
|
|
95
105
|
change(doc, (d: any) => {
|
|
96
106
|
d.meta.author.set("Alice")
|
|
97
107
|
})
|
|
@@ -99,7 +109,7 @@ describe("YjsSubstrate", () => {
|
|
|
99
109
|
})
|
|
100
110
|
|
|
101
111
|
it("creates a substrate with struct list values via change()", () => {
|
|
102
|
-
const doc =
|
|
112
|
+
const doc = createDoc(yjs.bind(StructListSchema))
|
|
103
113
|
// Separate change() calls for list pushes to preserve order
|
|
104
114
|
change(doc, (d: any) => d.tasks.push({ name: "Task 1", done: false }))
|
|
105
115
|
change(doc, (d: any) => d.tasks.push({ name: "Task 2", done: true }))
|
|
@@ -114,7 +124,7 @@ describe("YjsSubstrate", () => {
|
|
|
114
124
|
|
|
115
125
|
describe("write round-trip", () => {
|
|
116
126
|
it("text insert round-trips through prepare/flush", () => {
|
|
117
|
-
const doc =
|
|
127
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
118
128
|
change(doc, (d: any) => {
|
|
119
129
|
d.title.insert(0, "Hello")
|
|
120
130
|
})
|
|
@@ -122,7 +132,7 @@ describe("YjsSubstrate", () => {
|
|
|
122
132
|
})
|
|
123
133
|
|
|
124
134
|
it("scalar set round-trips through prepare/flush", () => {
|
|
125
|
-
const doc =
|
|
135
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
126
136
|
change(doc, (d: any) => {
|
|
127
137
|
d.count.set(42)
|
|
128
138
|
})
|
|
@@ -130,7 +140,7 @@ describe("YjsSubstrate", () => {
|
|
|
130
140
|
})
|
|
131
141
|
|
|
132
142
|
it("list push round-trips through prepare/flush", () => {
|
|
133
|
-
const doc =
|
|
143
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
134
144
|
change(doc, (d: any) => {
|
|
135
145
|
d.items.push("a")
|
|
136
146
|
})
|
|
@@ -148,7 +158,7 @@ describe("YjsSubstrate", () => {
|
|
|
148
158
|
|
|
149
159
|
describe("version tracking", () => {
|
|
150
160
|
it("version advances after mutations", () => {
|
|
151
|
-
const doc =
|
|
161
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
152
162
|
const v1 = version(doc)
|
|
153
163
|
|
|
154
164
|
change(doc, (d: any) => {
|
|
@@ -161,7 +171,7 @@ describe("YjsSubstrate", () => {
|
|
|
161
171
|
})
|
|
162
172
|
|
|
163
173
|
it("version serialize/parse round-trips", () => {
|
|
164
|
-
const doc =
|
|
174
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
165
175
|
change(doc, (d: any) => {
|
|
166
176
|
d.title.insert(0, "Test")
|
|
167
177
|
d.count.set(5)
|
|
@@ -180,7 +190,7 @@ describe("YjsSubstrate", () => {
|
|
|
180
190
|
|
|
181
191
|
describe("export/import snapshot", () => {
|
|
182
192
|
it("exports a binary payload", () => {
|
|
183
|
-
const doc =
|
|
193
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
184
194
|
change(doc, (d: any) => {
|
|
185
195
|
d.title.insert(0, "Snapshot")
|
|
186
196
|
})
|
|
@@ -190,7 +200,7 @@ describe("YjsSubstrate", () => {
|
|
|
190
200
|
})
|
|
191
201
|
|
|
192
202
|
it("reconstructs equivalent state from snapshot", () => {
|
|
193
|
-
const doc1 =
|
|
203
|
+
const doc1 = createDoc(yjs.bind(SimpleSchema))
|
|
194
204
|
change(doc1, (d: any) => {
|
|
195
205
|
d.title.insert(0, "Hello")
|
|
196
206
|
d.count.set(42)
|
|
@@ -203,7 +213,7 @@ describe("YjsSubstrate", () => {
|
|
|
203
213
|
})
|
|
204
214
|
|
|
205
215
|
const payload = exportEntirety(doc1)
|
|
206
|
-
const doc2 =
|
|
216
|
+
const doc2 = createDoc(yjs.bind(SimpleSchema), payload)
|
|
207
217
|
|
|
208
218
|
expect(doc2.title()).toBe("Hello World")
|
|
209
219
|
expect(doc2.count()).toBe(42)
|
|
@@ -217,11 +227,11 @@ describe("YjsSubstrate", () => {
|
|
|
217
227
|
|
|
218
228
|
describe("delta sync", () => {
|
|
219
229
|
it("exportSince → merge syncs state", () => {
|
|
220
|
-
const doc1 =
|
|
230
|
+
const doc1 = createDoc(yjs.bind(SimpleSchema))
|
|
221
231
|
change(doc1, (d: any) => {
|
|
222
232
|
d.title.insert(0, "Start")
|
|
223
233
|
})
|
|
224
|
-
const doc2 =
|
|
234
|
+
const doc2 = createDoc(yjs.bind(SimpleSchema), exportEntirety(doc1))
|
|
225
235
|
|
|
226
236
|
const v1Before = version(doc1)
|
|
227
237
|
|
|
@@ -239,8 +249,8 @@ describe("YjsSubstrate", () => {
|
|
|
239
249
|
})
|
|
240
250
|
|
|
241
251
|
it("concurrent sync — two substrates converge after bidirectional sync", () => {
|
|
242
|
-
const doc1 =
|
|
243
|
-
const doc2 =
|
|
252
|
+
const doc1 = createDoc(yjs.bind(SimpleSchema))
|
|
253
|
+
const doc2 = createDoc(yjs.bind(SimpleSchema), exportEntirety(doc1))
|
|
244
254
|
|
|
245
255
|
const v1Before = version(doc1)
|
|
246
256
|
const v2Before = version(doc2)
|
|
@@ -285,11 +295,11 @@ describe("YjsSubstrate", () => {
|
|
|
285
295
|
|
|
286
296
|
describe("changefeed", () => {
|
|
287
297
|
it("fires on merge", () => {
|
|
288
|
-
const doc1 =
|
|
298
|
+
const doc1 = createDoc(yjs.bind(SimpleSchema))
|
|
289
299
|
change(doc1, (d: any) => {
|
|
290
300
|
d.title.insert(0, "A")
|
|
291
301
|
})
|
|
292
|
-
const doc2 =
|
|
302
|
+
const doc2 = createDoc(yjs.bind(SimpleSchema), exportEntirety(doc1))
|
|
293
303
|
|
|
294
304
|
const v2Before = version(doc2)
|
|
295
305
|
|
|
@@ -312,7 +322,10 @@ describe("YjsSubstrate", () => {
|
|
|
312
322
|
it("fires on external Y.Doc mutation (raw Yjs API)", () => {
|
|
313
323
|
const yjsDoc = new Y.Doc()
|
|
314
324
|
ensureContainers(yjsDoc, SimpleSchema)
|
|
315
|
-
const doc =
|
|
325
|
+
const doc = createRef(
|
|
326
|
+
SimpleSchema,
|
|
327
|
+
createYjsSubstrate(yjsDoc, SimpleSchema),
|
|
328
|
+
)
|
|
316
329
|
|
|
317
330
|
const received: any[] = []
|
|
318
331
|
subscribe(doc, (changeset: any) => {
|
|
@@ -328,7 +341,7 @@ describe("YjsSubstrate", () => {
|
|
|
328
341
|
})
|
|
329
342
|
|
|
330
343
|
it("no double-fire on kyneta local writes", () => {
|
|
331
|
-
const doc =
|
|
344
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
332
345
|
|
|
333
346
|
const received: any[] = []
|
|
334
347
|
subscribe(doc, (changeset: any) => {
|
|
@@ -345,18 +358,15 @@ describe("YjsSubstrate", () => {
|
|
|
345
358
|
})
|
|
346
359
|
|
|
347
360
|
it("nested struct field changefeed fires on merge", () => {
|
|
348
|
-
const doc1 =
|
|
349
|
-
const _doc2 =
|
|
350
|
-
StructListSchema,
|
|
351
|
-
exportEntirety(doc1),
|
|
352
|
-
)
|
|
361
|
+
const doc1 = createDoc(yjs.bind(StructListSchema))
|
|
362
|
+
const _doc2 = createDoc(yjs.bind(StructListSchema), exportEntirety(doc1))
|
|
353
363
|
|
|
354
364
|
// Add a struct item on doc1, sync to doc2
|
|
355
365
|
change(doc1, (d: any) => {
|
|
356
366
|
d.tasks.push({ name: "Buy milk", done: false })
|
|
357
367
|
})
|
|
358
368
|
const snap = exportEntirety(doc1)
|
|
359
|
-
const doc2b =
|
|
369
|
+
const doc2b = createDoc(yjs.bind(StructListSchema), snap)
|
|
360
370
|
|
|
361
371
|
const taskB = [...doc2b.tasks][0] as any
|
|
362
372
|
expect(taskB.done()).toBe(false)
|
|
@@ -387,16 +397,13 @@ describe("YjsSubstrate", () => {
|
|
|
387
397
|
})
|
|
388
398
|
|
|
389
399
|
it("multi-key struct update fires per-field changefeeds on merge", () => {
|
|
390
|
-
const doc1 =
|
|
400
|
+
const doc1 = createDoc(yjs.bind(StructListSchema))
|
|
391
401
|
|
|
392
402
|
// Add a struct item, sync to doc2
|
|
393
403
|
change(doc1, (d: any) => {
|
|
394
404
|
d.tasks.push({ name: "Buy milk", done: false })
|
|
395
405
|
})
|
|
396
|
-
const doc2 =
|
|
397
|
-
StructListSchema,
|
|
398
|
-
exportEntirety(doc1),
|
|
399
|
-
)
|
|
406
|
+
const doc2 = createDoc(yjs.bind(StructListSchema), exportEntirety(doc1))
|
|
400
407
|
|
|
401
408
|
const taskB = [...doc2.tasks][0] as any
|
|
402
409
|
const v2 = version(doc2)
|
|
@@ -438,7 +445,7 @@ describe("YjsSubstrate", () => {
|
|
|
438
445
|
|
|
439
446
|
describe("transaction support", () => {
|
|
440
447
|
it("multi-op change() is atomic", () => {
|
|
441
|
-
const doc =
|
|
448
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
442
449
|
|
|
443
450
|
const received: any[] = []
|
|
444
451
|
subscribe(doc, (changeset: any) => {
|
|
@@ -474,7 +481,7 @@ describe("YjsSubstrate", () => {
|
|
|
474
481
|
|
|
475
482
|
describe("nested structure", () => {
|
|
476
483
|
it("push struct into list, read back via navigation", () => {
|
|
477
|
-
const doc =
|
|
484
|
+
const doc = createDoc(yjs.bind(StructListSchema))
|
|
478
485
|
|
|
479
486
|
change(doc, (d: any) => {
|
|
480
487
|
d.tasks.push({ name: "Task 1", done: false })
|
|
@@ -494,7 +501,7 @@ describe("YjsSubstrate", () => {
|
|
|
494
501
|
})
|
|
495
502
|
|
|
496
503
|
it("nested struct write round-trip", () => {
|
|
497
|
-
const doc =
|
|
504
|
+
const doc = createDoc(yjs.bind(FullSchema))
|
|
498
505
|
change(doc, (d: any) => {
|
|
499
506
|
d.meta.author.set("Alice")
|
|
500
507
|
})
|
|
@@ -553,7 +560,7 @@ describe("YjsSubstrate", () => {
|
|
|
553
560
|
})
|
|
554
561
|
|
|
555
562
|
it("reconstructs from snapshot with correct state", () => {
|
|
556
|
-
const doc =
|
|
563
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
557
564
|
change(doc, (d: any) => {
|
|
558
565
|
d.title.insert(0, "Snapshot Test")
|
|
559
566
|
d.count.set(77)
|
|
@@ -561,7 +568,7 @@ describe("YjsSubstrate", () => {
|
|
|
561
568
|
})
|
|
562
569
|
|
|
563
570
|
const payload = exportEntirety(doc)
|
|
564
|
-
const doc2 =
|
|
571
|
+
const doc2 = createDoc(yjs.bind(SimpleSchema), payload)
|
|
565
572
|
|
|
566
573
|
expect(doc2.title()).toBe("Snapshot Test")
|
|
567
574
|
expect(doc2.count()).toBe(77)
|
package/src/bind-yjs.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// bind-yjs — Yjs CRDT substrate namespace and factory.
|
|
2
2
|
//
|
|
3
|
-
// Provides the `yjs` substrate namespace (`yjs.bind()`, `yjs.replica()
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
6
6
|
//
|
|
7
7
|
// Yjs clientID is a uint32 number. We use FNV-1a hash truncated to
|
|
8
8
|
// 32 bits, mirroring the Loro binding's hashPeerId pattern but
|
|
@@ -31,9 +31,9 @@ import {
|
|
|
31
31
|
BACKING_DOC,
|
|
32
32
|
createSubstrateNamespace,
|
|
33
33
|
STRUCTURAL_YJS_CLIENT_ID,
|
|
34
|
-
unwrap,
|
|
35
34
|
} from "@kyneta/schema"
|
|
36
35
|
import * as Y from "yjs"
|
|
36
|
+
import type { YjsNativeMap } from "./native-map.js"
|
|
37
37
|
import { ensureContainers } from "./populate.js"
|
|
38
38
|
import {
|
|
39
39
|
createYjsReplica,
|
|
@@ -142,19 +142,17 @@ function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
|
|
|
142
142
|
* - `yjs.bind(schema, "ephemeral")` — ephemeral/presence broadcast
|
|
143
143
|
* - `yjs.replica()` — collaborative replication (default)
|
|
144
144
|
* - `yjs.replica("ephemeral")` — ephemeral replication
|
|
145
|
-
* - `yjs.unwrap(ref)` — access the underlying Y.Doc
|
|
146
145
|
*
|
|
147
146
|
* Strategy is constrained to `CrdtStrategy` (`"collaborative" | "ephemeral"`).
|
|
148
147
|
* Passing `"authoritative"` is a compile error.
|
|
148
|
+
*
|
|
149
|
+
* To access the underlying Y.Doc, use `unwrap(ref)` from `@kyneta/schema`.
|
|
149
150
|
*/
|
|
150
151
|
/** The closed set of capability tags that the Yjs substrate supports. */
|
|
151
152
|
export type YjsCaps = "text" | "json"
|
|
152
153
|
|
|
153
|
-
export const yjs: SubstrateNamespace<CrdtStrategy, YjsCaps>
|
|
154
|
-
|
|
155
|
-
unwrap(ref: object): Y.Doc
|
|
156
|
-
} = {
|
|
157
|
-
...createSubstrateNamespace<CrdtStrategy, YjsCaps>({
|
|
154
|
+
export const yjs: SubstrateNamespace<CrdtStrategy, YjsCaps, YjsNativeMap> =
|
|
155
|
+
createSubstrateNamespace<CrdtStrategy, YjsCaps, YjsNativeMap>({
|
|
158
156
|
strategies: {
|
|
159
157
|
collaborative: {
|
|
160
158
|
factory: ctx => createYjsFactory(ctx.peerId),
|
|
@@ -166,33 +164,4 @@ export const yjs: SubstrateNamespace<CrdtStrategy, YjsCaps> & {
|
|
|
166
164
|
},
|
|
167
165
|
},
|
|
168
166
|
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
|
-
},
|
|
198
|
-
}
|
|
167
|
+
})
|
package/src/change-mapping.ts
CHANGED
|
@@ -29,7 +29,12 @@ import type {
|
|
|
29
29
|
TextChange,
|
|
30
30
|
TextInstruction,
|
|
31
31
|
} from "@kyneta/schema"
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
advanceSchema,
|
|
34
|
+
expandMapOpsToLeaves,
|
|
35
|
+
KIND,
|
|
36
|
+
RawPath,
|
|
37
|
+
} from "@kyneta/schema"
|
|
33
38
|
import * as Y from "yjs"
|
|
34
39
|
import { resolveYjsType } from "./yjs-resolve.js"
|
|
35
40
|
|
|
@@ -585,4 +590,4 @@ function getFieldSchema(
|
|
|
585
590
|
|
|
586
591
|
function pathToString(path: Path): string {
|
|
587
592
|
return path.segments.map(seg => String(seg.resolve())).join(".")
|
|
588
|
-
}
|
|
593
|
+
}
|