@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.
@@ -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
  })
@@ -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
- "applyChangeToYjs: ReplaceChange at root path is not supported",
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":