@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.
@@ -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/src/__tests__/position-conformance.js"
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, true)
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, true)
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, true)
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, true)
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, true)
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, true)
319
+ ensureContainers(doc3, TextSchema)
320
320
  const substrate3 = createYjsSubstrate(doc3, TextSchema)
321
321
  const ref3 = createRef(TextSchema, substrate3) as any
322
322