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