@kyneta/yjs-schema 1.3.1 → 1.4.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.
@@ -0,0 +1,376 @@
1
+ // position.test.ts — YjsPosition conformance suite + concurrent-edit tests.
2
+ //
3
+ // Part 1 (Task 4.4): Runs the shared positionConformance suite against a
4
+ // Yjs-backed factory, proving YjsPosition satisfies the Position contract.
5
+ //
6
+ // Part 2 (Task 4.6): Yjs-specific concurrent-edit tests — two Y.Doc instances
7
+ // make independent edits, sync via Y.applyUpdate, and verify positions
8
+ // resolve correctly on the converged state.
9
+
10
+ import {
11
+ change,
12
+ createRef,
13
+ hasPosition,
14
+ type Instruction,
15
+ isTextChange,
16
+ POSITION,
17
+ type PositionCapable,
18
+ Schema,
19
+ } from "@kyneta/schema"
20
+ import {
21
+ type PositionTestEnv,
22
+ positionConformance,
23
+ } from "@kyneta/schema/src/__tests__/position-conformance.js"
24
+ import { describe, expect, it } from "vitest"
25
+ import * as Y from "yjs"
26
+ import { ensureContainers } from "../populate.js"
27
+ import { createYjsSubstrate } from "../substrate.js"
28
+
29
+ // ===========================================================================
30
+ // Shared test schema
31
+ // ===========================================================================
32
+
33
+ const TextSchema = Schema.struct({
34
+ title: Schema.text(),
35
+ })
36
+
37
+ // ===========================================================================
38
+ // Part 1: Conformance suite — YjsPosition
39
+ // ===========================================================================
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Factory: creates a Y.Doc-backed PositionTestEnv for the conformance suite.
43
+ //
44
+ // YjsPosition.transform() is a no-op — Yjs RelativePositions resolve
45
+ // statelessly against the document's item graph. The conformance suite calls
46
+ // transform() then resolve(); since resolve() queries the live Y.Doc, the
47
+ // tests pass because the underlying Yjs state already reflects the mutations.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function createYjsEnv(initialText: string): PositionTestEnv {
51
+ const doc = new Y.Doc()
52
+ ensureContainers(doc, TextSchema)
53
+ const substrate = createYjsSubstrate(doc, TextSchema)
54
+ const ref = createRef(TextSchema, substrate) as any
55
+
56
+ // Seed the initial text content
57
+ if (initialText.length > 0) {
58
+ change(ref, (d: any) => {
59
+ d.title.insert(0, initialText)
60
+ })
61
+ }
62
+
63
+ // Extract the PositionCapable from the text ref
64
+ const textRef = ref.title
65
+ if (!hasPosition(textRef)) {
66
+ throw new Error("Yjs text ref missing [POSITION] capability")
67
+ }
68
+ const positions: PositionCapable = textRef[POSITION]
69
+
70
+ return {
71
+ positions,
72
+
73
+ insert(index: number, text: string): readonly Instruction[] {
74
+ const ops = change(ref, (d: any) => {
75
+ d.title.insert(index, text)
76
+ })
77
+ const textOp = ops.find(op => isTextChange(op.change))
78
+ if (!textOp || !isTextChange(textOp.change)) {
79
+ throw new Error("insert did not produce a TextChange op")
80
+ }
81
+ return textOp.change.instructions
82
+ },
83
+
84
+ delete(index: number, count: number): readonly Instruction[] {
85
+ const ops = change(ref, (d: any) => {
86
+ d.title.delete(index, count)
87
+ })
88
+ const textOp = ops.find(op => isTextChange(op.change))
89
+ if (!textOp || !isTextChange(textOp.change)) {
90
+ throw new Error("delete did not produce a TextChange op")
91
+ }
92
+ return textOp.change.instructions
93
+ },
94
+
95
+ currentText(): string {
96
+ return ref.title()
97
+ },
98
+ }
99
+ }
100
+
101
+ positionConformance(createYjsEnv, { cursorModel: "identity" })
102
+
103
+ // ===========================================================================
104
+ // Part 2: Yjs-specific concurrent-edit tests
105
+ // ===========================================================================
106
+
107
+ describe("YjsPosition: concurrent edits", () => {
108
+ it("positions resolve correctly after sync", () => {
109
+ // --- Doc 1: create and seed ---
110
+ const doc1 = new Y.Doc()
111
+ ensureContainers(doc1, TextSchema)
112
+ const substrate1 = createYjsSubstrate(doc1, TextSchema)
113
+ const ref1 = createRef(TextSchema, substrate1) as any
114
+ change(ref1, (d: any) => {
115
+ d.title.insert(0, "hello")
116
+ })
117
+
118
+ // --- Doc 2: fork from doc1's state ---
119
+ const doc2 = new Y.Doc()
120
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
121
+ ensureContainers(doc2, TextSchema, true)
122
+ const substrate2 = createYjsSubstrate(doc2, TextSchema)
123
+ const ref2 = createRef(TextSchema, substrate2) as any
124
+
125
+ // Sanity: both docs agree on initial text
126
+ expect(ref1.title()).toBe("hello")
127
+ expect(ref2.title()).toBe("hello")
128
+
129
+ // --- Create positions before concurrent edits ---
130
+ const textRef1 = ref1.title
131
+ if (!hasPosition(textRef1)) throw new Error("missing [POSITION] on ref1")
132
+ const pos1 = textRef1[POSITION].createPosition(2, "right") // after "he"
133
+
134
+ const textRef2 = ref2.title
135
+ if (!hasPosition(textRef2)) throw new Error("missing [POSITION] on ref2")
136
+ const pos2 = textRef2[POSITION].createPosition(4, "left") // before "o"
137
+
138
+ // --- Concurrent edits ---
139
+ change(ref1, (d: any) => {
140
+ d.title.insert(0, "AA") // doc1: "AAhello"
141
+ })
142
+ change(ref2, (d: any) => {
143
+ d.title.insert(5, "BB") // doc2: "helloBBo"
144
+ })
145
+
146
+ // --- Sync ---
147
+ Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2))
148
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
149
+
150
+ // Both docs should converge to the same text
151
+ const finalText = ref1.title()
152
+ expect(ref2.title()).toBe(finalText)
153
+
154
+ // Positions should resolve to valid indices on the converged doc
155
+ const idx1 = pos1.resolve()
156
+ const idx2 = pos2.resolve()
157
+ expect(idx1).not.toBeNull()
158
+ expect(idx2).not.toBeNull()
159
+ expect(idx1!).toBeGreaterThanOrEqual(0)
160
+ expect(idx2!).toBeGreaterThanOrEqual(0)
161
+ expect(idx1!).toBeLessThanOrEqual(finalText.length)
162
+ expect(idx2!).toBeLessThanOrEqual(finalText.length)
163
+ })
164
+
165
+ it("sticky side preserved through concurrent inserts at same position", () => {
166
+ // --- Doc 1: create and seed ---
167
+ const doc1 = new Y.Doc()
168
+ ensureContainers(doc1, TextSchema)
169
+ const substrate1 = createYjsSubstrate(doc1, TextSchema)
170
+ const ref1 = createRef(TextSchema, substrate1) as any
171
+ change(ref1, (d: any) => {
172
+ d.title.insert(0, "abc")
173
+ })
174
+
175
+ // --- Doc 2: fork ---
176
+ const doc2 = new Y.Doc()
177
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
178
+ ensureContainers(doc2, TextSchema, true)
179
+ const substrate2 = createYjsSubstrate(doc2, TextSchema)
180
+ const ref2 = createRef(TextSchema, substrate2) as any
181
+
182
+ // Left-sticky position at index 1 on doc1
183
+ const textRef1 = ref1.title
184
+ if (!hasPosition(textRef1)) throw new Error("missing [POSITION]")
185
+ const leftPos = textRef1[POSITION].createPosition(1, "left")
186
+
187
+ // Right-sticky position at index 1 on doc2
188
+ const textRef2 = ref2.title
189
+ if (!hasPosition(textRef2)) throw new Error("missing [POSITION]")
190
+ const rightPos = textRef2[POSITION].createPosition(1, "right")
191
+
192
+ // --- Both insert at index 1 concurrently ---
193
+ change(ref1, (d: any) => {
194
+ d.title.insert(1, "X")
195
+ })
196
+ change(ref2, (d: any) => {
197
+ d.title.insert(1, "Y")
198
+ })
199
+
200
+ // --- Sync ---
201
+ Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2))
202
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
203
+
204
+ // Both docs should converge
205
+ const finalText = ref1.title()
206
+ expect(ref2.title()).toBe(finalText)
207
+
208
+ // Both positions should resolve to valid indices
209
+ const leftIdx = leftPos.resolve()
210
+ const rightIdx = rightPos.resolve()
211
+ expect(leftIdx).not.toBeNull()
212
+ expect(rightIdx).not.toBeNull()
213
+
214
+ // Left-sticky should be ≤ right-sticky: the left-sticky cursor
215
+ // stays before insertions at its gap, while right-sticky shifts past.
216
+ expect(leftIdx!).toBeLessThanOrEqual(rightIdx!)
217
+ })
218
+
219
+ it("position survives deletion on remote peer and re-insertion", () => {
220
+ // --- Doc 1: create and seed ---
221
+ const doc1 = new Y.Doc()
222
+ ensureContainers(doc1, TextSchema)
223
+ const substrate1 = createYjsSubstrate(doc1, TextSchema)
224
+ const ref1 = createRef(TextSchema, substrate1) as any
225
+ change(ref1, (d: any) => {
226
+ d.title.insert(0, "abcde")
227
+ })
228
+
229
+ // --- Doc 2: fork ---
230
+ const doc2 = new Y.Doc()
231
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
232
+ ensureContainers(doc2, TextSchema, true)
233
+ const substrate2 = createYjsSubstrate(doc2, TextSchema)
234
+ const ref2 = createRef(TextSchema, substrate2) as any
235
+
236
+ // Position at index 3 on doc1
237
+ const textRef1 = ref1.title
238
+ if (!hasPosition(textRef1)) throw new Error("missing [POSITION]")
239
+ const pos = textRef1[POSITION].createPosition(3, "right")
240
+ expect(pos.resolve()).toBe(3)
241
+
242
+ // Doc2 deletes the range covering position 3
243
+ change(ref2, (d: any) => {
244
+ d.title.delete(1, 3) // "ae"
245
+ })
246
+
247
+ // Sync doc2's deletion into doc1
248
+ Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2))
249
+
250
+ // Position should still resolve (Yjs RelativePositions survive deletion
251
+ // — they bind to item IDs, and deleted items remain in the CRDT graph)
252
+ const afterDelete = pos.resolve()
253
+ expect(afterDelete).not.toBeNull()
254
+ expect(afterDelete!).toBeGreaterThanOrEqual(0)
255
+ expect(afterDelete!).toBeLessThanOrEqual(ref1.title().length)
256
+
257
+ // Now insert new content near the collapsed position
258
+ change(ref1, (d: any) => {
259
+ d.title.insert(1, "XYZ")
260
+ })
261
+
262
+ // Position should still resolve to a valid index
263
+ const afterInsert = pos.resolve()
264
+ expect(afterInsert).not.toBeNull()
265
+ expect(afterInsert!).toBeGreaterThanOrEqual(0)
266
+ expect(afterInsert!).toBeLessThanOrEqual(ref1.title().length)
267
+ })
268
+
269
+ it("encode/decode round-trip works across synced documents", () => {
270
+ // --- Doc 1: create and seed ---
271
+ const doc1 = new Y.Doc()
272
+ ensureContainers(doc1, TextSchema)
273
+ const substrate1 = createYjsSubstrate(doc1, TextSchema)
274
+ const ref1 = createRef(TextSchema, substrate1) as any
275
+ change(ref1, (d: any) => {
276
+ d.title.insert(0, "hello world")
277
+ })
278
+
279
+ // Create a position and encode it
280
+ const textRef1 = ref1.title
281
+ if (!hasPosition(textRef1)) throw new Error("missing [POSITION]")
282
+ const pos = textRef1[POSITION].createPosition(5, "left")
283
+ const encoded = pos.encode()
284
+
285
+ // Decode on a different doc that has the same state
286
+ const doc2 = new Y.Doc()
287
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
288
+ ensureContainers(doc2, TextSchema, true)
289
+ const substrate2 = createYjsSubstrate(doc2, TextSchema)
290
+ const ref2 = createRef(TextSchema, substrate2) as any
291
+
292
+ const textRef2 = ref2.title
293
+ if (!hasPosition(textRef2)) throw new Error("missing [POSITION]")
294
+ const decoded = textRef2[POSITION].decodePosition(encoded)
295
+
296
+ // Both should resolve to the same index
297
+ expect(decoded.resolve()).toBe(pos.resolve())
298
+ expect(decoded.side).toBe(pos.side)
299
+ })
300
+
301
+ it("multiple positions from three peers all resolve after full mesh sync", () => {
302
+ // --- Create 3 peers ---
303
+ const doc1 = new Y.Doc()
304
+ ensureContainers(doc1, TextSchema)
305
+ const substrate1 = createYjsSubstrate(doc1, TextSchema)
306
+ const ref1 = createRef(TextSchema, substrate1) as any
307
+ change(ref1, (d: any) => {
308
+ d.title.insert(0, "0123456789")
309
+ })
310
+
311
+ const doc2 = new Y.Doc()
312
+ Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
313
+ ensureContainers(doc2, TextSchema, true)
314
+ const substrate2 = createYjsSubstrate(doc2, TextSchema)
315
+ const ref2 = createRef(TextSchema, substrate2) as any
316
+
317
+ const doc3 = new Y.Doc()
318
+ Y.applyUpdate(doc3, Y.encodeStateAsUpdate(doc1))
319
+ ensureContainers(doc3, TextSchema, true)
320
+ const substrate3 = createYjsSubstrate(doc3, TextSchema)
321
+ const ref3 = createRef(TextSchema, substrate3) as any
322
+
323
+ // Each peer creates a position
324
+ const t1 = ref1.title
325
+ if (!hasPosition(t1)) throw new Error("missing [POSITION]")
326
+ const posA = t1[POSITION].createPosition(2, "right")
327
+
328
+ const t2 = ref2.title
329
+ if (!hasPosition(t2)) throw new Error("missing [POSITION]")
330
+ const posB = t2[POSITION].createPosition(5, "left")
331
+
332
+ const t3 = ref3.title
333
+ if (!hasPosition(t3)) throw new Error("missing [POSITION]")
334
+ const posC = t3[POSITION].createPosition(8, "right")
335
+
336
+ // Concurrent edits from all three peers
337
+ change(ref1, (d: any) => {
338
+ d.title.insert(0, "AA")
339
+ })
340
+ change(ref2, (d: any) => {
341
+ d.title.insert(5, "BB")
342
+ })
343
+ change(ref3, (d: any) => {
344
+ d.title.delete(7, 2)
345
+ })
346
+
347
+ // Full mesh sync: every peer gets every other peer's changes
348
+ const update1 = Y.encodeStateAsUpdate(doc1)
349
+ const update2 = Y.encodeStateAsUpdate(doc2)
350
+ const update3 = Y.encodeStateAsUpdate(doc3)
351
+
352
+ Y.applyUpdate(doc1, update2)
353
+ Y.applyUpdate(doc1, update3)
354
+ Y.applyUpdate(doc2, update1)
355
+ Y.applyUpdate(doc2, update3)
356
+ Y.applyUpdate(doc3, update1)
357
+ Y.applyUpdate(doc3, update2)
358
+
359
+ // All three docs should converge
360
+ const finalText = ref1.title()
361
+ expect(ref2.title()).toBe(finalText)
362
+ expect(ref3.title()).toBe(finalText)
363
+
364
+ // All positions should resolve to valid indices
365
+ for (const [label, pos] of [
366
+ ["posA", posA],
367
+ ["posB", posB],
368
+ ["posC", posC],
369
+ ] as const) {
370
+ const idx = pos.resolve()
371
+ expect(idx, `${label} should resolve`).not.toBeNull()
372
+ expect(idx!, `${label} >= 0`).toBeGreaterThanOrEqual(0)
373
+ expect(idx!, `${label} <= length`).toBeLessThanOrEqual(finalText.length)
374
+ }
375
+ })
376
+ })