@kyneta/yjs-schema 1.1.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.
@@ -29,7 +29,7 @@ import type {
29
29
  TextChange,
30
30
  TextInstruction,
31
31
  } from "@kyneta/schema"
32
- import { advanceSchema, expandMapOpsToLeaves, RawPath } from "@kyneta/schema"
32
+ import { advanceSchema, expandMapOpsToLeaves, KIND, RawPath } from "@kyneta/schema"
33
33
  import * as Y from "yjs"
34
34
  import { resolveYjsType } from "./yjs-resolve.js"
35
35
 
@@ -74,15 +74,15 @@ export function applyChangeToYjs(
74
74
 
75
75
  case "increment":
76
76
  throw new Error(
77
- "Yjs substrate does not support counter annotations. " +
78
- "Use Schema.number() with ReplaceChange instead. " +
77
+ `Yjs substrate does not support "${change.type}" changes. ` +
78
+ `Counter requires a CRDT backend that supports counters (e.g. Loro). ` +
79
79
  `Attempted IncrementChange with amount=${(change as IncrementChange).amount} at path [${pathToString(path)}].`,
80
80
  )
81
81
 
82
82
  case "tree":
83
83
  throw new Error(
84
- "Yjs substrate does not support tree annotations. " +
85
- "Yjs has no native tree type. " +
84
+ `Yjs substrate does not support "${change.type}" changes. ` +
85
+ `Tree requires a CRDT backend that supports trees (e.g. Loro). ` +
86
86
  `Attempted TreeChange at path [${pathToString(path)}].`,
87
87
  )
88
88
 
@@ -250,24 +250,16 @@ function maybeCreateSharedType(
250
250
  ): unknown {
251
251
  if (schema === undefined) return value
252
252
 
253
- const structural = unwrapAnnotations(schema)
254
- const tag = schema._kind === "annotated" ? schema.tag : undefined
255
-
256
- // Annotated text Y.Text
257
- if (tag === "text") {
258
- const text = new Y.Text()
259
- if (typeof value === "string" && value.length > 0) {
260
- text.insert(0, value)
253
+ switch (schema[KIND]) {
254
+ // First-class text Y.Text
255
+ case "text": {
256
+ const text = new Y.Text()
257
+ if (typeof value === "string" && value.length > 0) {
258
+ text.insert(0, value)
259
+ }
260
+ return text
261
261
  }
262
- return text
263
- }
264
-
265
- // Annotated counter/movable/tree → should not reach here (thrown earlier)
266
- if (tag === "counter" || tag === "movable" || tag === "tree") {
267
- throw new Error(`Yjs substrate does not support "${tag}" annotations.`)
268
- }
269
262
 
270
- switch (structural._kind) {
271
263
  case "product": {
272
264
  if (
273
265
  value === null ||
@@ -277,13 +269,13 @@ function maybeCreateSharedType(
277
269
  ) {
278
270
  return value
279
271
  }
280
- return createStructuredMap(value as Record<string, unknown>, structural)
272
+ return createStructuredMap(value as Record<string, unknown>, schema)
281
273
  }
282
274
 
283
275
  case "sequence": {
284
276
  if (!Array.isArray(value)) return value
285
277
  const arr = new Y.Array()
286
- const itemSchema = structural.item
278
+ const itemSchema = schema.item
287
279
  const items = (value as unknown[]).map(item =>
288
280
  maybeCreateSharedType(item, itemSchema),
289
281
  )
@@ -301,15 +293,26 @@ function maybeCreateSharedType(
301
293
  return value
302
294
  }
303
295
  const map = new Y.Map()
304
- const valueSchema = structural.item
296
+ const valueSchema = schema.item
305
297
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
306
298
  map.set(k, maybeCreateSharedType(v, valueSchema))
307
299
  }
308
300
  return map
309
301
  }
310
302
 
303
+ // Unsupported first-class CRDT types — should not reach here
304
+ // (rejected at bind time by caps check)
305
+ case "counter":
306
+ case "set":
307
+ case "tree":
308
+ case "movable":
309
+ throw new Error(
310
+ `Yjs substrate does not support [KIND]="${schema[KIND]}". ` +
311
+ `This should have been caught at bind() time.`,
312
+ )
313
+
311
314
  default:
312
- // Scalar, sum, or other — return as plain value
315
+ // Scalar, sum — return as plain value
313
316
  return value
314
317
  }
315
318
  }
@@ -326,9 +329,8 @@ function createStructuredMap(
326
329
  productSchema: SchemaNode,
327
330
  ): Y.Map<any> {
328
331
  const map = new Y.Map()
329
- const structural = unwrapAnnotations(productSchema)
330
332
 
331
- if (structural._kind !== "product") {
333
+ if (productSchema[KIND] !== "product") {
332
334
  // Fallback: set all values as plain
333
335
  for (const [key, val] of Object.entries(obj)) {
334
336
  map.set(key, val)
@@ -339,25 +341,22 @@ function createStructuredMap(
339
341
  // Process fields present in the value object
340
342
  for (const [key, val] of Object.entries(obj)) {
341
343
  if (val === undefined) continue
342
- const fieldSchema = structural.fields[key]
344
+ const fieldSchema = productSchema.fields[key]
343
345
  const yjsVal = fieldSchema ? maybeCreateSharedType(val, fieldSchema) : val
344
346
  map.set(key, yjsVal)
345
347
  }
346
348
 
347
- // Create shared types for annotated fields declared in the schema
349
+ // Create shared types for first-class CRDT fields declared in the schema
348
350
  // but missing from the value object. This ensures Yjs containers
349
351
  // exist for later mutation (e.g. .insert() on a text field inside
350
352
  // a struct inside a record/list).
351
353
  for (const [key, fieldSchema] of Object.entries(
352
- structural.fields as Record<string, SchemaNode>,
354
+ productSchema.fields as Record<string, SchemaNode>,
353
355
  )) {
354
356
  if (key in obj) continue // already processed above
355
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
356
- if (tag === "text") {
357
+ if (fieldSchema[KIND] === "text") {
357
358
  map.set(key, new Y.Text())
358
359
  }
359
- // Other annotated container types (counter, movable, tree) are
360
- // unsupported in Yjs and will throw if used elsewhere.
361
360
  }
362
361
 
363
362
  return map
@@ -380,7 +379,7 @@ function createStructuredMap(
380
379
  *
381
380
  * @param events - The events from the `observeDeep` callback
382
381
  */
383
- export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
382
+ export function eventsToOps(events: Y.YEvent<any>[], schema: SchemaNode): Op[] {
384
383
  const ops: Op[] = []
385
384
 
386
385
  for (const event of events) {
@@ -391,7 +390,7 @@ export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
391
390
  }
392
391
  }
393
392
 
394
- return expandMapOpsToLeaves(ops)
393
+ return expandMapOpsToLeaves(ops, schema)
395
394
  }
396
395
 
397
396
  // ---------------------------------------------------------------------------
@@ -541,17 +540,6 @@ function extractEventValue(value: unknown): unknown {
541
540
  // Schema helpers
542
541
  // ---------------------------------------------------------------------------
543
542
 
544
- /**
545
- * Unwrap annotation wrappers to reach the structural schema node.
546
- */
547
- function unwrapAnnotations(schema: SchemaNode): SchemaNode {
548
- let s = schema
549
- while (s._kind === "annotated" && s.schema !== undefined) {
550
- s = s.schema
551
- }
552
- return s
553
- }
554
-
555
543
  /**
556
544
  * Resolve the schema at a given path by walking through advanceSchema.
557
545
  */
@@ -567,8 +555,9 @@ function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
567
555
  * Get the item schema from a sequence schema, if available.
568
556
  */
569
557
  function getItemSchema(schema: SchemaNode): SchemaNode | undefined {
570
- const structural = unwrapAnnotations(schema)
571
- return structural._kind === "sequence" ? structural.item : undefined
558
+ if (schema[KIND] === "sequence") return schema.item
559
+ if (schema[KIND] === "movable") return schema.item
560
+ return undefined
572
561
  }
573
562
 
574
563
  /**
@@ -578,12 +567,14 @@ function getFieldSchema(
578
567
  schema: SchemaNode,
579
568
  key: string,
580
569
  ): SchemaNode | undefined {
581
- const structural = unwrapAnnotations(schema)
582
- if (structural._kind === "product") {
583
- return structural.fields[key]
570
+ if (schema[KIND] === "product") {
571
+ return schema.fields[key]
584
572
  }
585
- if (structural._kind === "map") {
586
- return structural.item
573
+ if (schema[KIND] === "map") {
574
+ return schema.item
575
+ }
576
+ if (schema[KIND] === "set") {
577
+ return schema.item
587
578
  }
588
579
  return undefined
589
580
  }
@@ -594,4 +585,4 @@ function getFieldSchema(
594
585
 
595
586
  function pathToString(path: Path): string {
596
587
  return path.segments.map(seg => String(seg.resolve())).join(".")
597
- }
588
+ }
package/src/create.ts CHANGED
@@ -21,8 +21,8 @@ import type {
21
21
  SubstratePayload,
22
22
  } from "@kyneta/schema"
23
23
  import {
24
- changefeed,
25
24
  interpret,
25
+ observation,
26
26
  readable,
27
27
  registerSubstrate,
28
28
  writable,
@@ -71,7 +71,7 @@ function registerDoc(
71
71
  const doc: any = (interpret as any)(schema, substrate.context())
72
72
  .with(readable)
73
73
  .with(writable)
74
- .with(changefeed)
74
+ .with(observation)
75
75
  .done()
76
76
  substrates.set(doc, substrate)
77
77
  // Also register in the general unwrap() registry so that the
package/src/index.ts CHANGED
@@ -34,33 +34,18 @@ export {
34
34
  version,
35
35
  } from "./sync.js"
36
36
 
37
- // Text annotation convenience — so users don't need LoroSchema just for text()
38
- import type { AnnotatedSchema } from "@kyneta/schema"
39
- import { Schema } from "@kyneta/schema"
40
-
41
- /**
42
- * Collaborative text (CRDT). Produces `annotated("text")`.
43
- *
44
- * The annotation implies scalar string semantics for reads,
45
- * but the Yjs substrate provides collaborative editing (insert, delete)
46
- * via Y.Text.
47
- *
48
- * This is a convenience re-export so that `@kyneta/yjs-schema` users
49
- * don't need to import `LoroSchema` just for `text()`.
50
- */
51
- export function text(): AnnotatedSchema<"text", undefined> {
52
- return Schema.annotated("text")
53
- }
54
-
55
37
  // Types (re-exported for convenience)
56
- export type { Changeset, Op, Ref, SubstratePayload } from "@kyneta/schema"
38
+ export type { Changeset } from "@kyneta/changefeed"
39
+ export type { Op, Ref, SubstratePayload } from "@kyneta/schema"
57
40
 
58
41
  // ---------------------------------------------------------------------------
59
42
  // Low-level primitives — for power users and custom substrate compositions
60
43
  // ---------------------------------------------------------------------------
61
44
 
62
- // Bindconvenience wrapper for Yjs CRDT substrate
63
- export { bindYjs } from "./bind-yjs.js"
45
+ // Namespacethe yjs substrate namespace (replaces standalone escape hatch;
46
+ // the old `yjs(ref)` call is now `yjs.unwrap(ref)`)
47
+ export { yjs } from "./bind-yjs.js"
48
+ export type { YjsCaps } from "./bind-yjs.js"
64
49
  // Change mapping
65
50
  export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
66
51
  // Container creation
@@ -68,10 +53,12 @@ export { ensureContainers } from "./populate.js"
68
53
  // Reader
69
54
  export { yjsReader } from "./reader.js"
70
55
  // Substrate
71
- export { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
56
+ export {
57
+ createYjsSubstrate,
58
+ yjsReplicaFactory,
59
+ yjsSubstrateFactory,
60
+ } from "./substrate.js"
72
61
  // Version
73
62
  export { YjsVersion } from "./version.js"
74
- // Escape hatch — access the underlying Y.Doc from a ref
75
- export { yjs } from "./yjs-escape.js"
76
63
  // Container resolution
77
- export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
64
+ export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
package/src/populate.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  // shared types (Y.Text, Y.Array, Y.Map) and plain value slots uniformly.
16
16
 
17
17
  import type { Schema as SchemaNode } from "@kyneta/schema"
18
- import { STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
18
+ import { KIND, STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
19
19
  import * as Y from "yjs"
20
20
 
21
21
  // ---------------------------------------------------------------------------
@@ -26,9 +26,9 @@ import * as Y from "yjs"
26
26
  * Ensure that a Y.Doc's root map contains the correct Yjs shared types
27
27
  * matching the schema structure.
28
28
  *
29
- * Obtains the root map via `doc.getMap("root")`, unwraps the root product
30
- * schema, and creates empty containers for each field within a single
31
- * `doc.transact()` call for atomicity.
29
+ * Obtains the root map via `doc.getMap("root")`, reads the root product
30
+ * schema's fields, and creates empty containers for each field within a
31
+ * single `doc.transact()` call for atomicity.
32
32
  *
33
33
  * When `conditional` is true, fields that already exist in the root map
34
34
  * are skipped. This is the correct mode after hydration — containers
@@ -45,7 +45,7 @@ import * as Y from "yjs"
45
45
  * structural ops across all peers, enabling Yjs deduplication on merge.
46
46
  *
47
47
  * @param doc - The Y.Doc to prepare
48
- * @param schema - The root document schema (typically annotated("doc", product))
48
+ * @param schema - The root document schema (a ProductSchema)
49
49
  * @param conditional - If true, skip fields that already exist in the root map.
50
50
  * Context: jj:smmulzkm (two-phase substrate construction)
51
51
  */
@@ -56,15 +56,7 @@ export function ensureContainers(
56
56
  ): void {
57
57
  const rootMap = doc.getMap("root")
58
58
 
59
- let rootProduct = schema
60
- while (
61
- rootProduct._kind === "annotated" &&
62
- rootProduct.schema !== undefined
63
- ) {
64
- rootProduct = rootProduct.schema
65
- }
66
-
67
- if (rootProduct._kind !== "product") {
59
+ if (schema[KIND] !== "product") {
68
60
  return
69
61
  }
70
62
 
@@ -75,7 +67,7 @@ export function ensureContainers(
75
67
 
76
68
  try {
77
69
  doc.transact(() => {
78
- for (const [key, fieldSchema] of Object.entries(rootProduct.fields).sort(
70
+ for (const [key, fieldSchema] of Object.entries(schema.fields).sort(
79
71
  ([a], [b]) => a.localeCompare(b),
80
72
  )) {
81
73
  if (conditional && rootMap.has(key)) continue
@@ -95,62 +87,36 @@ export function ensureContainers(
95
87
  /**
96
88
  * Ensure a root-level Yjs shared type exists for a schema field.
97
89
  *
98
- * Dispatches based on the schema annotation tag and structural kind:
99
- * - `annotated("text")` → empty Y.Text
100
- * - `annotated("counter")` → throws (unsupported in Yjs)
101
- * - `annotated("movable")` → throws (unsupported in Yjs)
102
- * - `annotated("tree")` → throws (unsupported in Yjs)
103
- * - `product` → empty Y.Map (recursive for nested products)
104
- * - `sequence` → empty Y.Array
105
- * - `map` → empty Y.Map
106
- * - `scalar`/`sum` → no-op (plain values don't need containers)
90
+ * Dispatches on `[KIND]`:
91
+ * - `"text"` → empty Y.Text
92
+ * - `"product"` → empty Y.Map (recursive for nested products)
93
+ * - `"sequence"` → empty Y.Array
94
+ * - `"map"` → empty Y.Map
95
+ * - `"scalar"` / `"sum"` Zero.structural default
96
+ * - `"counter"` / `"set"` / `"tree"` / `"movable"` throw (not supported by Yjs)
107
97
  */
108
98
  function ensureRootField(
109
99
  rootMap: Y.Map<unknown>,
110
100
  key: string,
111
101
  fieldSchema: SchemaNode,
112
102
  ): void {
113
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
114
-
115
- switch (tag) {
103
+ switch (fieldSchema[KIND]) {
116
104
  case "text":
117
105
  rootMap.set(key, new Y.Text())
118
106
  return
119
107
 
120
- case "counter":
121
- throw new Error(
122
- `Yjs substrate does not support counter annotations. ` +
123
- `Use Schema.number() with ReplaceChange instead. ` +
124
- `Encountered counter annotation at root field "${key}".`,
125
- )
126
-
127
- case "movable":
128
- throw new Error(
129
- `Yjs substrate does not support movable list annotations. ` +
130
- `Yjs has no native movable list type. ` +
131
- `Encountered movable annotation at root field "${key}".`,
132
- )
133
-
134
- case "tree":
135
- throw new Error(
136
- `Yjs substrate does not support tree annotations. ` +
137
- `Yjs has no native tree type. ` +
138
- `Encountered tree annotation at root field "${key}".`,
139
- )
140
- }
141
-
142
- const structural = unwrapAnnotations(fieldSchema)
143
-
144
- switch (structural._kind) {
145
108
  case "product":
146
- rootMap.set(key, ensureMapContainers(structural))
109
+ rootMap.set(key, ensureMapContainers(fieldSchema))
147
110
  return
111
+
148
112
  case "sequence":
149
113
  rootMap.set(key, new Y.Array())
150
114
  return
115
+
151
116
  case "map":
152
117
  rootMap.set(key, new Y.Map())
153
118
  return
119
+
154
120
  case "scalar":
155
121
  case "sum": {
156
122
  // Plain values don't need shared type containers, but they DO
@@ -162,6 +128,16 @@ function ensureRootField(
162
128
  }
163
129
  return
164
130
  }
131
+
132
+ case "counter":
133
+ case "set":
134
+ case "tree":
135
+ case "movable":
136
+ throw new Error(
137
+ `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
138
+ `Supported kinds: text, product, sequence, map, scalar, sum. ` +
139
+ `Encountered unsupported kind at root field "${key}".`,
140
+ )
165
141
  }
166
142
  }
167
143
 
@@ -175,37 +151,33 @@ function ensureRootField(
175
151
  *
176
152
  * Only creates containers for fields that require Yjs shared types
177
153
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
178
- * Scalar and sum fields are left empty they'll be written as plain
179
- * values via change() when needed.
154
+ * Scalar and sum fields are set to their structural zero defaults.
180
155
  */
181
156
  function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
182
157
  const map = new Y.Map()
183
- const structural = unwrapAnnotations(schema)
184
158
 
185
- if (structural._kind !== "product") return map
159
+ if (schema[KIND] !== "product") return map
186
160
 
187
161
  for (const [key, fieldSchema] of Object.entries(
188
- structural.fields as Record<string, SchemaNode>,
162
+ schema.fields as Record<string, SchemaNode>,
189
163
  ).sort(([a], [b]) => a.localeCompare(b))) {
190
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
191
-
192
- if (tag === "text") {
193
- map.set(key, new Y.Text())
194
- continue
195
- }
196
-
197
- const fs = unwrapAnnotations(fieldSchema)
164
+ switch (fieldSchema[KIND]) {
165
+ case "text":
166
+ map.set(key, new Y.Text())
167
+ break
198
168
 
199
- switch (fs._kind) {
200
169
  case "product":
201
170
  map.set(key, ensureMapContainers(fieldSchema))
202
171
  break
172
+
203
173
  case "sequence":
204
174
  map.set(key, new Y.Array())
205
175
  break
176
+
206
177
  case "map":
207
178
  map.set(key, new Y.Map())
208
179
  break
180
+
209
181
  case "scalar":
210
182
  case "sum": {
211
183
  const zero = Zero.structural(fieldSchema)
@@ -214,23 +186,18 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
214
186
  }
215
187
  break
216
188
  }
189
+
190
+ case "counter":
191
+ case "set":
192
+ case "tree":
193
+ case "movable":
194
+ throw new Error(
195
+ `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
196
+ `Supported kinds: text, product, sequence, map, scalar, sum. ` +
197
+ `Encountered unsupported kind at nested field "${key}".`,
198
+ )
217
199
  }
218
200
  }
219
201
 
220
202
  return map
221
- }
222
-
223
- // ---------------------------------------------------------------------------
224
- // Helpers
225
- // ---------------------------------------------------------------------------
226
-
227
- /**
228
- * Unwrap annotation wrappers to reach the structural schema node.
229
- */
230
- function unwrapAnnotations(schema: SchemaNode): SchemaNode {
231
- let s = schema
232
- while (s._kind === "annotated" && s.schema !== undefined) {
233
- s = s.schema
234
- }
235
- return s
236
- }
203
+ }
package/src/substrate.ts CHANGED
@@ -101,7 +101,7 @@ export function createYjsSubstrate(
101
101
  // wrappedPrepare (changefeed layer) still buffers the op.
102
102
  },
103
103
 
104
- onFlush(origin?: string): void {
104
+ onFlush(_origin?: string): void {
105
105
  if (!inOurTransaction && pendingChanges.length > 0) {
106
106
  // Local write: apply accumulated changes within a single
107
107
  // Yjs transaction tagged with our origin for echo suppression.
@@ -132,6 +132,18 @@ export function createYjsSubstrate(
132
132
  return new YjsVersion(Y.encodeStateVector(doc))
133
133
  },
134
134
 
135
+ baseVersion(): YjsVersion {
136
+ // Yjs substrate: base is always the initial state (no advance supported).
137
+ return new YjsVersion(new Uint8Array([0]))
138
+ },
139
+
140
+ advance(_to: YjsVersion): void {
141
+ throw new Error(
142
+ "advance() on a live Yjs substrate is not yet supported. " +
143
+ "Use advance() on a YjsReplica instead.",
144
+ )
145
+ },
146
+
135
147
  exportEntirety(): SubstratePayload {
136
148
  return {
137
149
  kind: "entirety",
@@ -180,7 +192,7 @@ export function createYjsSubstrate(
180
192
  }
181
193
 
182
194
  // Convert Yjs events → kyneta Ops
183
- const ops = eventsToOps(events)
195
+ const ops = eventsToOps(events, schema)
184
196
  if (ops.length === 0) {
185
197
  return
186
198
  }
@@ -239,24 +251,57 @@ export function createYjsSubstrate(
239
251
  * storage without ever interpreting document fields.
240
252
  */
241
253
  export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
254
+ let currentDoc = doc
255
+ let currentBase: YjsVersion = new YjsVersion(
256
+ Y.encodeStateVector(new Y.Doc()),
257
+ )
258
+
242
259
  return {
243
- [BACKING_DOC]: doc,
260
+ get [BACKING_DOC]() {
261
+ return currentDoc
262
+ },
244
263
 
245
264
  version(): YjsVersion {
246
- return new YjsVersion(Y.encodeStateVector(doc))
265
+ return new YjsVersion(Y.encodeStateVector(currentDoc))
266
+ },
267
+
268
+ baseVersion(): YjsVersion {
269
+ return currentBase
270
+ },
271
+
272
+ advance(to: YjsVersion): void {
273
+ const baseCmp = currentBase.compare(to)
274
+ if (baseCmp === "ahead") {
275
+ throw new Error("advance(): target is behind base version")
276
+ }
277
+ const currentCmp = to.compare(this.version())
278
+ if (currentCmp === "ahead") {
279
+ throw new Error("advance(): target is ahead of current version")
280
+ }
281
+
282
+ // Yjs can only do full projection (to = version).
283
+ // For any to < version, it's a no-op — undershoot contract.
284
+ if (currentCmp !== "equal") return
285
+
286
+ // Full projection: create a new doc with current state, no history.
287
+ const update = Y.encodeStateAsUpdate(currentDoc)
288
+ const newDoc = new Y.Doc()
289
+ Y.applyUpdate(newDoc, update)
290
+ currentDoc = newDoc
291
+ currentBase = new YjsVersion(Y.encodeStateVector(currentDoc))
247
292
  },
248
293
 
249
294
  exportEntirety(): SubstratePayload {
250
295
  return {
251
296
  kind: "entirety",
252
297
  encoding: "binary",
253
- data: Y.encodeStateAsUpdate(doc),
298
+ data: Y.encodeStateAsUpdate(currentDoc),
254
299
  }
255
300
  },
256
301
 
257
302
  exportSince(since: YjsVersion): SubstratePayload | null {
258
303
  try {
259
- const bytes = Y.encodeStateAsUpdate(doc, since.sv)
304
+ const bytes = Y.encodeStateAsUpdate(currentDoc, since.sv)
260
305
  return { kind: "since", encoding: "binary", data: bytes }
261
306
  } catch {
262
307
  return null
@@ -273,7 +318,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
273
318
  "If you recently switched CRDT backends, stale clients may be sending incompatible data.",
274
319
  )
275
320
  }
276
- Y.applyUpdate(doc, payload.data)
321
+ Y.applyUpdate(currentDoc, payload.data)
277
322
  },
278
323
  } as Replica<YjsVersion>
279
324
  }