@kyneta/yjs-schema 1.6.1 → 1.8.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 +2 -0
- package/dist/index.d.ts +17 -61
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +222 -207
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/__tests__/create.test.ts +11 -0
- package/src/__tests__/eager-write-coherence.test.ts +321 -0
- package/src/__tests__/materialize.test.ts +227 -0
- package/src/__tests__/position.test.ts +7 -7
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +56 -3
- package/src/bind-yjs.ts +3 -5
- package/src/change-mapping.ts +73 -48
- package/src/index.ts +1 -2
- package/src/materialize.ts +109 -0
- package/src/populate.ts +35 -37
- package/src/substrate.ts +277 -111
- package/src/yjs-extract.ts +52 -0
- package/src/yjs-resolve.ts +30 -95
- package/src/__tests__/reader.test.ts +0 -685
- package/src/reader.ts +0 -174
|
@@ -60,7 +60,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
60
60
|
// Peer A creates doc and writes text
|
|
61
61
|
const docA = new Y.Doc()
|
|
62
62
|
docA.clientID = 100
|
|
63
|
-
ensureContainers(docA, TestSchema,
|
|
63
|
+
ensureContainers(docA, TestSchema, binding)
|
|
64
64
|
docA.transact(() => {
|
|
65
65
|
const root = docA.getMap("root")
|
|
66
66
|
;(root.get(id("title")) as Y.Text).insert(0, "Hello from A")
|
|
@@ -70,7 +70,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
70
70
|
// Peer B independently creates same doc and writes different text
|
|
71
71
|
const docB = new Y.Doc()
|
|
72
72
|
docB.clientID = 200
|
|
73
|
-
ensureContainers(docB, TestSchema,
|
|
73
|
+
ensureContainers(docB, TestSchema, binding)
|
|
74
74
|
docB.transact(() => {
|
|
75
75
|
const root = docB.getMap("root")
|
|
76
76
|
;(root.get(id("title")) as Y.Text).insert(0, "Hello from B")
|
|
@@ -105,7 +105,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
105
105
|
const docs = [100, 200, 300].map(cid => {
|
|
106
106
|
const doc = new Y.Doc()
|
|
107
107
|
doc.clientID = cid
|
|
108
|
-
ensureContainers(doc, TestSchema,
|
|
108
|
+
ensureContainers(doc, TestSchema, binding)
|
|
109
109
|
doc.transact(() => {
|
|
110
110
|
const root = doc.getMap("root")
|
|
111
111
|
;(root.get(id("title")) as Y.Text).insert(0, `Peer${cid}`)
|
|
@@ -145,7 +145,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
145
145
|
// Create and write
|
|
146
146
|
const doc1 = new Y.Doc()
|
|
147
147
|
doc1.clientID = 42
|
|
148
|
-
ensureContainers(doc1, TestSchema,
|
|
148
|
+
ensureContainers(doc1, TestSchema, binding)
|
|
149
149
|
doc1.transact(() => {
|
|
150
150
|
const root = doc1.getMap("root")
|
|
151
151
|
;(root.get(id("title")) as Y.Text).insert(0, "Persistent")
|
|
@@ -159,7 +159,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
159
159
|
const doc2 = new Y.Doc()
|
|
160
160
|
doc2.clientID = 42
|
|
161
161
|
Y.applyUpdate(doc2, snapshot)
|
|
162
|
-
ensureContainers(doc2, TestSchema,
|
|
162
|
+
ensureContainers(doc2, TestSchema, binding)
|
|
163
163
|
|
|
164
164
|
// Data preserved
|
|
165
165
|
const root2 = doc2.getMap("root")
|
|
@@ -190,10 +190,10 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
190
190
|
const bindingB = trivialBinding(schemaB)
|
|
191
191
|
|
|
192
192
|
const docA = new Y.Doc()
|
|
193
|
-
ensureContainers(docA, schemaA,
|
|
193
|
+
ensureContainers(docA, schemaA, bindingA)
|
|
194
194
|
|
|
195
195
|
const docB = new Y.Doc()
|
|
196
|
-
ensureContainers(docB, schemaB,
|
|
196
|
+
ensureContainers(docB, schemaB, bindingB)
|
|
197
197
|
|
|
198
198
|
// Both should produce byte-identical structural state
|
|
199
199
|
const stateA = Y.encodeStateAsUpdate(docA)
|
|
@@ -221,7 +221,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
221
221
|
// Peer A: create v1, write data, export
|
|
222
222
|
const docA = new Y.Doc()
|
|
223
223
|
docA.clientID = 100
|
|
224
|
-
ensureContainers(docA, v1Schema,
|
|
224
|
+
ensureContainers(docA, v1Schema, v1Binding)
|
|
225
225
|
docA.transact(() => {
|
|
226
226
|
const root = docA.getMap("root")
|
|
227
227
|
;(root.get(id("title")) as Y.Text).insert(0, "Title")
|
|
@@ -229,17 +229,17 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
229
229
|
})
|
|
230
230
|
const v1State = Y.encodeStateAsUpdate(docA)
|
|
231
231
|
|
|
232
|
-
// Peer B: independently create v2, hydrate v1 data,
|
|
232
|
+
// Peer B: independently create v2, hydrate v1 data, ensure containers
|
|
233
233
|
const docB = new Y.Doc()
|
|
234
234
|
docB.clientID = 200
|
|
235
235
|
Y.applyUpdate(docB, v1State)
|
|
236
|
-
ensureContainers(docB, v2Schema,
|
|
236
|
+
ensureContainers(docB, v2Schema, v2Binding)
|
|
237
237
|
|
|
238
238
|
// Another peer C: same thing independently
|
|
239
239
|
const docC = new Y.Doc()
|
|
240
240
|
docC.clientID = 300
|
|
241
241
|
Y.applyUpdate(docC, v1State)
|
|
242
|
-
ensureContainers(docC, v2Schema,
|
|
242
|
+
ensureContainers(docC, v2Schema, v2Binding)
|
|
243
243
|
|
|
244
244
|
// B and C's structural ops for "notes" should be identical (both at clientID 0)
|
|
245
245
|
const stateB = Y.encodeStateAsUpdate(docB)
|
|
@@ -270,7 +270,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
270
270
|
|
|
271
271
|
const doc = new Y.Doc()
|
|
272
272
|
doc.clientID = 999
|
|
273
|
-
ensureContainers(doc, TestSchema,
|
|
273
|
+
ensureContainers(doc, TestSchema, binding)
|
|
274
274
|
|
|
275
275
|
// clientID should be restored
|
|
276
276
|
expect(doc.clientID).toBe(999)
|
|
@@ -285,7 +285,7 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
285
285
|
|
|
286
286
|
const doc = new Y.Doc()
|
|
287
287
|
doc.clientID = 777
|
|
288
|
-
ensureContainers(doc, TestSchema,
|
|
288
|
+
ensureContainers(doc, TestSchema, binding)
|
|
289
289
|
|
|
290
290
|
// The state vector should NOT contain the caller's clientID —
|
|
291
291
|
// only STRUCTURAL_YJS_CLIENT_ID (0) should have produced ops.
|
|
@@ -399,19 +399,19 @@ describe("structural merge protocol (Yjs)", () => {
|
|
|
399
399
|
expect(rootA.get(id("count"))).toBe(rootB.get(id("count")))
|
|
400
400
|
})
|
|
401
401
|
|
|
402
|
-
// ──
|
|
402
|
+
// ── ensureContainers is idempotent ──
|
|
403
403
|
|
|
404
|
-
it("
|
|
404
|
+
it("ensureContainers after hydration preserves existing data", () => {
|
|
405
405
|
const binding = trivialBinding(TestSchema)
|
|
406
406
|
|
|
407
407
|
const doc = new Y.Doc()
|
|
408
408
|
doc.clientID = 50
|
|
409
|
-
ensureContainers(doc, TestSchema,
|
|
409
|
+
ensureContainers(doc, TestSchema, binding)
|
|
410
410
|
|
|
411
411
|
const stateBefore = Y.encodeStateAsUpdate(doc)
|
|
412
412
|
|
|
413
|
-
//
|
|
414
|
-
ensureContainers(doc, TestSchema,
|
|
413
|
+
// Repeated call should not create new ops (everything already exists)
|
|
414
|
+
ensureContainers(doc, TestSchema, binding)
|
|
415
415
|
|
|
416
416
|
const stateAfter = Y.encodeStateAsUpdate(doc)
|
|
417
417
|
expect(stateAfter).toEqual(stateBefore)
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
RawPath,
|
|
9
9
|
Schema,
|
|
10
10
|
subscribe,
|
|
11
|
+
unwrap,
|
|
11
12
|
version,
|
|
12
13
|
} from "@kyneta/schema"
|
|
13
14
|
import { describe, expect, it } from "vitest"
|
|
@@ -618,7 +619,7 @@ describe("YjsSubstrate", () => {
|
|
|
618
619
|
// Context: jj:qpultxsw.
|
|
619
620
|
|
|
620
621
|
describe("re-entrant write during merge replay", () => {
|
|
621
|
-
it("subscriber's local change() inside a merge-replay batch lands in Yjs",
|
|
622
|
+
it("subscriber's local change() inside a merge-replay batch lands in Yjs", () => {
|
|
622
623
|
const docA = createDoc(yjs.bind(SimpleSchema))
|
|
623
624
|
const docB = createDoc(yjs.bind(SimpleSchema))
|
|
624
625
|
|
|
@@ -647,11 +648,63 @@ describe("YjsSubstrate", () => {
|
|
|
647
648
|
const delta = exportSince(docA, v0)!
|
|
648
649
|
merge(docB, delta, { origin: "sync" })
|
|
649
650
|
|
|
650
|
-
await new Promise<void>(r => queueMicrotask(r))
|
|
651
|
-
|
|
652
651
|
expect(docB.title()).toBe("seedmore")
|
|
653
652
|
expect(docB.count()).toBe(42)
|
|
654
653
|
expect(writes).toBe(1)
|
|
655
654
|
})
|
|
656
655
|
})
|
|
656
|
+
|
|
657
|
+
// -------------------------------------------------------------------------
|
|
658
|
+
// Origin-free discriminator tests
|
|
659
|
+
// -------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
describe("origin-free discriminator", () => {
|
|
662
|
+
it("options.origin survives to transaction.origin", () => {
|
|
663
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
664
|
+
const native = unwrap(doc) as Y.Doc
|
|
665
|
+
|
|
666
|
+
let capturedOrigin: string | undefined = "not-called"
|
|
667
|
+
native.on("afterTransaction", tr => {
|
|
668
|
+
capturedOrigin = tr.origin
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
change(doc, d => d.title.insert(0, "x"), { origin: "undo" })
|
|
672
|
+
expect(capturedOrigin).toBe("undo")
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it("external wrapping kyneta is correctly classified as own", () => {
|
|
676
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
677
|
+
|
|
678
|
+
let kynetaFires = 0
|
|
679
|
+
subscribe(doc, () => {
|
|
680
|
+
kynetaFires++
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
const native = unwrap(doc) as Y.Doc
|
|
684
|
+
native.transact(() => {
|
|
685
|
+
change(doc, d => d.title.insert(0, "x"))
|
|
686
|
+
}, "external")
|
|
687
|
+
|
|
688
|
+
// Should fire exactly once (captured via wrappedPrepare),
|
|
689
|
+
// and NOT twice (the bridge should skip the external transaction
|
|
690
|
+
// because the inner kyneta transact marked the transaction).
|
|
691
|
+
expect(kynetaFires).toBe(1)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it("external raw transact with any string origin is bridged", () => {
|
|
695
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
696
|
+
|
|
697
|
+
let kynetaFires = 0
|
|
698
|
+
subscribe(doc, () => {
|
|
699
|
+
kynetaFires++
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
const native = unwrap(doc) as Y.Doc
|
|
703
|
+
native.transact(() => {
|
|
704
|
+
native.getMap("root").set("title", "ext")
|
|
705
|
+
}, "kyneta-prepare")
|
|
706
|
+
|
|
707
|
+
expect(kynetaFires).toBe(1)
|
|
708
|
+
})
|
|
709
|
+
})
|
|
657
710
|
})
|
package/src/bind-yjs.ts
CHANGED
|
@@ -105,17 +105,15 @@ function createYjsFactory(
|
|
|
105
105
|
// Set stable identity AFTER hydration — avoids Yjs clientID
|
|
106
106
|
// conflict detection that would reassign to a random value.
|
|
107
107
|
doc.clientID = numericClientId
|
|
108
|
-
|
|
109
|
-
// from hydrated state (each set() is a CRDT write).
|
|
110
|
-
ensureContainers(doc, schema, true, binding)
|
|
108
|
+
ensureContainers(doc, schema, binding)
|
|
111
109
|
return createYjsSubstrate(doc, schema, binding)
|
|
112
110
|
},
|
|
113
111
|
|
|
114
112
|
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
115
|
-
// Fresh doc — set identity immediately
|
|
113
|
+
// Fresh doc — set identity immediately.
|
|
116
114
|
const doc = new Y.Doc()
|
|
117
115
|
doc.clientID = numericClientId
|
|
118
|
-
ensureContainers(doc, schema,
|
|
116
|
+
ensureContainers(doc, schema, binding)
|
|
119
117
|
return createYjsSubstrate(doc, schema, binding)
|
|
120
118
|
},
|
|
121
119
|
|
package/src/change-mapping.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
MapChange,
|
|
23
23
|
Op,
|
|
24
24
|
Path,
|
|
25
|
+
ProductSchema,
|
|
25
26
|
ReplaceChange,
|
|
26
27
|
RichTextChange,
|
|
27
28
|
RichTextInstruction,
|
|
@@ -33,9 +34,11 @@ import type {
|
|
|
33
34
|
TextInstruction,
|
|
34
35
|
} from "@kyneta/schema"
|
|
35
36
|
import {
|
|
36
|
-
advanceSchema,
|
|
37
37
|
expandMapOpsToLeaves,
|
|
38
|
+
isJsonBoundary,
|
|
39
|
+
isPlainObject,
|
|
38
40
|
KIND,
|
|
41
|
+
pathSchema,
|
|
39
42
|
RawPath,
|
|
40
43
|
richTextChange,
|
|
41
44
|
} from "@kyneta/schema"
|
|
@@ -118,6 +121,17 @@ export function applyChangeToYjs(
|
|
|
118
121
|
`Attempted TreeChange at path [${pathToString(path)}].`,
|
|
119
122
|
)
|
|
120
123
|
|
|
124
|
+
case "set-op":
|
|
125
|
+
// Sets (`Schema.set`) are rejected by `yjs.bind` at compile time
|
|
126
|
+
// (`"add-wins-per-key"` is not in `YjsLaws`). Unreachable from any
|
|
127
|
+
// bound Yjs substrate today; kept against the new `SetChange`
|
|
128
|
+
// vocabulary so future law-set expansion has a clear extension point.
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Yjs substrate does not support "${change.type}" changes. ` +
|
|
131
|
+
`Schema.set requires "add-wins-per-key" which is not in YjsLaws. ` +
|
|
132
|
+
`Attempted SetChange at path [${pathToString(path)}].`,
|
|
133
|
+
)
|
|
134
|
+
|
|
121
135
|
default:
|
|
122
136
|
throw new Error(
|
|
123
137
|
`applyChangeToYjs: unsupported change type "${change.type}"`,
|
|
@@ -201,7 +215,7 @@ function applySequenceChange(
|
|
|
201
215
|
}
|
|
202
216
|
|
|
203
217
|
// Resolve the item schema for structured insert detection
|
|
204
|
-
const targetSchema =
|
|
218
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
205
219
|
const itemSchema = getItemSchema(targetSchema)
|
|
206
220
|
|
|
207
221
|
let cursor = 0
|
|
@@ -241,7 +255,7 @@ function applyMapChange(
|
|
|
241
255
|
}
|
|
242
256
|
|
|
243
257
|
// Resolve the schema at this path for structured value detection
|
|
244
|
-
const targetSchema =
|
|
258
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
245
259
|
|
|
246
260
|
// Apply deletes first
|
|
247
261
|
if (change.delete) {
|
|
@@ -259,9 +273,10 @@ function applyMapChange(
|
|
|
259
273
|
// For map schemas (records), use the key as-is (no identity-keying).
|
|
260
274
|
let mapKey = key
|
|
261
275
|
if (binding && targetSchema[KIND] === "product") {
|
|
262
|
-
// Compute absolute schema path for this field
|
|
276
|
+
// Compute absolute schema path for this field — only product-field
|
|
277
|
+
// segments contribute (entry segments are runtime keys).
|
|
263
278
|
const parentAbsPath = path.segments
|
|
264
|
-
.filter(s => s.role === "
|
|
279
|
+
.filter(s => s.role === "field")
|
|
265
280
|
.map(s => s.resolve() as string)
|
|
266
281
|
.join(".")
|
|
267
282
|
const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key
|
|
@@ -286,7 +301,7 @@ function applyReplaceChange(
|
|
|
286
301
|
): void {
|
|
287
302
|
if (path.length === 0) {
|
|
288
303
|
throw new Error(
|
|
289
|
-
"
|
|
304
|
+
"Cannot replace the root document struct in a CRDT backend. The root identity is fixed. Please mutate its properties individually (e.g., `doc.myField.set(value)` instead of `doc.set({ myField: value })`).",
|
|
290
305
|
)
|
|
291
306
|
}
|
|
292
307
|
|
|
@@ -303,15 +318,19 @@ function applyReplaceChange(
|
|
|
303
318
|
)
|
|
304
319
|
|
|
305
320
|
const resolved = lastSeg.resolve()
|
|
306
|
-
if (
|
|
321
|
+
if (
|
|
322
|
+
parent instanceof Y.Map &&
|
|
323
|
+
(lastSeg.role === "field" || lastSeg.role === "entry")
|
|
324
|
+
) {
|
|
307
325
|
// Resolve schema for the target field for structured value detection
|
|
308
|
-
const targetSchema =
|
|
326
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
309
327
|
const yjsValue = maybeCreateSharedType(change.value, targetSchema)
|
|
310
|
-
//
|
|
328
|
+
// Identity-keying applies only at product-field boundaries; entry
|
|
329
|
+
// segments use the runtime key as-is.
|
|
311
330
|
let mapKey = resolved as string
|
|
312
|
-
if (binding) {
|
|
331
|
+
if (binding && lastSeg.role === "field") {
|
|
313
332
|
const absPath = path.segments
|
|
314
|
-
.filter(s => s.role === "
|
|
333
|
+
.filter(s => s.role === "field")
|
|
315
334
|
.map(s => s.resolve() as string)
|
|
316
335
|
.join(".")
|
|
317
336
|
const identity = binding.forward.get(absPath) as string | undefined
|
|
@@ -319,7 +338,7 @@ function applyReplaceChange(
|
|
|
319
338
|
}
|
|
320
339
|
parent.set(mapKey, yjsValue)
|
|
321
340
|
} else if (parent instanceof Y.Array && lastSeg.role === "index") {
|
|
322
|
-
const targetSchema =
|
|
341
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
323
342
|
const yjsValue = maybeCreateSharedType(change.value, targetSchema)
|
|
324
343
|
parent.delete(resolved as number, 1)
|
|
325
344
|
parent.insert(resolved as number, [yjsValue])
|
|
@@ -349,6 +368,12 @@ function maybeCreateSharedType(
|
|
|
349
368
|
): unknown {
|
|
350
369
|
if (schema === undefined) return value
|
|
351
370
|
|
|
371
|
+
// JSON-boundary schemas (struct.json/list.json/record.json) store
|
|
372
|
+
// their entire subtree as a plain JSON value in the parent Y.Map
|
|
373
|
+
// entry. Skip Y.Map/Y.Array materialisation and pass the value
|
|
374
|
+
// through unchanged — including nested objects/arrays underneath.
|
|
375
|
+
if (isJsonBoundary(schema)) return value
|
|
376
|
+
|
|
352
377
|
switch (schema[KIND]) {
|
|
353
378
|
// First-class text → Y.Text
|
|
354
379
|
case "text": {
|
|
@@ -383,12 +408,7 @@ function maybeCreateSharedType(
|
|
|
383
408
|
}
|
|
384
409
|
|
|
385
410
|
case "product": {
|
|
386
|
-
if (
|
|
387
|
-
value === null ||
|
|
388
|
-
value === undefined ||
|
|
389
|
-
typeof value !== "object" ||
|
|
390
|
-
Array.isArray(value)
|
|
391
|
-
) {
|
|
411
|
+
if (!isPlainObject(value)) {
|
|
392
412
|
return value
|
|
393
413
|
}
|
|
394
414
|
return createStructuredMap(value as Record<string, unknown>, schema)
|
|
@@ -406,12 +426,7 @@ function maybeCreateSharedType(
|
|
|
406
426
|
}
|
|
407
427
|
|
|
408
428
|
case "map": {
|
|
409
|
-
if (
|
|
410
|
-
value === null ||
|
|
411
|
-
value === undefined ||
|
|
412
|
-
typeof value !== "object" ||
|
|
413
|
-
Array.isArray(value)
|
|
414
|
-
) {
|
|
429
|
+
if (!isPlainObject(value)) {
|
|
415
430
|
return value
|
|
416
431
|
}
|
|
417
432
|
const map = new Y.Map()
|
|
@@ -509,7 +524,7 @@ export function eventsToOps(
|
|
|
509
524
|
const ops: Op[] = []
|
|
510
525
|
|
|
511
526
|
for (const event of events) {
|
|
512
|
-
const kynetaPath = yjsPathToKynetaPath(event.path, binding)
|
|
527
|
+
const kynetaPath = yjsPathToKynetaPath(event.path, schema, binding)
|
|
513
528
|
const change = eventToChange(event, schema, kynetaPath, binding)
|
|
514
529
|
if (change) {
|
|
515
530
|
ops.push({ path: kynetaPath, change })
|
|
@@ -524,31 +539,55 @@ export function eventsToOps(
|
|
|
524
539
|
// ---------------------------------------------------------------------------
|
|
525
540
|
|
|
526
541
|
/**
|
|
527
|
-
* Convert a Yjs event path
|
|
542
|
+
* Convert a Yjs event path to a kyneta `RawPath`, walking the schema
|
|
543
|
+
* alongside so each segment is classified as field / entry / index by
|
|
544
|
+
* the current schema kind.
|
|
528
545
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
546
|
+
* Why schema-aware and not "did the inverse lookup hit?": the binding's
|
|
547
|
+
* inverse map only covers declared product-field positions reachable
|
|
548
|
+
* without crossing a runtime-keyed container. A declared struct field
|
|
549
|
+
* nested under a `record(...)` value type is reachable via Yjs but
|
|
550
|
+
* absent from `binding.inverse` — without the schema walk it would be
|
|
551
|
+
* misclassified as an entry and then rejected by `advanceSchema`.
|
|
531
552
|
*/
|
|
532
553
|
function yjsPathToKynetaPath(
|
|
533
554
|
yjsPath: (string | number)[],
|
|
555
|
+
rootSchema: SchemaNode,
|
|
534
556
|
binding?: SchemaBinding,
|
|
535
557
|
): RawPath {
|
|
536
558
|
let path = RawPath.empty
|
|
559
|
+
let schema: SchemaNode | undefined = rootSchema
|
|
537
560
|
for (const segment of yjsPath) {
|
|
538
561
|
if (typeof segment === "string") {
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
562
|
+
// Inverse-lookup recovers the original declared field name when
|
|
563
|
+
// the segment IS an identity hash; otherwise we keep the string.
|
|
564
|
+
let leaf = segment
|
|
542
565
|
const absPath = binding?.inverse.get(segment as any)
|
|
543
566
|
if (absPath) {
|
|
544
567
|
const lastDot = absPath.lastIndexOf(".")
|
|
545
|
-
|
|
568
|
+
leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
|
|
569
|
+
}
|
|
570
|
+
const kind = schema?.[KIND]
|
|
571
|
+
if (kind === "product") {
|
|
546
572
|
path = path.field(leaf)
|
|
573
|
+
schema = (schema as ProductSchema | undefined)?.fields[leaf]
|
|
574
|
+
} else if (kind === "map" || kind === "set" || kind === "tree") {
|
|
575
|
+
path = path.entry(leaf)
|
|
576
|
+
schema = (schema as any)?.item
|
|
547
577
|
} else {
|
|
548
|
-
|
|
578
|
+
// Unknown / sum / unrecognized — fall back to entry. Subsequent
|
|
579
|
+
// segments are likely walking plain JSON inside a sum variant.
|
|
580
|
+
path = path.entry(leaf)
|
|
581
|
+
schema = undefined
|
|
549
582
|
}
|
|
550
583
|
} else if (typeof segment === "number") {
|
|
551
584
|
path = path.item(segment)
|
|
585
|
+
const kind = schema?.[KIND]
|
|
586
|
+
if (kind === "sequence" || kind === "movable") {
|
|
587
|
+
schema = (schema as any).item
|
|
588
|
+
} else {
|
|
589
|
+
schema = undefined
|
|
590
|
+
}
|
|
552
591
|
}
|
|
553
592
|
}
|
|
554
593
|
return path
|
|
@@ -576,7 +615,7 @@ function eventToChange(
|
|
|
576
615
|
): ChangeBase | null {
|
|
577
616
|
if (event.target instanceof Y.Text) {
|
|
578
617
|
// Both text and richtext use Y.Text — resolve the schema to dispatch.
|
|
579
|
-
const schemaAtPath =
|
|
618
|
+
const schemaAtPath = pathSchema(rootSchema, kynetaPath)
|
|
580
619
|
if (schemaAtPath[KIND] === "richtext") {
|
|
581
620
|
return richTextEventToChange(event)
|
|
582
621
|
}
|
|
@@ -739,20 +778,6 @@ function extractEventValue(value: unknown): unknown {
|
|
|
739
778
|
// Schema helpers
|
|
740
779
|
// ---------------------------------------------------------------------------
|
|
741
780
|
|
|
742
|
-
/**
|
|
743
|
-
* Resolve the schema at a given path by walking through advanceSchema.
|
|
744
|
-
*/
|
|
745
|
-
function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
|
|
746
|
-
let schema = rootSchema
|
|
747
|
-
for (const seg of path.segments) {
|
|
748
|
-
schema = advanceSchema(schema, seg)
|
|
749
|
-
// Sum variants are always PlainSchema — cannot advance further.
|
|
750
|
-
// Return early; remaining segments address plain JSON values.
|
|
751
|
-
if (schema[KIND] === "sum") return schema
|
|
752
|
-
}
|
|
753
|
-
return schema
|
|
754
|
-
}
|
|
755
|
-
|
|
756
781
|
/**
|
|
757
782
|
* Get the item schema from a sequence schema, if available.
|
|
758
783
|
*/
|
package/src/index.ts
CHANGED
|
@@ -50,8 +50,7 @@ export type { YjsNativeMap } from "./native-map.js"
|
|
|
50
50
|
export { ensureContainers } from "./populate.js"
|
|
51
51
|
// Position conformance
|
|
52
52
|
export { fromYjsAssoc, toYjsAssoc, YjsPosition } from "./position.js"
|
|
53
|
-
|
|
54
|
-
export { yjsReader } from "./reader.js"
|
|
53
|
+
|
|
55
54
|
// Substrate
|
|
56
55
|
export {
|
|
57
56
|
createYjsSubstrate,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// materialize — Yjs→PlainState materialization via generic resolver.
|
|
2
|
+
//
|
|
3
|
+
// Implements `createYjsResolver`, a closure-based `MaterializeResolver`
|
|
4
|
+
// that navigates the Yjs shared type tree via `resolveYjsType`. The
|
|
5
|
+
// generic `createMaterializeInterpreter` drives the catamorphism; the
|
|
6
|
+
// resolver handles only the CRDT-specific value extraction.
|
|
7
|
+
//
|
|
8
|
+
// Unsupported types (counter, tree, movable) return `undefined` from
|
|
9
|
+
// the resolver, triggering the generic interpreter's zero fallback.
|
|
10
|
+
//
|
|
11
|
+
// Zero fallback for missing values is handled canonically by the
|
|
12
|
+
// generic interpreter — not inlined here.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
MaterializeResolver,
|
|
16
|
+
Path,
|
|
17
|
+
PlainState,
|
|
18
|
+
RichTextDelta,
|
|
19
|
+
SchemaBinding,
|
|
20
|
+
Schema as SchemaNode,
|
|
21
|
+
} from "@kyneta/schema"
|
|
22
|
+
import {
|
|
23
|
+
createMaterializeInterpreter,
|
|
24
|
+
interpret,
|
|
25
|
+
isNonNullObject,
|
|
26
|
+
materializeContextFromResolver,
|
|
27
|
+
} from "@kyneta/schema"
|
|
28
|
+
import * as Y from "yjs"
|
|
29
|
+
import { extractValue, yTextToRichTextDelta } from "./yjs-extract.js"
|
|
30
|
+
import { resolveYjsType } from "./yjs-resolve.js"
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Yjs resolver
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function createYjsResolver(
|
|
37
|
+
rootMap: Y.Map<any>,
|
|
38
|
+
rootSchema: SchemaNode,
|
|
39
|
+
binding?: SchemaBinding,
|
|
40
|
+
): MaterializeResolver {
|
|
41
|
+
return {
|
|
42
|
+
resolveValue(path: Path): unknown {
|
|
43
|
+
const result = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
44
|
+
return extractValue(result.resolved)
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
resolveText(path: Path): string | undefined {
|
|
48
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
49
|
+
if (resolved instanceof Y.Text) {
|
|
50
|
+
return resolved.toJSON()
|
|
51
|
+
}
|
|
52
|
+
const value = extractValue(resolved)
|
|
53
|
+
return typeof value === "string" ? value : undefined
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Yjs does not support counters — schemas with counter types are
|
|
57
|
+
// rejected at bind time. Return undefined to trigger zero fallback.
|
|
58
|
+
resolveCounter(_path: Path): number | undefined {
|
|
59
|
+
return undefined
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
resolveRichText(path: Path): RichTextDelta | undefined {
|
|
63
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
64
|
+
if (resolved instanceof Y.Text) {
|
|
65
|
+
return yTextToRichTextDelta(resolved)
|
|
66
|
+
}
|
|
67
|
+
return undefined
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
resolveLength(path: Path): number {
|
|
71
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
72
|
+
if (resolved instanceof Y.Array) {
|
|
73
|
+
return resolved.length
|
|
74
|
+
}
|
|
75
|
+
return Array.isArray(resolved) ? resolved.length : 0
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
resolveKeys(path: Path): string[] {
|
|
79
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
80
|
+
if (resolved instanceof Y.Map) {
|
|
81
|
+
return Array.from(resolved.keys())
|
|
82
|
+
}
|
|
83
|
+
return isNonNullObject(resolved) ? Object.keys(resolved) : []
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Yjs has no tree primitive — schemas with `Schema.tree` are rejected
|
|
87
|
+
// at bind time. Defensive [] for any caller that reaches here.
|
|
88
|
+
resolveForest(_path: Path): readonly never[] {
|
|
89
|
+
return []
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Public API
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export function materializeYjsShadow(
|
|
99
|
+
doc: Y.Doc,
|
|
100
|
+
schema: SchemaNode,
|
|
101
|
+
binding?: SchemaBinding,
|
|
102
|
+
): PlainState {
|
|
103
|
+
const rootMap = doc.getMap("root")
|
|
104
|
+
const resolver = createYjsResolver(rootMap, schema, binding)
|
|
105
|
+
const interp = createMaterializeInterpreter(resolver)
|
|
106
|
+
const ctx = materializeContextFromResolver(resolver)
|
|
107
|
+
const result = interpret(schema, interp, ctx)
|
|
108
|
+
return result as PlainState
|
|
109
|
+
}
|