@kyneta/yjs-schema 1.0.0 → 1.2.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,333 @@
1
+ // bind-constraints — compile-time and runtime tests for yjs.bind() caps enforcement.
2
+ //
3
+ // Verifies that `yjs.bind()` rejects schemas containing capabilities
4
+ // that Yjs doesn't support (counter, movable, tree, set) at COMPILE TIME
5
+ // via the `RestrictCaps` / `AllowedCaps` mechanism, while accepting
6
+ // capabilities it does support (text) and plain schemas.
7
+
8
+ import {
9
+ type BoundSchema,
10
+ type ExtractCaps,
11
+ json,
12
+ Schema,
13
+ } from "@kyneta/schema"
14
+ import { describe, expect, expectTypeOf, it } from "vitest"
15
+ import { yjs } from "../bind-yjs.js"
16
+
17
+ // ===========================================================================
18
+ // §1 — Compile-time acceptance: schemas that yjs.bind() SHOULD accept
19
+ // ===========================================================================
20
+
21
+ describe("yjs.bind() accepts Yjs-compatible schemas", () => {
22
+ it("plain schema (no caps)", () => {
23
+ const schema = Schema.struct({
24
+ name: Schema.string(),
25
+ count: Schema.number(),
26
+ active: Schema.boolean(),
27
+ })
28
+ const bound = yjs.bind(schema)
29
+ expect(bound).toBeDefined()
30
+ expect(bound.schema).toBe(schema)
31
+ expectTypeOf(bound).toMatchTypeOf<BoundSchema<typeof schema>>()
32
+ })
33
+
34
+ it("schema with text", () => {
35
+ const schema = Schema.struct({
36
+ title: Schema.text(),
37
+ })
38
+ const bound = yjs.bind(schema)
39
+ expect(bound).toBeDefined()
40
+ expect(bound.schema).toBe(schema)
41
+ })
42
+
43
+ it("schema with text + plain scalars", () => {
44
+ const schema = Schema.struct({
45
+ title: Schema.text(),
46
+ count: Schema.number(),
47
+ active: Schema.boolean(),
48
+ tags: Schema.list(Schema.string()),
49
+ })
50
+ const bound = yjs.bind(schema)
51
+ expect(bound).toBeDefined()
52
+ })
53
+
54
+ it("deeply nested text is accepted", () => {
55
+ const schema = Schema.struct({
56
+ channels: Schema.list(
57
+ Schema.struct({
58
+ meta: Schema.record(
59
+ Schema.struct({
60
+ description: Schema.text(),
61
+ }),
62
+ ),
63
+ }),
64
+ ),
65
+ })
66
+ const bound = yjs.bind(schema)
67
+ expect(bound).toBeDefined()
68
+ })
69
+
70
+ it("optional text field alongside plain scalars is accepted", () => {
71
+ const schema = Schema.struct({
72
+ title: Schema.text(),
73
+ draft: Schema.text(),
74
+ count: Schema.number(),
75
+ })
76
+ const bound = yjs.bind(schema)
77
+ expect(bound).toBeDefined()
78
+ })
79
+
80
+ it("preserves full schema type through bind()", () => {
81
+ const schema = Schema.struct({
82
+ title: Schema.text(),
83
+ items: Schema.list(
84
+ Schema.struct({ name: Schema.string(), done: Schema.boolean() }),
85
+ ),
86
+ })
87
+ const bound = yjs.bind(schema)
88
+ expectTypeOf(bound.schema).toEqualTypeOf(schema)
89
+ })
90
+
91
+ it("json merge boundary is accepted", () => {
92
+ const schema = Schema.struct({
93
+ title: Schema.text(),
94
+ metadata: Schema.struct.json({
95
+ version: Schema.number(),
96
+ tags: Schema.list(Schema.string()),
97
+ }),
98
+ })
99
+ const bound = yjs.bind(schema)
100
+ expect(bound).toBeDefined()
101
+ })
102
+ })
103
+
104
+ // ===========================================================================
105
+ // §2 — Compile-time rejection: schemas that yjs.bind() SHOULD reject
106
+ // ===========================================================================
107
+
108
+ describe("yjs.bind() rejects schemas with unsupported caps", () => {
109
+ it("rejects counter", () => {
110
+ const schema = Schema.struct({
111
+ count: Schema.counter(),
112
+ })
113
+ // @ts-expect-error — counter is not in YjsCaps
114
+ yjs.bind(schema)
115
+ })
116
+
117
+ it("rejects movableList", () => {
118
+ const schema = Schema.struct({
119
+ items: Schema.movableList(Schema.string()),
120
+ })
121
+ // @ts-expect-error — movable is not in YjsCaps
122
+ yjs.bind(schema)
123
+ })
124
+
125
+ it("rejects tree", () => {
126
+ const schema = Schema.struct({
127
+ hierarchy: Schema.tree(
128
+ Schema.struct({ label: Schema.string() }),
129
+ ),
130
+ })
131
+ // @ts-expect-error — tree is not in YjsCaps
132
+ yjs.bind(schema)
133
+ })
134
+
135
+ it("rejects set", () => {
136
+ const schema = Schema.struct({
137
+ tags: Schema.set(Schema.string()),
138
+ })
139
+ // @ts-expect-error — set is not in YjsCaps
140
+ yjs.bind(schema)
141
+ })
142
+
143
+ it("rejects deeply nested counter", () => {
144
+ const schema = Schema.struct({
145
+ items: Schema.list(
146
+ Schema.struct({
147
+ meta: Schema.record(
148
+ Schema.struct({ hits: Schema.counter() }),
149
+ ),
150
+ }),
151
+ ),
152
+ })
153
+ // @ts-expect-error — counter is deeply nested but still caught
154
+ yjs.bind(schema)
155
+ })
156
+
157
+ it("rejects mix of supported and unsupported (text + counter)", () => {
158
+ const schema = Schema.struct({
159
+ title: Schema.text(),
160
+ views: Schema.counter(),
161
+ })
162
+ // @ts-expect-error — counter is not in YjsCaps
163
+ yjs.bind(schema)
164
+ })
165
+
166
+ it("rejects counter inside list", () => {
167
+ const schema = Schema.struct({
168
+ scores: Schema.list(
169
+ Schema.struct({ value: Schema.counter() }),
170
+ ),
171
+ })
172
+ // @ts-expect-error — counter nested inside list struct
173
+ yjs.bind(schema)
174
+ })
175
+ })
176
+
177
+ // ===========================================================================
178
+ // §3 — Cross-substrate: same schema, different bind targets
179
+ // ===========================================================================
180
+
181
+ describe("cross-substrate: universal schema vs substrate-specific schema", () => {
182
+ // A schema using only universally-supported features
183
+ const universalSchema = Schema.struct({
184
+ title: Schema.text(),
185
+ items: Schema.list(
186
+ Schema.struct({
187
+ name: Schema.string(),
188
+ done: Schema.boolean(),
189
+ }),
190
+ ),
191
+ })
192
+
193
+ // A schema using Loro-specific features (counter, movable)
194
+ const loroSpecificSchema = Schema.struct({
195
+ title: Schema.text(),
196
+ count: Schema.counter(),
197
+ tasks: Schema.movableList(
198
+ Schema.struct({ name: Schema.string() }),
199
+ ),
200
+ })
201
+
202
+ it("universal schema is Yjs-compatible (ExtractCaps check)", () => {
203
+ type Caps = ExtractCaps<typeof universalSchema>
204
+ // Only "text" — in YjsCaps
205
+ expectTypeOf<Caps>().toEqualTypeOf<"text">()
206
+ })
207
+
208
+ it("Loro-specific schema is NOT Yjs-compatible (ExtractCaps check)", () => {
209
+ type Caps = ExtractCaps<typeof loroSpecificSchema>
210
+ // Includes "counter" and "movable" which are NOT in YjsCaps
211
+ expectTypeOf<Caps>().toEqualTypeOf<"text" | "counter" | "movable">()
212
+ })
213
+
214
+ it("universal schema binds to yjs", () => {
215
+ const bound = yjs.bind(universalSchema)
216
+ expect(bound).toBeDefined()
217
+ expect(bound.schema).toBe(universalSchema)
218
+ })
219
+
220
+ it("Loro-specific schema is rejected by yjs.bind()", () => {
221
+ // @ts-expect-error — counter and movable not in YjsCaps
222
+ yjs.bind(loroSpecificSchema)
223
+ })
224
+
225
+ it("json.bind() accepts schemas with all caps (AllowedCaps = string)", () => {
226
+ const schema = Schema.struct({
227
+ title: Schema.text(),
228
+ count: Schema.counter(),
229
+ tasks: Schema.movableList(Schema.string()),
230
+ })
231
+ const bound = json.bind(schema)
232
+ expect(bound).toBeDefined()
233
+ expect(bound.schema).toBe(schema)
234
+ })
235
+ })
236
+
237
+ // ===========================================================================
238
+ // §4 — Edge cases: discriminated unions, multiple text fields
239
+ // ===========================================================================
240
+
241
+ describe("bind constraint edge cases", () => {
242
+ it("discriminated union with all-plain variants is accepted", () => {
243
+ const schema = Schema.struct({
244
+ content: Schema.discriminatedUnion("type", [
245
+ Schema.struct({
246
+ type: Schema.string("text"),
247
+ body: Schema.string(),
248
+ }),
249
+ Schema.struct({
250
+ type: Schema.string("image"),
251
+ url: Schema.string(),
252
+ }),
253
+ ]),
254
+ })
255
+ const bound = yjs.bind(schema)
256
+ expect(bound).toBeDefined()
257
+ })
258
+
259
+ it("struct with counter alongside plain variants is rejected", () => {
260
+ const schema = Schema.struct({
261
+ content: Schema.discriminatedUnion("type", [
262
+ Schema.struct({
263
+ type: Schema.string("text"),
264
+ body: Schema.string(),
265
+ }),
266
+ Schema.struct({
267
+ type: Schema.string("image"),
268
+ url: Schema.string(),
269
+ }),
270
+ ]),
271
+ hits: Schema.counter(),
272
+ })
273
+ // @ts-expect-error — counter taints the whole schema
274
+ yjs.bind(schema)
275
+ })
276
+
277
+ it("multiple text fields are all accepted", () => {
278
+ const schema = Schema.struct({
279
+ title: Schema.text(),
280
+ body: Schema.text(),
281
+ summary: Schema.text(),
282
+ })
283
+ const bound = yjs.bind(schema)
284
+ expect(bound).toBeDefined()
285
+ })
286
+
287
+ it("plain-only schema (no caps at all) is accepted", () => {
288
+ const schema = Schema.struct({
289
+ name: Schema.string(),
290
+ age: Schema.number(),
291
+ active: Schema.boolean(),
292
+ tags: Schema.list(Schema.string()),
293
+ address: Schema.struct({
294
+ street: Schema.string(),
295
+ city: Schema.string(),
296
+ }),
297
+ metadata: Schema.record(Schema.any()),
298
+ })
299
+ const bound = yjs.bind(schema)
300
+ expect(bound).toBeDefined()
301
+ })
302
+ })
303
+
304
+ // ===========================================================================
305
+ // §5 — Root kind rejection: bind() requires a product (struct) root
306
+ // ===========================================================================
307
+
308
+ describe("yjs.bind() rejects non-product root schemas", () => {
309
+ it("rejects bare list at root", () => {
310
+ // @ts-expect-error — SequenceSchema is not ProductSchema
311
+ yjs.bind(Schema.list(Schema.string()))
312
+ })
313
+
314
+ it("rejects bare record at root", () => {
315
+ // @ts-expect-error — MapSchema is not ProductSchema
316
+ yjs.bind(Schema.record(Schema.string()))
317
+ })
318
+
319
+ it("rejects bare text at root", () => {
320
+ // @ts-expect-error — TextSchema is not ProductSchema
321
+ yjs.bind(Schema.text())
322
+ })
323
+
324
+ it("rejects bare scalar at root", () => {
325
+ // @ts-expect-error — ScalarSchema is not ProductSchema
326
+ yjs.bind(Schema.string())
327
+ })
328
+
329
+ it("rejects list of structs at root", () => {
330
+ // @ts-expect-error — SequenceSchema<ProductSchema> is still not ProductSchema
331
+ yjs.bind(Schema.list(Schema.struct({ name: Schema.string() })))
332
+ })
333
+ })
@@ -1,17 +1,15 @@
1
+ import { change, RawPath, Schema } from "@kyneta/schema"
1
2
  import { describe, expect, it } from "vitest"
2
3
  import * as Y from "yjs"
3
- import { Schema, change, RawPath } from "@kyneta/schema"
4
- import { bindYjs } from "../bind-yjs.js"
5
- import { yjs } from "../yjs-escape.js"
4
+ import { yjs } from "../bind-yjs.js"
6
5
  import { createYjsDoc } from "../create.js"
7
- import { yjsSubstrateFactory } from "../substrate.js"
8
6
 
9
7
  // ===========================================================================
10
8
  // Schemas used across tests
11
9
  // ===========================================================================
12
10
 
13
- const TodoSchema = Schema.doc({
14
- title: Schema.annotated("text"),
11
+ const TodoSchema = Schema.struct({
12
+ title: Schema.text(),
15
13
  items: Schema.list(
16
14
  Schema.struct({
17
15
  name: Schema.string(),
@@ -20,8 +18,8 @@ const TodoSchema = Schema.doc({
20
18
  ),
21
19
  })
22
20
 
23
- const SimpleSchema = Schema.doc({
24
- title: Schema.annotated("text"),
21
+ const SimpleSchema = Schema.struct({
22
+ title: Schema.text(),
25
23
  count: Schema.number(),
26
24
  })
27
25
 
@@ -29,26 +27,26 @@ const SimpleSchema = Schema.doc({
29
27
  // Tests
30
28
  // ===========================================================================
31
29
 
32
- describe("bindYjs", () => {
30
+ describe("yjs.bind", () => {
33
31
  // -------------------------------------------------------------------------
34
32
  // BoundSchema shape
35
33
  // -------------------------------------------------------------------------
36
34
 
37
35
  describe("BoundSchema", () => {
38
- it("creates BoundSchema with causal strategy", () => {
39
- const bound = bindYjs(TodoSchema)
36
+ it("creates BoundSchema with collaborative strategy", () => {
37
+ const bound = yjs.bind(TodoSchema)
40
38
  expect(bound._brand).toBe("BoundSchema")
41
39
  expect(bound.schema).toBe(TodoSchema)
42
- expect(bound.strategy).toBe("causal")
40
+ expect(bound.strategy).toBe("collaborative")
43
41
  })
44
42
 
45
43
  it("has a factory builder function", () => {
46
- const bound = bindYjs(TodoSchema)
44
+ const bound = yjs.bind(TodoSchema)
47
45
  expect(typeof bound.factory).toBe("function")
48
46
  })
49
47
 
50
48
  it("preserves the schema reference", () => {
51
- const bound = bindYjs(SimpleSchema)
49
+ const bound = yjs.bind(SimpleSchema)
52
50
  expect(bound.schema).toBe(SimpleSchema)
53
51
  })
54
52
  })
@@ -59,10 +57,10 @@ describe("bindYjs", () => {
59
57
 
60
58
  describe("factory builder", () => {
61
59
  it("produces a working SubstrateFactory", () => {
62
- const bound = bindYjs(SimpleSchema)
60
+ const bound = yjs.bind(SimpleSchema)
63
61
  const factory = bound.factory({ peerId: "peer-1" })
64
62
 
65
- const substrate = factory.create(SimpleSchema)
63
+ const _substrate = factory.create(SimpleSchema)
66
64
 
67
65
  // Populate via the substrate's writable context
68
66
  const doc = createYjsDocFromFactory(factory, SimpleSchema)
@@ -75,8 +73,8 @@ describe("bindYjs", () => {
75
73
  expect(doc.count()).toBe(7)
76
74
  })
77
75
 
78
- it("factory supports fromSnapshot", () => {
79
- const bound = bindYjs(SimpleSchema)
76
+ it("factory supports fromEntirety", () => {
77
+ const bound = yjs.bind(SimpleSchema)
80
78
  const factory = bound.factory({ peerId: "peer-1" })
81
79
 
82
80
  // Create and populate
@@ -86,18 +84,16 @@ describe("bindYjs", () => {
86
84
  d.count.set(42)
87
85
  })
88
86
  const substrate1 = (unwrap as any)(doc1)
89
- const snapshot = substrate1.exportSnapshot()
87
+ const snapshot = substrate1.exportEntirety()
90
88
 
91
89
  // Restore
92
- const substrate2 = factory.fromSnapshot(snapshot, SimpleSchema)
93
- expect(substrate2.store.read(RawPath.empty.field("title"))).toBe(
94
- "Snap",
95
- )
96
- expect(substrate2.store.read(RawPath.empty.field("count"))).toBe(42)
90
+ const substrate2 = factory.fromEntirety(snapshot, SimpleSchema)
91
+ expect(substrate2.reader.read(RawPath.empty.field("title"))).toBe("Snap")
92
+ expect(substrate2.reader.read(RawPath.empty.field("count"))).toBe(42)
97
93
  })
98
94
 
99
95
  it("factory supports parseVersion", () => {
100
- const bound = bindYjs(SimpleSchema)
96
+ const bound = yjs.bind(SimpleSchema)
101
97
  const factory = bound.factory({ peerId: "peer-1" })
102
98
 
103
99
  const substrate = factory.create(SimpleSchema)
@@ -114,38 +110,34 @@ describe("bindYjs", () => {
114
110
 
115
111
  describe("deterministic clientID", () => {
116
112
  it("same peerId produces same clientID across multiple factory calls", () => {
117
- const bound = bindYjs(SimpleSchema)
113
+ const bound = yjs.bind(SimpleSchema)
118
114
  const factory = bound.factory({ peerId: "stable-peer-id" })
119
115
 
120
- const s1 = factory.create(SimpleSchema)
121
- const s2 = factory.create(SimpleSchema)
116
+ const _s1 = factory.create(SimpleSchema)
117
+ const _s2 = factory.create(SimpleSchema)
122
118
 
123
119
  // Both docs should have the same clientID
124
- const doc1 = yjs(
125
- createYjsDocFromFactory(factory, SimpleSchema),
126
- )
127
- const doc2 = yjs(
128
- createYjsDocFromFactory(factory, SimpleSchema),
129
- )
120
+ const doc1 = yjs.unwrap(createYjsDocFromFactory(factory, SimpleSchema))
121
+ const doc2 = yjs.unwrap(createYjsDocFromFactory(factory, SimpleSchema))
130
122
 
131
123
  expect(doc1.clientID).toBe(doc2.clientID)
132
124
  })
133
125
 
134
126
  it("different peerIds produce different clientIDs", () => {
135
- const bound = bindYjs(SimpleSchema)
127
+ const bound = yjs.bind(SimpleSchema)
136
128
  const factory1 = bound.factory({ peerId: "peer-alpha" })
137
129
  const factory2 = bound.factory({ peerId: "peer-beta" })
138
130
 
139
- const doc1 = yjs(createYjsDocFromFactory(factory1, SimpleSchema))
140
- const doc2 = yjs(createYjsDocFromFactory(factory2, SimpleSchema))
131
+ const doc1 = yjs.unwrap(createYjsDocFromFactory(factory1, SimpleSchema))
132
+ const doc2 = yjs.unwrap(createYjsDocFromFactory(factory2, SimpleSchema))
141
133
 
142
134
  expect(doc1.clientID).not.toBe(doc2.clientID)
143
135
  })
144
136
 
145
137
  it("clientID is a valid uint32", () => {
146
- const bound = bindYjs(SimpleSchema)
138
+ const bound = yjs.bind(SimpleSchema)
147
139
  const factory = bound.factory({ peerId: "test-peer-id-12345" })
148
- const doc = yjs(createYjsDocFromFactory(factory, SimpleSchema))
140
+ const doc = yjs.unwrap(createYjsDocFromFactory(factory, SimpleSchema))
149
141
 
150
142
  expect(typeof doc.clientID).toBe("number")
151
143
  expect(doc.clientID).toBeGreaterThanOrEqual(0)
@@ -157,13 +149,13 @@ describe("bindYjs", () => {
157
149
  const peerId = "deterministic-check-peer"
158
150
 
159
151
  // Simulate two separate "sessions" — both should hash to the same value
160
- const bound1 = bindYjs(SimpleSchema)
152
+ const bound1 = yjs.bind(SimpleSchema)
161
153
  const factory1 = bound1.factory({ peerId })
162
- const doc1 = yjs(createYjsDocFromFactory(factory1, SimpleSchema))
154
+ const doc1 = yjs.unwrap(createYjsDocFromFactory(factory1, SimpleSchema))
163
155
 
164
- const bound2 = bindYjs(SimpleSchema)
156
+ const bound2 = yjs.bind(SimpleSchema)
165
157
  const factory2 = bound2.factory({ peerId })
166
- const doc2 = yjs(createYjsDocFromFactory(factory2, SimpleSchema))
158
+ const doc2 = yjs.unwrap(createYjsDocFromFactory(factory2, SimpleSchema))
167
159
 
168
160
  expect(doc1.clientID).toBe(doc2.clientID)
169
161
  })
@@ -173,14 +165,14 @@ describe("bindYjs", () => {
173
165
  // yjs() escape hatch
174
166
  // -------------------------------------------------------------------------
175
167
 
176
- describe("yjs() escape hatch", () => {
168
+ describe("yjs.unwrap() escape hatch", () => {
177
169
  it("returns the underlying Y.Doc from a createYjsDoc ref", () => {
178
170
  const doc = createYjsDoc(SimpleSchema)
179
171
  change(doc, (d: any) => {
180
172
  d.title.insert(0, "Escape")
181
173
  d.count.set(0)
182
174
  })
183
- const yjsDoc = yjs(doc)
175
+ const yjsDoc = yjs.unwrap(doc)
184
176
 
185
177
  expect(yjsDoc).toBeInstanceOf(Y.Doc)
186
178
  expect(yjsDoc.getMap("root").get("count")).toBe(0)
@@ -192,7 +184,7 @@ describe("bindYjs", () => {
192
184
  d.title.insert(0, "Hello")
193
185
  d.count.set(42)
194
186
  })
195
- const yjsDoc = yjs(doc)
187
+ const yjsDoc = yjs.unwrap(doc)
196
188
  const rootMap = yjsDoc.getMap("root")
197
189
 
198
190
  expect((rootMap.get("title") as Y.Text).toJSON()).toBe("Hello")
@@ -200,7 +192,7 @@ describe("bindYjs", () => {
200
192
  })
201
193
 
202
194
  it("throws for non-Yjs refs (plain object)", () => {
203
- expect(() => yjs({})).toThrow("yjs() requires a ref")
195
+ expect(() => yjs.unwrap({})).toThrow("yjs.unwrap() requires a ref")
204
196
  })
205
197
 
206
198
  it("throws for non-Yjs refs (random object with properties)", () => {
@@ -208,12 +200,12 @@ describe("bindYjs", () => {
208
200
  title: () => "fake",
209
201
  count: () => 0,
210
202
  }
211
- expect(() => yjs(fake)).toThrow("yjs() requires a ref")
203
+ expect(() => yjs.unwrap(fake)).toThrow("yjs.unwrap() requires a ref")
212
204
  })
213
205
 
214
206
  it("mutations through escape hatch are visible via kyneta ref", () => {
215
207
  const doc = createYjsDoc(SimpleSchema)
216
- const yjsDoc = yjs(doc)
208
+ const yjsDoc = yjs.unwrap(doc)
217
209
 
218
210
  // Mutate via raw Yjs
219
211
  yjsDoc.getMap("root").set("count", 99)
@@ -225,7 +217,7 @@ describe("bindYjs", () => {
225
217
  change(doc, (d: any) => {
226
218
  d.title.insert(0, "Hello")
227
219
  })
228
- const yjsDoc = yjs(doc)
220
+ const yjsDoc = yjs.unwrap(doc)
229
221
 
230
222
  const text = yjsDoc.getMap("root").get("title") as Y.Text
231
223
  text.insert(5, " World")
@@ -238,9 +230,15 @@ describe("bindYjs", () => {
238
230
  // Helper — create a doc via a factory and return a ref with escape hatch
239
231
  // ===========================================================================
240
232
 
241
- import { interpret, readable, writable, changefeed, registerSubstrate, unwrap } from "@kyneta/schema"
242
- import type { SubstrateFactory } from "@kyneta/schema"
243
- import type { Schema as SchemaType } from "@kyneta/schema"
233
+ import type { Schema as SchemaType, SubstrateFactory } from "@kyneta/schema"
234
+ import {
235
+ interpret,
236
+ observation,
237
+ readable,
238
+ registerSubstrate,
239
+ unwrap,
240
+ writable,
241
+ } from "@kyneta/schema"
244
242
 
245
243
  /**
246
244
  * Helper to create a kyneta ref from a factory (mimicking what exchange.get does).
@@ -254,7 +252,7 @@ function createYjsDocFromFactory(
254
252
  const doc: any = (interpret as any)(schema, substrate.context())
255
253
  .with(readable)
256
254
  .with(writable)
257
- .with(changefeed)
255
+ .with(observation)
258
256
  .done()
259
257
 
260
258
  // Register for escape hatch — createYjsSubstrate already registered
@@ -263,4 +261,4 @@ function createYjsDocFromFactory(
263
261
  registerSubstrate(doc, substrate)
264
262
 
265
263
  return doc
266
- }
264
+ }