@kyneta/yjs-schema 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,34 +1,27 @@
1
- import { describe, expect, it, vi } from "vitest"
1
+ import { change, RawPath, Schema, subscribe } from "@kyneta/schema"
2
+ import { describe, expect, it } from "vitest"
2
3
  import * as Y from "yjs"
3
- import { Schema, change, subscribe, RawPath } from "@kyneta/schema"
4
- import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
5
- import { YjsVersion } from "../version.js"
6
- import { createYjsDoc, createYjsDocFromSnapshot } from "../create.js"
7
- import {
8
- version,
9
- exportSnapshot,
10
- exportSince,
11
- importDelta,
12
- } from "../sync.js"
4
+ import { createYjsDoc, createYjsDocFromEntirety } from "../create.js"
13
5
  import { ensureContainers } from "../populate.js"
6
+ import { yjsSubstrateFactory } from "../substrate.js"
7
+ import { exportEntirety, exportSince, merge, version } from "../sync.js"
8
+ import { YjsVersion } from "../version.js"
14
9
 
15
10
  // ===========================================================================
16
11
  // Helpers
17
12
  // ===========================================================================
18
13
 
19
-
20
-
21
14
  // ===========================================================================
22
15
  // Schemas used across tests
23
16
  // ===========================================================================
24
17
 
25
- const SimpleSchema = Schema.doc({
26
- title: Schema.annotated("text"),
18
+ const SimpleSchema = Schema.struct({
19
+ title: Schema.text(),
27
20
  count: Schema.number(),
28
21
  items: Schema.list(Schema.string()),
29
22
  })
30
23
 
31
- const StructListSchema = Schema.doc({
24
+ const StructListSchema = Schema.struct({
32
25
  tasks: Schema.list(
33
26
  Schema.struct({
34
27
  name: Schema.string(),
@@ -37,8 +30,8 @@ const StructListSchema = Schema.doc({
37
30
  ),
38
31
  })
39
32
 
40
- const FullSchema = Schema.doc({
41
- title: Schema.annotated("text"),
33
+ const FullSchema = Schema.struct({
34
+ title: Schema.text(),
42
35
  count: Schema.number(),
43
36
  active: Schema.boolean(),
44
37
  items: Schema.list(Schema.string()),
@@ -66,10 +59,10 @@ describe("YjsSubstrate", () => {
66
59
  describe("factory create", () => {
67
60
  it("creates a substrate with empty containers", () => {
68
61
  const substrate = yjsSubstrateFactory.create(SimpleSchema)
69
- expect(substrate.store.read(RawPath.empty.field("title"))).toBe("")
62
+ expect(substrate.reader.read(RawPath.empty.field("title"))).toBe("")
70
63
  // Plain scalars return structural zeros
71
- expect(substrate.store.read(RawPath.empty.field("count"))).toBe(0)
72
- expect(substrate.store.read(RawPath.empty.field("items"))).toEqual([])
64
+ expect(substrate.reader.read(RawPath.empty.field("count"))).toBe(0)
65
+ expect(substrate.reader.read(RawPath.empty.field("items"))).toEqual([])
73
66
  })
74
67
 
75
68
  it("creates a substrate and populates via change()", () => {
@@ -188,8 +181,10 @@ describe("YjsSubstrate", () => {
188
181
  describe("export/import snapshot", () => {
189
182
  it("exports a binary payload", () => {
190
183
  const doc = createYjsDoc(SimpleSchema)
191
- change(doc, (d: any) => { d.title.insert(0, "Snapshot") })
192
- const payload = exportSnapshot(doc)
184
+ change(doc, (d: any) => {
185
+ d.title.insert(0, "Snapshot")
186
+ })
187
+ const payload = exportEntirety(doc)
193
188
  expect(payload.encoding).toBe("binary")
194
189
  expect(payload.data).toBeInstanceOf(Uint8Array)
195
190
  })
@@ -207,8 +202,8 @@ describe("YjsSubstrate", () => {
207
202
  d.title.insert(5, " World")
208
203
  })
209
204
 
210
- const payload = exportSnapshot(doc1)
211
- const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
205
+ const payload = exportEntirety(doc1)
206
+ const doc2 = createYjsDocFromEntirety(SimpleSchema, payload)
212
207
 
213
208
  expect(doc2.title()).toBe("Hello World")
214
209
  expect(doc2.count()).toBe(42)
@@ -221,13 +216,12 @@ describe("YjsSubstrate", () => {
221
216
  // -------------------------------------------------------------------------
222
217
 
223
218
  describe("delta sync", () => {
224
- it("exportSince → importDelta syncs state", () => {
219
+ it("exportSince → merge syncs state", () => {
225
220
  const doc1 = createYjsDoc(SimpleSchema)
226
- change(doc1, (d: any) => { d.title.insert(0, "Start") })
227
- const doc2 = createYjsDocFromSnapshot(
228
- SimpleSchema,
229
- exportSnapshot(doc1),
230
- )
221
+ change(doc1, (d: any) => {
222
+ d.title.insert(0, "Start")
223
+ })
224
+ const doc2 = createYjsDocFromEntirety(SimpleSchema, exportEntirety(doc1))
231
225
 
232
226
  const v1Before = version(doc1)
233
227
 
@@ -239,17 +233,14 @@ describe("YjsSubstrate", () => {
239
233
  const delta = exportSince(doc1, v1Before)
240
234
  expect(delta).not.toBeNull()
241
235
 
242
- importDelta(doc2, delta!)
236
+ merge(doc2, delta!)
243
237
  expect(doc2.title()).toBe("Start Edited")
244
238
  expect(doc2.count()).toBe(99)
245
239
  })
246
240
 
247
241
  it("concurrent sync — two substrates converge after bidirectional sync", () => {
248
242
  const doc1 = createYjsDoc(SimpleSchema)
249
- const doc2 = createYjsDocFromSnapshot(
250
- SimpleSchema,
251
- exportSnapshot(doc1),
252
- )
243
+ const doc2 = createYjsDocFromEntirety(SimpleSchema, exportEntirety(doc1))
253
244
 
254
245
  const v1Before = version(doc1)
255
246
  const v2Before = version(doc2)
@@ -271,8 +262,8 @@ describe("YjsSubstrate", () => {
271
262
  const d1to2 = exportSince(doc1, v2Before)
272
263
  const d2to1 = exportSince(doc2, v1Before)
273
264
 
274
- importDelta(doc2, d1to2!)
275
- importDelta(doc1, d2to1!)
265
+ merge(doc2, d1to2!)
266
+ merge(doc1, d2to1!)
276
267
 
277
268
  // Should now be equal
278
269
  expect(version(doc1).compare(version(doc2))).toBe("equal")
@@ -293,13 +284,12 @@ describe("YjsSubstrate", () => {
293
284
  // -------------------------------------------------------------------------
294
285
 
295
286
  describe("changefeed", () => {
296
- it("fires on importDelta", () => {
287
+ it("fires on merge", () => {
297
288
  const doc1 = createYjsDoc(SimpleSchema)
298
- change(doc1, (d: any) => { d.title.insert(0, "A") })
299
- const doc2 = createYjsDocFromSnapshot(
300
- SimpleSchema,
301
- exportSnapshot(doc1),
302
- )
289
+ change(doc1, (d: any) => {
290
+ d.title.insert(0, "A")
291
+ })
292
+ const doc2 = createYjsDocFromEntirety(SimpleSchema, exportEntirety(doc1))
303
293
 
304
294
  const v2Before = version(doc2)
305
295
 
@@ -313,7 +303,7 @@ describe("YjsSubstrate", () => {
313
303
  })
314
304
 
315
305
  const delta = exportSince(doc1, v2Before)
316
- importDelta(doc2, delta!)
306
+ merge(doc2, delta!)
317
307
 
318
308
  expect(received.length).toBeGreaterThanOrEqual(1)
319
309
  expect(doc2.count()).toBe(42)
@@ -354,19 +344,19 @@ describe("YjsSubstrate", () => {
354
344
  expect(received.length).toBe(1)
355
345
  })
356
346
 
357
- it("nested struct field changefeed fires on importDelta", () => {
347
+ it("nested struct field changefeed fires on merge", () => {
358
348
  const doc1 = createYjsDoc(StructListSchema)
359
- const doc2 = createYjsDocFromSnapshot(
349
+ const _doc2 = createYjsDocFromEntirety(
360
350
  StructListSchema,
361
- exportSnapshot(doc1),
351
+ exportEntirety(doc1),
362
352
  )
363
353
 
364
354
  // Add a struct item on doc1, sync to doc2
365
355
  change(doc1, (d: any) => {
366
356
  d.tasks.push({ name: "Buy milk", done: false })
367
357
  })
368
- const snap = exportSnapshot(doc1)
369
- const doc2b = createYjsDocFromSnapshot(StructListSchema, snap)
358
+ const snap = exportEntirety(doc1)
359
+ const doc2b = createYjsDocFromEntirety(StructListSchema, snap)
370
360
 
371
361
  const taskB = [...doc2b.tasks][0] as any
372
362
  expect(taskB.done()).toBe(false)
@@ -385,7 +375,7 @@ describe("YjsSubstrate", () => {
385
375
 
386
376
  // Sync the toggle to doc2b
387
377
  const delta = exportSince(doc1, v2)!
388
- importDelta(doc2b, delta)
378
+ merge(doc2b, delta)
389
379
 
390
380
  // Value should be updated
391
381
  expect(taskB.done()).toBe(true)
@@ -396,16 +386,16 @@ describe("YjsSubstrate", () => {
396
386
  unsub()
397
387
  })
398
388
 
399
- it("multi-key struct update fires per-field changefeeds on importDelta", () => {
389
+ it("multi-key struct update fires per-field changefeeds on merge", () => {
400
390
  const doc1 = createYjsDoc(StructListSchema)
401
391
 
402
392
  // Add a struct item, sync to doc2
403
393
  change(doc1, (d: any) => {
404
394
  d.tasks.push({ name: "Buy milk", done: false })
405
395
  })
406
- const doc2 = createYjsDocFromSnapshot(
396
+ const doc2 = createYjsDocFromEntirety(
407
397
  StructListSchema,
408
- exportSnapshot(doc1),
398
+ exportEntirety(doc1),
409
399
  )
410
400
 
411
401
  const taskB = [...doc2.tasks][0] as any
@@ -428,7 +418,7 @@ describe("YjsSubstrate", () => {
428
418
 
429
419
  // Sync to doc2
430
420
  const delta = exportSince(doc1, v2)!
431
- importDelta(doc2, delta)
421
+ merge(doc2, delta)
432
422
 
433
423
  // Both field-level changefeeds should have fired
434
424
  expect(nameChanges.length).toBeGreaterThanOrEqual(1)
@@ -522,50 +512,41 @@ describe("YjsSubstrate", () => {
522
512
  // Counter annotation throws
523
513
  // -------------------------------------------------------------------------
524
514
 
525
- describe("unsupported annotations", () => {
526
- it("counter annotation throws clear error at construction", () => {
527
- const CounterSchema = Schema.doc({
528
- count: Schema.annotated("counter"),
515
+ describe("unsupported kinds", () => {
516
+ it("counter throws clear error at construction", () => {
517
+ const CounterSchema = Schema.struct({
518
+ count: Schema.counter(),
529
519
  })
530
520
 
531
- expect(() =>
532
- yjsSubstrateFactory.create(CounterSchema),
533
- ).toThrow("counter")
521
+ expect(() => yjsSubstrateFactory.create(CounterSchema)).toThrow("counter")
534
522
  })
535
523
 
536
- it("movable annotation throws clear error at construction", () => {
537
- const MovableSchema = Schema.doc({
538
- items: Schema.annotated("movable", Schema.list(Schema.string())),
524
+ it("movableList throws clear error at construction", () => {
525
+ const MovableSchema = Schema.struct({
526
+ items: Schema.movableList(Schema.string()),
539
527
  })
540
528
 
541
- expect(() =>
542
- yjsSubstrateFactory.create(MovableSchema),
543
- ).toThrow("movable")
529
+ expect(() => yjsSubstrateFactory.create(MovableSchema)).toThrow("movable")
544
530
  })
545
531
 
546
- it("tree annotation throws clear error at construction", () => {
547
- const TreeSchema = Schema.doc({
548
- tree: Schema.annotated(
549
- "tree",
550
- Schema.struct({ label: Schema.string() }),
551
- ),
532
+ it("tree throws clear error at construction", () => {
533
+ const TreeSchema = Schema.struct({
534
+ tree: Schema.tree(Schema.struct({ label: Schema.string() })),
552
535
  })
553
536
 
554
- expect(() =>
555
- yjsSubstrateFactory.create(TreeSchema),
556
- ).toThrow("tree")
537
+ expect(() => yjsSubstrateFactory.create(TreeSchema)).toThrow("tree")
557
538
  })
558
539
  })
559
540
 
560
541
  // -------------------------------------------------------------------------
561
- // fromSnapshot
542
+ // fromEntirety
562
543
  // -------------------------------------------------------------------------
563
544
 
564
- describe("fromSnapshot", () => {
545
+ describe("fromEntirety", () => {
565
546
  it("rejects non-binary payloads", () => {
566
547
  expect(() =>
567
- yjsSubstrateFactory.fromSnapshot(
568
- { encoding: "json", data: "{}" },
548
+ yjsSubstrateFactory.fromEntirety(
549
+ { kind: "entirety", encoding: "json", data: "{}" },
569
550
  SimpleSchema,
570
551
  ),
571
552
  ).toThrow("binary")
@@ -579,8 +560,8 @@ describe("YjsSubstrate", () => {
579
560
  d.items.push("x")
580
561
  })
581
562
 
582
- const payload = exportSnapshot(doc)
583
- const doc2 = createYjsDocFromSnapshot(SimpleSchema, payload)
563
+ const payload = exportEntirety(doc)
564
+ const doc2 = createYjsDocFromEntirety(SimpleSchema, payload)
584
565
 
585
566
  expect(doc2.title()).toBe("Snapshot Test")
586
567
  expect(doc2.count()).toBe(77)
@@ -601,4 +582,4 @@ describe("YjsSubstrate", () => {
601
582
  expect(parsed.compare(v)).toBe("equal")
602
583
  })
603
584
  })
604
- })
585
+ })
@@ -36,7 +36,7 @@ describe("YjsVersion", () => {
36
36
  })
37
37
 
38
38
  it("round-trips a version vector with one peer", () => {
39
- const v = versionAfterOps((doc) => {
39
+ const v = versionAfterOps(doc => {
40
40
  doc.getMap("root").set("title", "Hello")
41
41
  })
42
42
  const serialized = v.serialize()
@@ -66,7 +66,7 @@ describe("YjsVersion", () => {
66
66
  })
67
67
 
68
68
  it("serialized form is a non-empty string", () => {
69
- const v = versionAfterOps((doc) => {
69
+ const v = versionAfterOps(doc => {
70
70
  doc.getMap("root").set("count", 42)
71
71
  })
72
72
  const s = v.serialize()
@@ -89,7 +89,7 @@ describe("YjsVersion", () => {
89
89
 
90
90
  describe("compare", () => {
91
91
  it("returns 'equal' for the same version vector", () => {
92
- const v = versionAfterOps((doc) => {
92
+ const v = versionAfterOps(doc => {
93
93
  doc.getMap("root").set("t", "hi")
94
94
  })
95
95
  expect(v.compare(v)).toBe("equal")
@@ -152,10 +152,7 @@ describe("YjsVersion", () => {
152
152
  doc2.getMap("root").set("t", "B")
153
153
 
154
154
  // Sync doc1 → doc2 only (doc2 knows about both, doc1 only knows itself)
155
- const update = Y.encodeStateAsUpdate(
156
- doc1,
157
- Y.encodeStateVector(doc2),
158
- )
155
+ const update = Y.encodeStateAsUpdate(doc1, Y.encodeStateVector(doc2))
159
156
  Y.applyUpdate(doc2, update)
160
157
 
161
158
  const v1 = new YjsVersion(Y.encodeStateVector(doc1))
@@ -175,14 +172,8 @@ describe("YjsVersion", () => {
175
172
  doc2.getMap("root").set("t", "B")
176
173
 
177
174
  // Bidirectional sync
178
- const u1to2 = Y.encodeStateAsUpdate(
179
- doc1,
180
- Y.encodeStateVector(doc2),
181
- )
182
- const u2to1 = Y.encodeStateAsUpdate(
183
- doc2,
184
- Y.encodeStateVector(doc1),
185
- )
175
+ const u1to2 = Y.encodeStateAsUpdate(doc1, Y.encodeStateVector(doc2))
176
+ const u2to1 = Y.encodeStateAsUpdate(doc2, Y.encodeStateVector(doc1))
186
177
  Y.applyUpdate(doc2, u1to2)
187
178
  Y.applyUpdate(doc1, u2to1)
188
179
 
@@ -196,6 +187,7 @@ describe("YjsVersion", () => {
196
187
  const fake = {
197
188
  serialize: () => "fake",
198
189
  compare: () => "equal" as const,
190
+ meet: () => fake,
199
191
  }
200
192
  expect(() => v.compare(fake)).toThrow(
201
193
  "YjsVersion can only be compared with another YjsVersion",
@@ -224,4 +216,78 @@ describe("YjsVersion", () => {
224
216
  expect(late.compare(earlyParsed)).toBe("ahead")
225
217
  })
226
218
  })
227
- })
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
+ })
293
+ })