@kyneta/yjs-schema 1.7.0 → 2.0.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 `batch()` 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
+ batch,
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
+ batch(doc, (d: any) => {
80
+ d.events.push({ kind: "assistant", body: "" })
81
+ })
82
+ batch(doc, (d: any) => {
83
+ d.events.at(1).body.set("hello")
84
+ })
85
+ })
86
+
87
+ expect(() => {
88
+ batch(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
+ batch(doc, (d: any) => {
107
+ d.items.push({ name: "synthesised" })
108
+ })
109
+ observed = (doc.items as any).at(1).name()
110
+ })
111
+
112
+ batch(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
+ batch(doc, (d: any) => {
140
+ d.title.insert(0, "Hello")
141
+ d.items.push({ name: "a", done: false })
142
+ })
143
+ batch(doc, (d: any) => {
144
+ d.items.at(0).done.set(true)
145
+ d.items.push({ name: "b", done: false })
146
+ })
147
+ batch(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
+ batch(doc, (d: any) => {
183
+ d.config.set({ tags: "ci", retries: 3 })
184
+ })
185
+ expect(doc.config()).toEqual({ tags: "ci", retries: 3 })
186
+
187
+ batch(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
+ batch(doc, (d: any) => {
218
+ d.todos.push({ title: "first", done: false })
219
+ })
220
+ batch(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
+ batch(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
+ batch(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
+ batch(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 batch() 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
+ batch(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
+ batch(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
+ batch(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
+ })
@@ -9,7 +9,7 @@
9
9
  import type { ProductSchema, SchemaBinding } from "@kyneta/schema"
10
10
  import {
11
11
  BACKING_DOC,
12
- change,
12
+ batch,
13
13
  createDoc,
14
14
  deriveSchemaBinding,
15
15
  KIND,
@@ -80,7 +80,7 @@ const FullSchema = Schema.struct({
80
80
  describe("materializeYjsShadow", () => {
81
81
  it("materializes text fields", () => {
82
82
  const doc = createDoc(yjs.bind(TextSchema))
83
- change(doc, (d: any) => {
83
+ batch(doc, (d: any) => {
84
84
  d.title.insert(0, "hello")
85
85
  })
86
86
 
@@ -93,7 +93,7 @@ describe("materializeYjsShadow", () => {
93
93
 
94
94
  it("materializes scalar fields", () => {
95
95
  const doc = createDoc(yjs.bind(ScalarSchema))
96
- change(doc, (d: any) => {
96
+ batch(doc, (d: any) => {
97
97
  d.name.set("Alice")
98
98
  d.age.set(30)
99
99
  d.active.set(true)
@@ -108,11 +108,11 @@ describe("materializeYjsShadow", () => {
108
108
 
109
109
  it("materializes sequence fields", () => {
110
110
  const doc = createDoc(yjs.bind(SequenceSchema))
111
- // Separate change() calls for list pushes to preserve order
111
+ // Separate batch() calls for list pushes to preserve order
112
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"))
113
+ batch(doc, (d: any) => d.items.push("alpha"))
114
+ batch(doc, (d: any) => d.items.push("beta"))
115
+ batch(doc, (d: any) => d.items.push("gamma"))
116
116
 
117
117
  const yDoc = getYDoc(doc)
118
118
  const binding = trivialBinding(SequenceSchema)
@@ -123,7 +123,7 @@ describe("materializeYjsShadow", () => {
123
123
 
124
124
  it("materializes nested struct fields", () => {
125
125
  const doc = createDoc(yjs.bind(NestedSchema))
126
- change(doc, (d: any) => {
126
+ batch(doc, (d: any) => {
127
127
  d.meta.author.set("Bob")
128
128
  d.meta.version.set(42)
129
129
  })
@@ -154,7 +154,7 @@ describe("materializeYjsShadow", () => {
154
154
 
155
155
  it("uses raw field names, not identity hashes", () => {
156
156
  const doc = createDoc(yjs.bind(TextSchema))
157
- change(doc, (d: any) => {
157
+ batch(doc, (d: any) => {
158
158
  d.title.insert(0, "test")
159
159
  })
160
160
 
@@ -175,14 +175,14 @@ describe("materializeYjsShadow", () => {
175
175
  it("materializes a complex document with multiple field types", () => {
176
176
  const doc = createDoc(yjs.bind(FullSchema))
177
177
 
178
- change(doc, (d: any) => {
178
+ batch(doc, (d: any) => {
179
179
  d.title.insert(0, "My Doc")
180
180
  d.theme.set("dark")
181
181
  d.settings.darkMode.set(true)
182
182
  d.settings.fontSize.set(16)
183
183
  })
184
- change(doc, (d: any) => d.tags.push("important"))
185
- change(doc, (d: any) => d.tags.push("urgent"))
184
+ batch(doc, (d: any) => d.tags.push("important"))
185
+ batch(doc, (d: any) => d.tags.push("urgent"))
186
186
 
187
187
  const yDoc = getYDoc(doc)
188
188
  const binding = trivialBinding(FullSchema)
@@ -198,7 +198,7 @@ describe("materializeYjsShadow", () => {
198
198
 
199
199
  it("materializes without binding (undefined binding)", () => {
200
200
  const doc = createDoc(yjs.bind(TextSchema))
201
- change(doc, (d: any) => {
201
+ batch(doc, (d: any) => {
202
202
  d.title.insert(0, "no binding")
203
203
  })
204
204
 
@@ -8,7 +8,7 @@
8
8
  // resolve correctly on the converged state.
9
9
 
10
10
  import {
11
- change,
11
+ batch,
12
12
  createRef,
13
13
  hasPosition,
14
14
  type Instruction,
@@ -55,7 +55,7 @@ function createYjsEnv(initialText: string): PositionTestEnv {
55
55
 
56
56
  // Seed the initial text content
57
57
  if (initialText.length > 0) {
58
- change(ref, (d: any) => {
58
+ batch(ref, (d: any) => {
59
59
  d.title.insert(0, initialText)
60
60
  })
61
61
  }
@@ -71,7 +71,7 @@ function createYjsEnv(initialText: string): PositionTestEnv {
71
71
  positions,
72
72
 
73
73
  insert(index: number, text: string): readonly Instruction[] {
74
- const ops = change(ref, (d: any) => {
74
+ const ops = batch(ref, (d: any) => {
75
75
  d.title.insert(index, text)
76
76
  })
77
77
  const textOp = ops.find(op => isTextChange(op.change))
@@ -82,7 +82,7 @@ function createYjsEnv(initialText: string): PositionTestEnv {
82
82
  },
83
83
 
84
84
  delete(index: number, count: number): readonly Instruction[] {
85
- const ops = change(ref, (d: any) => {
85
+ const ops = batch(ref, (d: any) => {
86
86
  d.title.delete(index, count)
87
87
  })
88
88
  const textOp = ops.find(op => isTextChange(op.change))
@@ -111,7 +111,7 @@ describe("YjsPosition: concurrent edits", () => {
111
111
  ensureContainers(doc1, TextSchema)
112
112
  const substrate1 = createYjsSubstrate(doc1, TextSchema)
113
113
  const ref1 = createRef(TextSchema, substrate1) as any
114
- change(ref1, (d: any) => {
114
+ batch(ref1, (d: any) => {
115
115
  d.title.insert(0, "hello")
116
116
  })
117
117
 
@@ -136,10 +136,10 @@ describe("YjsPosition: concurrent edits", () => {
136
136
  const pos2 = textRef2[POSITION].createPosition(4, "left") // before "o"
137
137
 
138
138
  // --- Concurrent edits ---
139
- change(ref1, (d: any) => {
139
+ batch(ref1, (d: any) => {
140
140
  d.title.insert(0, "AA") // doc1: "AAhello"
141
141
  })
142
- change(ref2, (d: any) => {
142
+ batch(ref2, (d: any) => {
143
143
  d.title.insert(5, "BB") // doc2: "helloBBo"
144
144
  })
145
145
 
@@ -168,7 +168,7 @@ describe("YjsPosition: concurrent edits", () => {
168
168
  ensureContainers(doc1, TextSchema)
169
169
  const substrate1 = createYjsSubstrate(doc1, TextSchema)
170
170
  const ref1 = createRef(TextSchema, substrate1) as any
171
- change(ref1, (d: any) => {
171
+ batch(ref1, (d: any) => {
172
172
  d.title.insert(0, "abc")
173
173
  })
174
174
 
@@ -190,10 +190,10 @@ describe("YjsPosition: concurrent edits", () => {
190
190
  const rightPos = textRef2[POSITION].createPosition(1, "right")
191
191
 
192
192
  // --- Both insert at index 1 concurrently ---
193
- change(ref1, (d: any) => {
193
+ batch(ref1, (d: any) => {
194
194
  d.title.insert(1, "X")
195
195
  })
196
- change(ref2, (d: any) => {
196
+ batch(ref2, (d: any) => {
197
197
  d.title.insert(1, "Y")
198
198
  })
199
199
 
@@ -222,7 +222,7 @@ describe("YjsPosition: concurrent edits", () => {
222
222
  ensureContainers(doc1, TextSchema)
223
223
  const substrate1 = createYjsSubstrate(doc1, TextSchema)
224
224
  const ref1 = createRef(TextSchema, substrate1) as any
225
- change(ref1, (d: any) => {
225
+ batch(ref1, (d: any) => {
226
226
  d.title.insert(0, "abcde")
227
227
  })
228
228
 
@@ -240,7 +240,7 @@ describe("YjsPosition: concurrent edits", () => {
240
240
  expect(pos.resolve()).toBe(3)
241
241
 
242
242
  // Doc2 deletes the range covering position 3
243
- change(ref2, (d: any) => {
243
+ batch(ref2, (d: any) => {
244
244
  d.title.delete(1, 3) // "ae"
245
245
  })
246
246
 
@@ -255,7 +255,7 @@ describe("YjsPosition: concurrent edits", () => {
255
255
  expect(afterDelete!).toBeLessThanOrEqual(ref1.title().length)
256
256
 
257
257
  // Now insert new content near the collapsed position
258
- change(ref1, (d: any) => {
258
+ batch(ref1, (d: any) => {
259
259
  d.title.insert(1, "XYZ")
260
260
  })
261
261
 
@@ -272,7 +272,7 @@ describe("YjsPosition: concurrent edits", () => {
272
272
  ensureContainers(doc1, TextSchema)
273
273
  const substrate1 = createYjsSubstrate(doc1, TextSchema)
274
274
  const ref1 = createRef(TextSchema, substrate1) as any
275
- change(ref1, (d: any) => {
275
+ batch(ref1, (d: any) => {
276
276
  d.title.insert(0, "hello world")
277
277
  })
278
278
 
@@ -304,7 +304,7 @@ describe("YjsPosition: concurrent edits", () => {
304
304
  ensureContainers(doc1, TextSchema)
305
305
  const substrate1 = createYjsSubstrate(doc1, TextSchema)
306
306
  const ref1 = createRef(TextSchema, substrate1) as any
307
- change(ref1, (d: any) => {
307
+ batch(ref1, (d: any) => {
308
308
  d.title.insert(0, "0123456789")
309
309
  })
310
310
 
@@ -334,13 +334,13 @@ describe("YjsPosition: concurrent edits", () => {
334
334
  const posC = t3[POSITION].createPosition(8, "right")
335
335
 
336
336
  // Concurrent edits from all three peers
337
- change(ref1, (d: any) => {
337
+ batch(ref1, (d: any) => {
338
338
  d.title.insert(0, "AA")
339
339
  })
340
- change(ref2, (d: any) => {
340
+ batch(ref2, (d: any) => {
341
341
  d.title.insert(5, "BB")
342
342
  })
343
- change(ref3, (d: any) => {
343
+ batch(ref3, (d: any) => {
344
344
  d.title.delete(7, 2)
345
345
  })
346
346