@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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// materialize — Tests for materializeYjsShadow.
|
|
2
|
+
//
|
|
3
|
+
// Validates that the Yjs→PlainState materialization produces correct
|
|
4
|
+
// plain JS objects for all supported schema types: text, scalar,
|
|
5
|
+
// sequence, nested struct, and empty documents.
|
|
6
|
+
//
|
|
7
|
+
// Yjs does not support counter, tree, or movableList — those are skipped.
|
|
8
|
+
|
|
9
|
+
import type { ProductSchema, SchemaBinding } from "@kyneta/schema"
|
|
10
|
+
import {
|
|
11
|
+
BACKING_DOC,
|
|
12
|
+
change,
|
|
13
|
+
createDoc,
|
|
14
|
+
deriveSchemaBinding,
|
|
15
|
+
KIND,
|
|
16
|
+
Schema,
|
|
17
|
+
type SchemaNode,
|
|
18
|
+
SUBSTRATE,
|
|
19
|
+
} from "@kyneta/schema"
|
|
20
|
+
import { describe, expect, it } from "vitest"
|
|
21
|
+
import type * as Y from "yjs"
|
|
22
|
+
import { yjs } from "../bind-yjs.js"
|
|
23
|
+
import { materializeYjsShadow } from "../materialize.js"
|
|
24
|
+
|
|
25
|
+
// ===========================================================================
|
|
26
|
+
// Helpers
|
|
27
|
+
// ===========================================================================
|
|
28
|
+
|
|
29
|
+
function trivialBinding(schema: SchemaNode): SchemaBinding {
|
|
30
|
+
if (schema[KIND] === "product") {
|
|
31
|
+
return deriveSchemaBinding(schema as ProductSchema, {})
|
|
32
|
+
}
|
|
33
|
+
return { forward: new Map(), inverse: new Map() }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getYDoc(docRef: unknown): Y.Doc {
|
|
37
|
+
const substrate = (docRef as any)[SUBSTRATE]
|
|
38
|
+
return (substrate as any)[BACKING_DOC] as Y.Doc
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ===========================================================================
|
|
42
|
+
// Test schemas
|
|
43
|
+
// ===========================================================================
|
|
44
|
+
|
|
45
|
+
const TextSchema = Schema.struct({
|
|
46
|
+
title: Schema.text(),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const ScalarSchema = Schema.struct({
|
|
50
|
+
name: Schema.string(),
|
|
51
|
+
age: Schema.number(),
|
|
52
|
+
active: Schema.boolean(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const SequenceSchema = Schema.struct({
|
|
56
|
+
items: Schema.list(Schema.string()),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const NestedSchema = Schema.struct({
|
|
60
|
+
meta: Schema.struct({
|
|
61
|
+
author: Schema.string(),
|
|
62
|
+
version: Schema.number(),
|
|
63
|
+
}),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const FullSchema = Schema.struct({
|
|
67
|
+
title: Schema.text(),
|
|
68
|
+
theme: Schema.string(),
|
|
69
|
+
tags: Schema.list(Schema.string()),
|
|
70
|
+
settings: Schema.struct({
|
|
71
|
+
darkMode: Schema.boolean(),
|
|
72
|
+
fontSize: Schema.number(),
|
|
73
|
+
}),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ===========================================================================
|
|
77
|
+
// Tests
|
|
78
|
+
// ===========================================================================
|
|
79
|
+
|
|
80
|
+
describe("materializeYjsShadow", () => {
|
|
81
|
+
it("materializes text fields", () => {
|
|
82
|
+
const doc = createDoc(yjs.bind(TextSchema))
|
|
83
|
+
change(doc, (d: any) => {
|
|
84
|
+
d.title.insert(0, "hello")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const yDoc = getYDoc(doc)
|
|
88
|
+
const binding = trivialBinding(TextSchema)
|
|
89
|
+
const result = materializeYjsShadow(yDoc, TextSchema, binding)
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual({ title: "hello" })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("materializes scalar fields", () => {
|
|
95
|
+
const doc = createDoc(yjs.bind(ScalarSchema))
|
|
96
|
+
change(doc, (d: any) => {
|
|
97
|
+
d.name.set("Alice")
|
|
98
|
+
d.age.set(30)
|
|
99
|
+
d.active.set(true)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const yDoc = getYDoc(doc)
|
|
103
|
+
const binding = trivialBinding(ScalarSchema)
|
|
104
|
+
const result = materializeYjsShadow(yDoc, ScalarSchema, binding)
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual({ name: "Alice", age: 30, active: true })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("materializes sequence fields", () => {
|
|
110
|
+
const doc = createDoc(yjs.bind(SequenceSchema))
|
|
111
|
+
// Separate change() calls for list pushes to preserve order
|
|
112
|
+
// (Yjs reverses order within a single transaction)
|
|
113
|
+
change(doc, (d: any) => d.items.push("alpha"))
|
|
114
|
+
change(doc, (d: any) => d.items.push("beta"))
|
|
115
|
+
change(doc, (d: any) => d.items.push("gamma"))
|
|
116
|
+
|
|
117
|
+
const yDoc = getYDoc(doc)
|
|
118
|
+
const binding = trivialBinding(SequenceSchema)
|
|
119
|
+
const result = materializeYjsShadow(yDoc, SequenceSchema, binding)
|
|
120
|
+
|
|
121
|
+
expect(result).toEqual({ items: ["alpha", "beta", "gamma"] })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("materializes nested struct fields", () => {
|
|
125
|
+
const doc = createDoc(yjs.bind(NestedSchema))
|
|
126
|
+
change(doc, (d: any) => {
|
|
127
|
+
d.meta.author.set("Bob")
|
|
128
|
+
d.meta.version.set(42)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const yDoc = getYDoc(doc)
|
|
132
|
+
const binding = trivialBinding(NestedSchema)
|
|
133
|
+
const result = materializeYjsShadow(yDoc, NestedSchema, binding)
|
|
134
|
+
|
|
135
|
+
expect(result).toEqual({
|
|
136
|
+
meta: { author: "Bob", version: 42 },
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("materializes empty document to structural zeros", () => {
|
|
141
|
+
const doc = createDoc(yjs.bind(FullSchema))
|
|
142
|
+
|
|
143
|
+
const yDoc = getYDoc(doc)
|
|
144
|
+
const binding = trivialBinding(FullSchema)
|
|
145
|
+
const result = materializeYjsShadow(yDoc, FullSchema, binding)
|
|
146
|
+
|
|
147
|
+
expect(result).toEqual({
|
|
148
|
+
title: "",
|
|
149
|
+
theme: "",
|
|
150
|
+
tags: [],
|
|
151
|
+
settings: { darkMode: false, fontSize: 0 },
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("uses raw field names, not identity hashes", () => {
|
|
156
|
+
const doc = createDoc(yjs.bind(TextSchema))
|
|
157
|
+
change(doc, (d: any) => {
|
|
158
|
+
d.title.insert(0, "test")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const yDoc = getYDoc(doc)
|
|
162
|
+
const binding = trivialBinding(TextSchema)
|
|
163
|
+
const result = materializeYjsShadow(yDoc, TextSchema, binding)
|
|
164
|
+
|
|
165
|
+
// Keys should be the raw field name "title", not an identity hash
|
|
166
|
+
const keys = Object.keys(result as Record<string, unknown>)
|
|
167
|
+
expect(keys).toContain("title")
|
|
168
|
+
expect(keys).toHaveLength(1)
|
|
169
|
+
// No key should look like a hash (long alphanumeric string)
|
|
170
|
+
for (const key of keys) {
|
|
171
|
+
expect(key).toBe("title")
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("materializes a complex document with multiple field types", () => {
|
|
176
|
+
const doc = createDoc(yjs.bind(FullSchema))
|
|
177
|
+
|
|
178
|
+
change(doc, (d: any) => {
|
|
179
|
+
d.title.insert(0, "My Doc")
|
|
180
|
+
d.theme.set("dark")
|
|
181
|
+
d.settings.darkMode.set(true)
|
|
182
|
+
d.settings.fontSize.set(16)
|
|
183
|
+
})
|
|
184
|
+
change(doc, (d: any) => d.tags.push("important"))
|
|
185
|
+
change(doc, (d: any) => d.tags.push("urgent"))
|
|
186
|
+
|
|
187
|
+
const yDoc = getYDoc(doc)
|
|
188
|
+
const binding = trivialBinding(FullSchema)
|
|
189
|
+
const result = materializeYjsShadow(yDoc, FullSchema, binding)
|
|
190
|
+
|
|
191
|
+
expect(result).toEqual({
|
|
192
|
+
title: "My Doc",
|
|
193
|
+
theme: "dark",
|
|
194
|
+
tags: ["important", "urgent"],
|
|
195
|
+
settings: { darkMode: true, fontSize: 16 },
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("materializes without binding (undefined binding)", () => {
|
|
200
|
+
const doc = createDoc(yjs.bind(TextSchema))
|
|
201
|
+
change(doc, (d: any) => {
|
|
202
|
+
d.title.insert(0, "no binding")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const yDoc = getYDoc(doc)
|
|
206
|
+
// Pass no binding — materialize should still work
|
|
207
|
+
const result = materializeYjsShadow(yDoc, TextSchema)
|
|
208
|
+
|
|
209
|
+
// Without binding, keys may be identity hashes — but the values
|
|
210
|
+
// should still be correct and the result should be an object.
|
|
211
|
+
expect(result).toBeDefined()
|
|
212
|
+
expect(typeof result).toBe("object")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("nested nullable materializes to null on fresh doc", () => {
|
|
216
|
+
const schema = Schema.struct({
|
|
217
|
+
settings: Schema.struct({
|
|
218
|
+
theme: Schema.string().nullable(),
|
|
219
|
+
}),
|
|
220
|
+
})
|
|
221
|
+
const doc = createDoc(yjs.bind(schema))
|
|
222
|
+
const yDoc = getYDoc(doc)
|
|
223
|
+
const binding = trivialBinding(schema)
|
|
224
|
+
const result = materializeYjsShadow(yDoc, schema, binding)
|
|
225
|
+
expect(result).toEqual({ settings: { theme: null } })
|
|
226
|
+
})
|
|
227
|
+
})
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
type PositionTestEnv,
|
|
22
22
|
positionConformance,
|
|
23
|
-
} from "@kyneta/schema/
|
|
23
|
+
} from "@kyneta/schema/testing"
|
|
24
24
|
import { describe, expect, it } from "vitest"
|
|
25
25
|
import * as Y from "yjs"
|
|
26
26
|
import { ensureContainers } from "../populate.js"
|
|
@@ -118,7 +118,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
118
118
|
// --- Doc 2: fork from doc1's state ---
|
|
119
119
|
const doc2 = new Y.Doc()
|
|
120
120
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
121
|
-
ensureContainers(doc2, TextSchema
|
|
121
|
+
ensureContainers(doc2, TextSchema)
|
|
122
122
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
123
123
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
124
124
|
|
|
@@ -175,7 +175,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
175
175
|
// --- Doc 2: fork ---
|
|
176
176
|
const doc2 = new Y.Doc()
|
|
177
177
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
178
|
-
ensureContainers(doc2, TextSchema
|
|
178
|
+
ensureContainers(doc2, TextSchema)
|
|
179
179
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
180
180
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
181
181
|
|
|
@@ -229,7 +229,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
229
229
|
// --- Doc 2: fork ---
|
|
230
230
|
const doc2 = new Y.Doc()
|
|
231
231
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
232
|
-
ensureContainers(doc2, TextSchema
|
|
232
|
+
ensureContainers(doc2, TextSchema)
|
|
233
233
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
234
234
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
235
235
|
|
|
@@ -285,7 +285,7 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
285
285
|
// Decode on a different doc that has the same state
|
|
286
286
|
const doc2 = new Y.Doc()
|
|
287
287
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
288
|
-
ensureContainers(doc2, TextSchema
|
|
288
|
+
ensureContainers(doc2, TextSchema)
|
|
289
289
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
290
290
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
291
291
|
|
|
@@ -310,13 +310,13 @@ describe("YjsPosition: concurrent edits", () => {
|
|
|
310
310
|
|
|
311
311
|
const doc2 = new Y.Doc()
|
|
312
312
|
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
|
|
313
|
-
ensureContainers(doc2, TextSchema
|
|
313
|
+
ensureContainers(doc2, TextSchema)
|
|
314
314
|
const substrate2 = createYjsSubstrate(doc2, TextSchema)
|
|
315
315
|
const ref2 = createRef(TextSchema, substrate2) as any
|
|
316
316
|
|
|
317
317
|
const doc3 = new Y.Doc()
|
|
318
318
|
Y.applyUpdate(doc3, Y.encodeStateAsUpdate(doc1))
|
|
319
|
-
ensureContainers(doc3, TextSchema
|
|
319
|
+
ensureContainers(doc3, TextSchema)
|
|
320
320
|
const substrate3 = createYjsSubstrate(doc3, TextSchema)
|
|
321
321
|
const ref3 = createRef(TextSchema, substrate3) as any
|
|
322
322
|
|