@kyneta/yjs-schema 1.7.0 → 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.map +1 -1
- package/dist/index.js +92 -34
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/create.test.ts +11 -0
- package/src/__tests__/eager-write-coherence.test.ts +321 -0
- package/src/__tests__/substrate.test.ts +55 -0
- package/src/change-mapping.ts +11 -13
- package/src/populate.ts +13 -1
- package/src/substrate.ts +265 -112
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// eager-write-coherence — pins the post-Phase-3 contract for the Yjs
|
|
2
|
+
// substrate's write path:
|
|
3
|
+
//
|
|
4
|
+
// 1. Re-entry: subscriber callbacks may freely `change()` the doc.
|
|
5
|
+
// Substrate writes land synchronously; both reads (σ via the
|
|
6
|
+
// Reader) AND subsequent writes (λ via applyChangeToYjs) succeed
|
|
7
|
+
// against the new state.
|
|
8
|
+
// 2. Projection law: `σ ≡ Π(λ)` holds at every prepare boundary
|
|
9
|
+
// (asserted via deep-equal between the substrate's reader view
|
|
10
|
+
// and a fresh `materializeYjsShadow` after a non-trivial
|
|
11
|
+
// mutation sequence).
|
|
12
|
+
// 3. Json-boundary storage: `struct.json` / `record.json` subtrees
|
|
13
|
+
// round-trip as plain JSON values in the parent Y.Map entry, not
|
|
14
|
+
// as nested Y.Map containers.
|
|
15
|
+
//
|
|
16
|
+
// Yjs doesn't need a nested-commit semantics test (4.7) because its
|
|
17
|
+
// native `Y.transact` already collapses re-entrant nesting for free.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
change,
|
|
21
|
+
interpret,
|
|
22
|
+
observation,
|
|
23
|
+
readable,
|
|
24
|
+
Schema,
|
|
25
|
+
subscribe,
|
|
26
|
+
unwrap,
|
|
27
|
+
writable,
|
|
28
|
+
} from "@kyneta/schema"
|
|
29
|
+
import { describe, expect, it } from "vitest"
|
|
30
|
+
import * as Y from "yjs"
|
|
31
|
+
import { materializeYjsShadow } from "../materialize.js"
|
|
32
|
+
import { ensureContainers } from "../populate.js"
|
|
33
|
+
import { createYjsSubstrate, yjsSubstrateFactory } from "../substrate.js"
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Harness
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function build<S extends ReturnType<typeof Schema.struct>>(schema: S) {
|
|
40
|
+
const substrate = yjsSubstrateFactory.create(schema)
|
|
41
|
+
const doc = interpret(schema, substrate.context())
|
|
42
|
+
.with(readable)
|
|
43
|
+
.with(writable)
|
|
44
|
+
.with(observation)
|
|
45
|
+
.done() as any
|
|
46
|
+
return { substrate, doc }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Unbound variant — bypasses the trivialBinding that identity-keys
|
|
50
|
+
// product fields, so raw-name `materializeYjsShadow(doc, schema)` /
|
|
51
|
+
// `rootMap.get(name)` calls in tests round-trip with the substrate.
|
|
52
|
+
function buildUnbound<S extends ReturnType<typeof Schema.struct>>(schema: S) {
|
|
53
|
+
const doc = new Y.Doc()
|
|
54
|
+
ensureContainers(doc, schema)
|
|
55
|
+
const substrate = createYjsSubstrate(doc, schema)
|
|
56
|
+
const view = interpret(schema, substrate.context())
|
|
57
|
+
.with(readable)
|
|
58
|
+
.with(writable)
|
|
59
|
+
.with(observation)
|
|
60
|
+
.done() as any
|
|
61
|
+
return { substrate, doc: view }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// 1. Re-entry — subscriber writes after subscriber push
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe("Yjs re-entry: subscriber writes after subscriber push", () => {
|
|
69
|
+
it("substrate-write timing: push then set inside the just-pushed item", () => {
|
|
70
|
+
const schema = Schema.struct({
|
|
71
|
+
events: Schema.list(
|
|
72
|
+
Schema.struct({ kind: Schema.string(), body: Schema.string() }),
|
|
73
|
+
),
|
|
74
|
+
})
|
|
75
|
+
const { doc } = build(schema)
|
|
76
|
+
|
|
77
|
+
subscribe(doc.events, () => {
|
|
78
|
+
if ((doc.events as any).length !== 1) return
|
|
79
|
+
change(doc, (d: any) => {
|
|
80
|
+
d.events.push({ kind: "assistant", body: "" })
|
|
81
|
+
})
|
|
82
|
+
change(doc, (d: any) => {
|
|
83
|
+
d.events.at(1).body.set("hello")
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(() => {
|
|
88
|
+
change(doc, (d: any) => {
|
|
89
|
+
d.events.push({ kind: "user", body: "hi" })
|
|
90
|
+
})
|
|
91
|
+
}).not.toThrow()
|
|
92
|
+
|
|
93
|
+
expect((doc.events as any).length).toBe(2)
|
|
94
|
+
expect((doc.events as any).at(1).body()).toBe("hello")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("read-your-writes: re-entrant read inside subscriber sees just-pushed item", () => {
|
|
98
|
+
const schema = Schema.struct({
|
|
99
|
+
items: Schema.list(Schema.struct({ name: Schema.string() })),
|
|
100
|
+
})
|
|
101
|
+
const { doc } = build(schema)
|
|
102
|
+
|
|
103
|
+
let observed: string | undefined
|
|
104
|
+
subscribe(doc.items, () => {
|
|
105
|
+
if ((doc.items as any).length !== 1) return
|
|
106
|
+
change(doc, (d: any) => {
|
|
107
|
+
d.items.push({ name: "synthesised" })
|
|
108
|
+
})
|
|
109
|
+
observed = (doc.items as any).at(1).name()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
change(doc, (d: any) => {
|
|
113
|
+
d.items.push({ name: "user" })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(observed).toBe("synthesised")
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// 2. Projection law σ ≡ Π(λ)
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe("Yjs projection law", () => {
|
|
125
|
+
it("shadow equals materialized projection of native doc after a mixed mutation sequence", () => {
|
|
126
|
+
const schema = Schema.struct({
|
|
127
|
+
title: Schema.text(),
|
|
128
|
+
items: Schema.list(
|
|
129
|
+
Schema.struct({ name: Schema.string(), done: Schema.boolean() }),
|
|
130
|
+
),
|
|
131
|
+
meta: Schema.struct.json({
|
|
132
|
+
tags: Schema.string(),
|
|
133
|
+
version: Schema.number(),
|
|
134
|
+
}),
|
|
135
|
+
peers: Schema.record(Schema.boolean()),
|
|
136
|
+
})
|
|
137
|
+
const { doc } = buildUnbound(schema)
|
|
138
|
+
|
|
139
|
+
change(doc, (d: any) => {
|
|
140
|
+
d.title.insert(0, "Hello")
|
|
141
|
+
d.items.push({ name: "a", done: false })
|
|
142
|
+
})
|
|
143
|
+
change(doc, (d: any) => {
|
|
144
|
+
d.items.at(0).done.set(true)
|
|
145
|
+
d.items.push({ name: "b", done: false })
|
|
146
|
+
})
|
|
147
|
+
change(doc, (d: any) => {
|
|
148
|
+
d.meta.set({ tags: "kyneta", version: 2 })
|
|
149
|
+
d.peers.set("alice", true)
|
|
150
|
+
d.peers.set("bob", false)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const nativeDoc = unwrap(doc) as Y.Doc
|
|
154
|
+
const projected = materializeYjsShadow(nativeDoc, schema)
|
|
155
|
+
expect(projected).toEqual({
|
|
156
|
+
title: "Hello",
|
|
157
|
+
items: [
|
|
158
|
+
{ name: "a", done: true },
|
|
159
|
+
{ name: "b", done: false },
|
|
160
|
+
],
|
|
161
|
+
meta: { tags: "kyneta", version: 2 },
|
|
162
|
+
peers: { alice: true, bob: false },
|
|
163
|
+
})
|
|
164
|
+
expect((doc.title as any)()).toBe("Hello")
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// 3. JSON boundary: plain JS values in the parent Y.Map entry
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe("Yjs json-boundary storage", () => {
|
|
173
|
+
it("struct.json field stores a plain JS value at the parent boundary key", () => {
|
|
174
|
+
const schema = Schema.struct({
|
|
175
|
+
config: Schema.struct.json({
|
|
176
|
+
tags: Schema.string(),
|
|
177
|
+
retries: Schema.number(),
|
|
178
|
+
}),
|
|
179
|
+
})
|
|
180
|
+
const { doc } = build(schema)
|
|
181
|
+
|
|
182
|
+
change(doc, (d: any) => {
|
|
183
|
+
d.config.set({ tags: "ci", retries: 3 })
|
|
184
|
+
})
|
|
185
|
+
expect(doc.config()).toEqual({ tags: "ci", retries: 3 })
|
|
186
|
+
|
|
187
|
+
change(doc, (d: any) => {
|
|
188
|
+
d.config.tags.set("prod")
|
|
189
|
+
})
|
|
190
|
+
expect(doc.config()).toEqual({ tags: "prod", retries: 3 })
|
|
191
|
+
|
|
192
|
+
// Direct CRDT inspection: the boundary slot in the root Y.Map
|
|
193
|
+
// holds a plain JS object — NOT a Y.Map. Yjs Y.Map instances
|
|
194
|
+
// would respond to `instanceof Y.Map`.
|
|
195
|
+
const native = unwrap(doc) as Y.Doc
|
|
196
|
+
const rootMap = native.getMap("root")
|
|
197
|
+
const rootKeys: string[] = []
|
|
198
|
+
rootMap.forEach((_v, k) => {
|
|
199
|
+
rootKeys.push(k)
|
|
200
|
+
})
|
|
201
|
+
expect(rootKeys.length).toBe(1)
|
|
202
|
+
const key = rootKeys[0]
|
|
203
|
+
if (key === undefined) throw new Error("rootKeys[0] missing")
|
|
204
|
+
const value = rootMap.get(key)
|
|
205
|
+
expect(value).toEqual({ tags: "prod", retries: 3 })
|
|
206
|
+
expect(value instanceof Y.Map).toBe(false)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("list.json items round-trip and replace cleanly on field-inside-item writes", () => {
|
|
210
|
+
const schema = Schema.struct({
|
|
211
|
+
todos: Schema.list.json(
|
|
212
|
+
Schema.struct({ title: Schema.string(), done: Schema.boolean() }),
|
|
213
|
+
),
|
|
214
|
+
})
|
|
215
|
+
const { doc } = build(schema)
|
|
216
|
+
|
|
217
|
+
change(doc, (d: any) => {
|
|
218
|
+
d.todos.push({ title: "first", done: false })
|
|
219
|
+
})
|
|
220
|
+
change(doc, (d: any) => {
|
|
221
|
+
d.todos.push({ title: "second", done: false })
|
|
222
|
+
})
|
|
223
|
+
expect(doc.todos()).toEqual([
|
|
224
|
+
{ title: "first", done: false },
|
|
225
|
+
{ title: "second", done: false },
|
|
226
|
+
])
|
|
227
|
+
|
|
228
|
+
change(doc, (d: any) => {
|
|
229
|
+
d.todos.at(0).done.set(true)
|
|
230
|
+
})
|
|
231
|
+
expect(doc.todos()).toEqual([
|
|
232
|
+
{ title: "first", done: true },
|
|
233
|
+
{ title: "second", done: false },
|
|
234
|
+
])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("record.json entries round-trip through the json-boundary path", () => {
|
|
238
|
+
const schema = Schema.struct({
|
|
239
|
+
profiles: Schema.record.json(Schema.struct({ email: Schema.string() })),
|
|
240
|
+
})
|
|
241
|
+
const { doc } = build(schema)
|
|
242
|
+
|
|
243
|
+
change(doc, (d: any) => {
|
|
244
|
+
d.profiles.set("alice", { email: "alice@example.com" })
|
|
245
|
+
d.profiles.set("bob", { email: "bob@example.com" })
|
|
246
|
+
})
|
|
247
|
+
expect(doc.profiles()).toEqual({
|
|
248
|
+
alice: { email: "alice@example.com" },
|
|
249
|
+
bob: { email: "bob@example.com" },
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
change(doc, (d: any) => {
|
|
253
|
+
d.profiles.at("alice").email.set("alice@new.example.com")
|
|
254
|
+
})
|
|
255
|
+
expect(doc.profiles()).toEqual({
|
|
256
|
+
alice: { email: "alice@new.example.com" },
|
|
257
|
+
bob: { email: "bob@example.com" },
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Three-primitive-substrate (jj:ryquprut): read-your-writes carries through
|
|
264
|
+
// to Yjs; abort produces one batched native event with net-zero delta.
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
describe("Yjs three-primitive substrate (jj:ryquprut)", () => {
|
|
268
|
+
it("multi-push in one change() block appends in order against the CRDT", () => {
|
|
269
|
+
const schema = Schema.struct({
|
|
270
|
+
todos: Schema.list(Schema.string()),
|
|
271
|
+
})
|
|
272
|
+
const { doc } = build(schema)
|
|
273
|
+
|
|
274
|
+
change(doc, (d: any) => {
|
|
275
|
+
d.todos.push("a")
|
|
276
|
+
d.todos.push("b")
|
|
277
|
+
d.todos.push("c")
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
expect((doc.todos as any)()).toEqual(["a", "b", "c"])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it("abort restores state on outermost throw", () => {
|
|
284
|
+
const schema = Schema.struct({
|
|
285
|
+
a: Schema.string(),
|
|
286
|
+
b: Schema.string(),
|
|
287
|
+
})
|
|
288
|
+
const { doc } = build(schema)
|
|
289
|
+
|
|
290
|
+
expect(() => {
|
|
291
|
+
change(doc, (d: any) => {
|
|
292
|
+
d.a.set("set-a")
|
|
293
|
+
d.b.set("set-b")
|
|
294
|
+
throw new Error("abort")
|
|
295
|
+
})
|
|
296
|
+
}).toThrow("abort")
|
|
297
|
+
|
|
298
|
+
expect((doc.a as any)()).toBe("")
|
|
299
|
+
expect((doc.b as any)()).toBe("")
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it("abort fires one Changeset with aborted: true on the Yjs stack", () => {
|
|
303
|
+
const schema = Schema.struct({ a: Schema.string() })
|
|
304
|
+
const { doc } = build(schema)
|
|
305
|
+
|
|
306
|
+
let aborted = false
|
|
307
|
+
subscribe(doc.a, (cs: any) => {
|
|
308
|
+
if (cs.aborted) aborted = true
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(() => {
|
|
312
|
+
change(doc, (d: any) => {
|
|
313
|
+
d.a.set("hello")
|
|
314
|
+
throw new Error("abort")
|
|
315
|
+
})
|
|
316
|
+
}).toThrow("abort")
|
|
317
|
+
|
|
318
|
+
expect(aborted).toBe(true)
|
|
319
|
+
expect((doc.a as any)()).toBe("")
|
|
320
|
+
})
|
|
321
|
+
})
|
|
@@ -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"
|
|
@@ -652,4 +653,58 @@ describe("YjsSubstrate", () => {
|
|
|
652
653
|
expect(writes).toBe(1)
|
|
653
654
|
})
|
|
654
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
|
+
})
|
|
655
710
|
})
|
package/src/change-mapping.ts
CHANGED
|
@@ -35,6 +35,8 @@ import type {
|
|
|
35
35
|
} from "@kyneta/schema"
|
|
36
36
|
import {
|
|
37
37
|
expandMapOpsToLeaves,
|
|
38
|
+
isJsonBoundary,
|
|
39
|
+
isPlainObject,
|
|
38
40
|
KIND,
|
|
39
41
|
pathSchema,
|
|
40
42
|
RawPath,
|
|
@@ -299,7 +301,7 @@ function applyReplaceChange(
|
|
|
299
301
|
): void {
|
|
300
302
|
if (path.length === 0) {
|
|
301
303
|
throw new Error(
|
|
302
|
-
"
|
|
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 })`).",
|
|
303
305
|
)
|
|
304
306
|
}
|
|
305
307
|
|
|
@@ -366,6 +368,12 @@ function maybeCreateSharedType(
|
|
|
366
368
|
): unknown {
|
|
367
369
|
if (schema === undefined) return value
|
|
368
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
|
+
|
|
369
377
|
switch (schema[KIND]) {
|
|
370
378
|
// First-class text → Y.Text
|
|
371
379
|
case "text": {
|
|
@@ -400,12 +408,7 @@ function maybeCreateSharedType(
|
|
|
400
408
|
}
|
|
401
409
|
|
|
402
410
|
case "product": {
|
|
403
|
-
if (
|
|
404
|
-
value === null ||
|
|
405
|
-
value === undefined ||
|
|
406
|
-
typeof value !== "object" ||
|
|
407
|
-
Array.isArray(value)
|
|
408
|
-
) {
|
|
411
|
+
if (!isPlainObject(value)) {
|
|
409
412
|
return value
|
|
410
413
|
}
|
|
411
414
|
return createStructuredMap(value as Record<string, unknown>, schema)
|
|
@@ -423,12 +426,7 @@ function maybeCreateSharedType(
|
|
|
423
426
|
}
|
|
424
427
|
|
|
425
428
|
case "map": {
|
|
426
|
-
if (
|
|
427
|
-
value === null ||
|
|
428
|
-
value === undefined ||
|
|
429
|
-
typeof value !== "object" ||
|
|
430
|
-
Array.isArray(value)
|
|
431
|
-
) {
|
|
429
|
+
if (!isPlainObject(value)) {
|
|
432
430
|
return value
|
|
433
431
|
}
|
|
434
432
|
const map = new Y.Map()
|
package/src/populate.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// functions: ensureContainers, ensureRootField, ensureMapContainers.
|
|
18
18
|
|
|
19
19
|
import type { SchemaBinding, Schema as SchemaNode } from "@kyneta/schema"
|
|
20
|
-
import { KIND, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
|
|
20
|
+
import { isJsonBoundary, KIND, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
|
|
21
21
|
import * as Y from "yjs"
|
|
22
22
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
@@ -123,6 +123,12 @@ function ensureRootField(
|
|
|
123
123
|
// and necessary on hydrated docs (preserves existing data).
|
|
124
124
|
if (rootMap.has(key)) return
|
|
125
125
|
|
|
126
|
+
// JSON-boundary fields (struct.json/list.json/record.json) store the
|
|
127
|
+
// subtree as a plain JSON value in the root Y.Map entry. We leave the
|
|
128
|
+
// entry absent at structural-init time; the first write materialises
|
|
129
|
+
// it with `rootMap.set(key, plainValue)`.
|
|
130
|
+
if (isJsonBoundary(fieldSchema)) return
|
|
131
|
+
|
|
126
132
|
switch (fieldSchema[KIND]) {
|
|
127
133
|
case "text":
|
|
128
134
|
case "richtext":
|
|
@@ -196,6 +202,12 @@ function ensureMapContainers(
|
|
|
196
202
|
const identity = binding?.forward.get(absPath) as string | undefined
|
|
197
203
|
const mapKey = identity ?? key
|
|
198
204
|
|
|
205
|
+
// JSON-boundary nested field: leave the entry absent, the first
|
|
206
|
+
// write will set the plain JSON value at this key.
|
|
207
|
+
if (isJsonBoundary(fieldSchema)) {
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
|
|
199
211
|
switch (fieldSchema[KIND]) {
|
|
200
212
|
case "text":
|
|
201
213
|
case "richtext":
|