@kyneta/yjs-schema 1.1.0 → 1.3.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/README.md +3 -3
- package/dist/index.d.ts +97 -225
- package/dist/index.js +281 -316
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/__tests__/bind-constraints.test.ts +325 -0
- package/src/__tests__/bind-yjs.test.ts +79 -70
- package/src/__tests__/create.test.ts +88 -65
- package/src/__tests__/reader.test.ts +38 -72
- package/src/__tests__/record-text-spike.test.ts +47 -46
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +62 -58
- package/src/__tests__/version.test.ts +75 -0
- package/src/bind-yjs.ts +40 -41
- package/src/change-mapping.ts +50 -54
- package/src/index.ts +29 -44
- package/src/native-map.ts +37 -0
- package/src/populate.ts +49 -82
- package/src/substrate.ts +68 -8
- package/src/version.ts +54 -52
- package/src/yjs-resolve.ts +0 -10
- package/src/create.ts +0 -177
- package/src/sync.ts +0 -107
- package/src/yjs-escape.ts +0 -84
|
@@ -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() }))
|
|
@@ -16,34 +16,33 @@
|
|
|
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
|
-
text,
|
|
30
27
|
version,
|
|
31
|
-
} from "
|
|
28
|
+
} from "@kyneta/schema"
|
|
29
|
+
import { describe, expect, it } from "vitest"
|
|
30
|
+
import { yjs } from "../bind-yjs.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,22 +51,30 @@ 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
|
})
|
|
63
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
|
+
|
|
64
71
|
// ===========================================================================
|
|
65
72
|
// Baseline: record-of-struct (plain, no text)
|
|
66
73
|
// ===========================================================================
|
|
67
74
|
|
|
68
75
|
describe("record-of-struct (plain baseline)", () => {
|
|
69
76
|
it("set a record entry and read it back", () => {
|
|
70
|
-
const doc =
|
|
77
|
+
const doc = createDoc(BoundPlainRecord)
|
|
71
78
|
|
|
72
79
|
change(doc, (d: any) => {
|
|
73
80
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
@@ -80,7 +87,7 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
80
87
|
})
|
|
81
88
|
|
|
82
89
|
it("set multiple entries and read all back", () => {
|
|
83
|
-
const doc =
|
|
90
|
+
const doc = createDoc(BoundPlainRecord)
|
|
84
91
|
|
|
85
92
|
change(doc, (d: any) => {
|
|
86
93
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
@@ -95,7 +102,7 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
95
102
|
})
|
|
96
103
|
|
|
97
104
|
it("navigate into a record entry via .at()", () => {
|
|
98
|
-
const doc =
|
|
105
|
+
const doc = createDoc(BoundPlainRecord)
|
|
99
106
|
|
|
100
107
|
change(doc, (d: any) => {
|
|
101
108
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
@@ -108,14 +115,14 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
108
115
|
})
|
|
109
116
|
|
|
110
117
|
it("syncs record-of-struct between two peers via snapshot", () => {
|
|
111
|
-
const docA =
|
|
118
|
+
const docA = createDoc(BoundPlainRecord)
|
|
112
119
|
|
|
113
120
|
change(docA, (d: any) => {
|
|
114
121
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
115
122
|
})
|
|
116
123
|
|
|
117
124
|
const snapshot = exportEntirety(docA)
|
|
118
|
-
const docB =
|
|
125
|
+
const docB = createDoc(BoundPlainRecord, snapshot)
|
|
119
126
|
|
|
120
127
|
expect(docB.profiles()).toEqual({
|
|
121
128
|
alice: { displayName: "Alice", age: 30 },
|
|
@@ -123,17 +130,14 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
123
130
|
})
|
|
124
131
|
|
|
125
132
|
it("syncs record-of-struct between two peers via delta", () => {
|
|
126
|
-
const docA =
|
|
133
|
+
const docA = createDoc(BoundPlainRecord)
|
|
127
134
|
|
|
128
135
|
change(docA, (d: any) => {
|
|
129
136
|
d.profiles.set("alice", { displayName: "Alice", age: 30 })
|
|
130
137
|
})
|
|
131
138
|
|
|
132
139
|
// Establish docB from snapshot (avoids Yjs clientID collision)
|
|
133
|
-
const docB =
|
|
134
|
-
PlainRecordSchema,
|
|
135
|
-
exportEntirety(docA),
|
|
136
|
-
)
|
|
140
|
+
const docB = createDoc(BoundPlainRecord, exportEntirety(docA))
|
|
137
141
|
|
|
138
142
|
const v0 = version(docB)
|
|
139
143
|
|
|
@@ -158,7 +162,7 @@ describe("record-of-struct (plain baseline)", () => {
|
|
|
158
162
|
|
|
159
163
|
describe("text-inside-struct-inside-record", () => {
|
|
160
164
|
it("set a record entry with text field omitted and read it back", () => {
|
|
161
|
-
const doc =
|
|
165
|
+
const doc = createDoc(BoundProfile)
|
|
162
166
|
|
|
163
167
|
change(doc, (d: any) => {
|
|
164
168
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -171,7 +175,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
171
175
|
})
|
|
172
176
|
|
|
173
177
|
it("set a record entry with text field provided and read it back", () => {
|
|
174
|
-
const doc =
|
|
178
|
+
const doc = createDoc(BoundProfile)
|
|
175
179
|
|
|
176
180
|
change(doc, (d: any) => {
|
|
177
181
|
d.profiles.set("alice", { displayName: "Alice", bio: "Hello world" })
|
|
@@ -184,7 +188,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
184
188
|
})
|
|
185
189
|
|
|
186
190
|
it("navigate into a record entry and read the text", () => {
|
|
187
|
-
const doc =
|
|
191
|
+
const doc = createDoc(BoundProfile)
|
|
188
192
|
|
|
189
193
|
change(doc, (d: any) => {
|
|
190
194
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -197,7 +201,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
197
201
|
})
|
|
198
202
|
|
|
199
203
|
it("insert text into a text field inside a record entry (field omitted at creation)", () => {
|
|
200
|
-
const doc =
|
|
204
|
+
const doc = createDoc(BoundProfile)
|
|
201
205
|
|
|
202
206
|
change(doc, (d: any) => {
|
|
203
207
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -211,7 +215,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
211
215
|
})
|
|
212
216
|
|
|
213
217
|
it("insert text into a text field inside a record entry (field provided at creation)", () => {
|
|
214
|
-
const doc =
|
|
218
|
+
const doc = createDoc(BoundProfile)
|
|
215
219
|
|
|
216
220
|
change(doc, (d: any) => {
|
|
217
221
|
d.profiles.set("alice", { displayName: "Alice", bio: "Initial" })
|
|
@@ -225,7 +229,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
225
229
|
})
|
|
226
230
|
|
|
227
231
|
it("set multiple entries and edit text independently", () => {
|
|
228
|
-
const doc =
|
|
232
|
+
const doc = createDoc(BoundProfile)
|
|
229
233
|
|
|
230
234
|
change(doc, (d: any) => {
|
|
231
235
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -242,7 +246,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
242
246
|
})
|
|
243
247
|
|
|
244
248
|
it("subscribe fires on text edit inside record entry", () => {
|
|
245
|
-
const doc =
|
|
249
|
+
const doc = createDoc(BoundProfile)
|
|
246
250
|
|
|
247
251
|
change(doc, (d: any) => {
|
|
248
252
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -261,7 +265,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
261
265
|
})
|
|
262
266
|
|
|
263
267
|
it("syncs text-inside-record via snapshot", () => {
|
|
264
|
-
const docA =
|
|
268
|
+
const docA = createDoc(BoundProfile)
|
|
265
269
|
|
|
266
270
|
change(docA, (d: any) => {
|
|
267
271
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -271,7 +275,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
271
275
|
})
|
|
272
276
|
|
|
273
277
|
const snapshot = exportEntirety(docA)
|
|
274
|
-
const docB =
|
|
278
|
+
const docB = createDoc(BoundProfile, snapshot)
|
|
275
279
|
|
|
276
280
|
expect(docB.profiles()).toEqual({
|
|
277
281
|
alice: { displayName: "Alice", bio: "Collaborative bio" },
|
|
@@ -282,7 +286,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
282
286
|
})
|
|
283
287
|
|
|
284
288
|
it("syncs text-inside-record via delta", () => {
|
|
285
|
-
const docA =
|
|
289
|
+
const docA = createDoc(BoundProfile)
|
|
286
290
|
|
|
287
291
|
change(docA, (d: any) => {
|
|
288
292
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
@@ -291,7 +295,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
291
295
|
// Establish docB from snapshot (consistent with Yjs test patterns —
|
|
292
296
|
// two independently-created Y.Docs may share a clientID, causing
|
|
293
297
|
// silent update drops)
|
|
294
|
-
const docB =
|
|
298
|
+
const docB = createDoc(BoundProfile, exportEntirety(docA))
|
|
295
299
|
const v0 = version(docB)
|
|
296
300
|
|
|
297
301
|
change(docA, (d: any) => {
|
|
@@ -314,13 +318,13 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
314
318
|
})
|
|
315
319
|
|
|
316
320
|
it("concurrent text edits inside record entries converge", () => {
|
|
317
|
-
const docA =
|
|
321
|
+
const docA = createDoc(BoundProfile)
|
|
318
322
|
|
|
319
323
|
// Sync initial state: A creates the entry, B starts from snapshot
|
|
320
324
|
change(docA, (d: any) => {
|
|
321
325
|
d.profiles.set("alice", { displayName: "Alice" })
|
|
322
326
|
})
|
|
323
|
-
const docB =
|
|
327
|
+
const docB = createDoc(BoundProfile, exportEntirety(docA))
|
|
324
328
|
|
|
325
329
|
// Both peers edit concurrently
|
|
326
330
|
const vA = version(docA)
|
|
@@ -354,7 +358,7 @@ describe("text-inside-struct-inside-record", () => {
|
|
|
354
358
|
|
|
355
359
|
describe("text-inside-struct-inside-list", () => {
|
|
356
360
|
it("push a struct with text field omitted and read it back", () => {
|
|
357
|
-
const doc =
|
|
361
|
+
const doc = createDoc(BoundListProfile)
|
|
358
362
|
|
|
359
363
|
change(doc, (d: any) => {
|
|
360
364
|
d.players.push({ name: "Alice" })
|
|
@@ -366,7 +370,7 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
366
370
|
})
|
|
367
371
|
|
|
368
372
|
it("push a struct with text field provided and read it back", () => {
|
|
369
|
-
const doc =
|
|
373
|
+
const doc = createDoc(BoundListProfile)
|
|
370
374
|
|
|
371
375
|
change(doc, (d: any) => {
|
|
372
376
|
d.players.push({ name: "Alice", bio: "Hi there" })
|
|
@@ -378,7 +382,7 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
378
382
|
})
|
|
379
383
|
|
|
380
384
|
it("insert text into a text field inside a list item (field omitted at creation)", () => {
|
|
381
|
-
const doc =
|
|
385
|
+
const doc = createDoc(BoundListProfile)
|
|
382
386
|
|
|
383
387
|
change(doc, (d: any) => {
|
|
384
388
|
d.players.push({ name: "Alice" })
|
|
@@ -392,7 +396,7 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
392
396
|
})
|
|
393
397
|
|
|
394
398
|
it("multiple list items with independent text fields", () => {
|
|
395
|
-
const doc =
|
|
399
|
+
const doc = createDoc(BoundListProfile)
|
|
396
400
|
|
|
397
401
|
change(doc, (d: any) => {
|
|
398
402
|
d.players.push({ name: "Alice" })
|
|
@@ -409,17 +413,14 @@ describe("text-inside-struct-inside-list", () => {
|
|
|
409
413
|
})
|
|
410
414
|
|
|
411
415
|
it("syncs list-of-struct-with-text via delta", () => {
|
|
412
|
-
const docA =
|
|
416
|
+
const docA = createDoc(BoundListProfile)
|
|
413
417
|
|
|
414
418
|
change(docA, (d: any) => {
|
|
415
419
|
d.players.push({ name: "Alice" })
|
|
416
420
|
})
|
|
417
421
|
|
|
418
422
|
// Establish docB from snapshot (avoids Yjs clientID collision)
|
|
419
|
-
const docB =
|
|
420
|
-
ListProfileSchema,
|
|
421
|
-
exportEntirety(docA),
|
|
422
|
-
)
|
|
423
|
+
const docB = createDoc(BoundListProfile, exportEntirety(docA))
|
|
423
424
|
const v0 = version(docB)
|
|
424
425
|
|
|
425
426
|
change(docA, (d: any) => {
|
|
@@ -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" })
|