@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.
- package/README.md +28 -25
- package/dist/index.d.ts +185 -86
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1159 -860
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/bind-constraints.test.ts +39 -30
- package/src/__tests__/bind-yjs.test.ts +53 -16
- package/src/__tests__/position.test.ts +376 -0
- package/src/__tests__/structural-merge.test.ts +111 -54
- package/src/__tests__/substrate.test.ts +18 -0
- package/src/__tests__/version.test.ts +87 -0
- package/src/bind-yjs.ts +44 -37
- package/src/change-mapping.ts +219 -25
- package/src/index.ts +3 -1
- package/src/populate.ts +59 -12
- package/src/position.ts +45 -0
- package/src/reader.ts +62 -6
- package/src/substrate.ts +99 -11
- package/src/version.ts +135 -33
- package/src/yjs-resolve.ts +59 -12
|
@@ -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
|
+
})
|