@kyneta/yjs-schema 1.6.0 → 1.7.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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BACKING_DOC, KIND, NATIVE, RawPath, STRUCTURAL_YJS_CLIENT_ID, SYNC_COLLABORATIVE, Schema, Zero, advanceSchema, applyChanges, base64ToUint8Array, buildWritableContext, change, createBindingTarget, createDoc, createRef, deriveSchemaBinding, executeBatch, expandMapOpsToLeaves, exportEntirety, exportSince, merge, richTextChange, subscribe, subscribeNode, uint8ArrayToBase64, unwrap, version, versionVectorCompare, versionVectorMeet } from "@kyneta/schema";
1
+ import { BACKING_DOC, KIND, NATIVE, RawPath, STRUCTURAL_YJS_CLIENT_ID, SYNC_COLLABORATIVE, Schema, applyChange, applyChanges, base64ToUint8Array, buildWritableContext, change, createBindingTarget, createDoc, createMaterializeInterpreter, createRef, deriveSchemaBinding, executeBatch, expandMapOpsToLeaves, exportEntirety, exportSince, foldPath, interpret, isNonNullObject, materializeContextFromResolver, merge, pathSchema, plainReader, richTextChange, subscribe, subscribeNode, uint8ArrayToBase64, unwrap, version, versionVectorCompare, versionVectorMeet } from "@kyneta/schema";
2
2
  import * as Y from "yjs";
3
3
  import { createSnapshot, decodeStateVector, encodeSnapshot, encodeStateVector, snapshot } from "yjs";
4
4
  //#region src/populate.ts
@@ -10,14 +10,10 @@ import { createSnapshot, decodeStateVector, encodeSnapshot, encodeStateVector, s
10
10
  * schema's fields, and creates empty containers for each field within a
11
11
  * single `doc.transact()` call for atomicity.
12
12
  *
13
- * When `conditional` is true, fields that already exist in the root map
14
- * are skipped. This is the correct mode after hydration — containers
15
- * present from stored state must not be overwritten (each `rootMap.set()`
16
- * is a CRDT write that advances the version vector and may conflict
17
- * with stored operations).
18
- *
19
- * When `conditional` is false (default), all fields are created
20
- * unconditionally. This is the correct mode for fresh documents.
13
+ * Container fields (text, product, sequence, map) are created if absent;
14
+ * existing containers are preserved (calling `rootMap.set()` on a field
15
+ * that already exists would be a destructive CRDT write). Scalar and sum
16
+ * fields are no-ops the materializer handles zeros.
21
17
  *
22
18
  * **Structural identity:** This function temporarily sets `doc.clientID`
23
19
  * to `STRUCTURAL_YJS_CLIENT_ID` (0) for the duration of container creation,
@@ -31,22 +27,16 @@ import { createSnapshot, decodeStateVector, encodeSnapshot, encodeStateVector, s
31
27
  *
32
28
  * @param doc - The Y.Doc to prepare
33
29
  * @param schema - The root document schema (a ProductSchema)
34
- * @param conditional - If true, skip fields that already exist in the root map.
35
- * Context: jj:smmulzkm (two-phase substrate construction)
36
30
  * @param binding - Optional SchemaBinding for identity-keyed containers.
37
31
  */
38
- function ensureContainers(doc, schema, conditional = false, binding) {
32
+ function ensureContainers(doc, schema, binding) {
39
33
  const rootMap = doc.getMap("root");
40
34
  if (schema[KIND] !== "product") return;
41
35
  const savedClientID = doc.clientID;
42
36
  doc.clientID = STRUCTURAL_YJS_CLIENT_ID;
43
37
  try {
44
38
  doc.transact(() => {
45
- for (const [key, fieldSchema] of Object.entries(schema.fields).sort(([a], [b]) => a.localeCompare(b))) {
46
- const mapKey = binding?.forward.get(key) ?? key;
47
- if (conditional && rootMap.has(mapKey)) continue;
48
- ensureRootField(rootMap, mapKey, fieldSchema, binding, key);
49
- }
39
+ for (const [key, fieldSchema] of Object.entries(schema.fields).sort(([a], [b]) => a.localeCompare(b))) ensureRootField(rootMap, binding?.forward.get(key) ?? key, fieldSchema, binding, key);
50
40
  });
51
41
  } finally {
52
42
  doc.clientID = savedClientID;
@@ -60,7 +50,7 @@ function ensureContainers(doc, schema, conditional = false, binding) {
60
50
  * - `"product"` → empty Y.Map (recursive for nested products)
61
51
  * - `"sequence"` → empty Y.Array
62
52
  * - `"map"` → empty Y.Map
63
- * - `"scalar"` / `"sum"` → Zero.structural default
53
+ * - `"scalar"` / `"sum"` → no-op (materializer zero fallback)
64
54
  * - `"counter"` / `"set"` / `"tree"` / `"movable"` → throw (not supported by Yjs)
65
55
  *
66
56
  * @param rootMap - The root Y.Map to set the field on.
@@ -70,6 +60,7 @@ function ensureContainers(doc, schema, conditional = false, binding) {
70
60
  * @param prefix - The absolute schema path prefix for this field (used for nested lookups).
71
61
  */
72
62
  function ensureRootField(rootMap, key, fieldSchema, binding, prefix) {
63
+ if (rootMap.has(key)) return;
73
64
  switch (fieldSchema[KIND]) {
74
65
  case "text":
75
66
  case "richtext":
@@ -85,11 +76,7 @@ function ensureRootField(rootMap, key, fieldSchema, binding, prefix) {
85
76
  rootMap.set(key, new Y.Map());
86
77
  return;
87
78
  case "scalar":
88
- case "sum": {
89
- const zero = Zero.structural(fieldSchema);
90
- if (zero !== void 0) rootMap.set(key, zero);
91
- return;
92
- }
79
+ case "sum": return;
93
80
  case "counter":
94
81
  case "set":
95
82
  case "tree":
@@ -102,7 +89,7 @@ function ensureRootField(rootMap, key, fieldSchema, binding, prefix) {
102
89
  *
103
90
  * Only creates containers for fields that require Yjs shared types
104
91
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
105
- * Scalar and sum fields are set to their structural zero defaults.
92
+ * Scalar and sum fields are skipped (materializer zero fallback).
106
93
  *
107
94
  * **Identity-keying:** When a `binding` is provided, computes the
108
95
  * absolute schema path for each nested field (`prefix.fieldName`) and
@@ -134,11 +121,7 @@ function ensureMapContainers(schema, binding, prefix) {
134
121
  map.set(mapKey, new Y.Map());
135
122
  break;
136
123
  case "scalar":
137
- case "sum": {
138
- const zero = Zero.structural(fieldSchema);
139
- if (zero !== void 0) map.set(mapKey, zero);
140
- break;
141
- }
124
+ case "sum": break;
142
125
  case "counter":
143
126
  case "set":
144
127
  case "tree":
@@ -158,68 +141,29 @@ function ensureMapContainers(schema, binding, prefix) {
158
141
  * - `Y.Text` → terminal (cannot step further)
159
142
  * - Plain value → terminal (return `undefined`)
160
143
  *
161
- * @param current - The current position (a Yjs shared type or plain value)
162
- * @param segment - The path segment to follow
163
- * @param identity - Optional identity hash to use instead of the segment's resolved value
144
+ * `_nextSchema` is part of the `PathStepper` contract for Loro's root
145
+ * dispatch but is unused here Yjs's `instanceof` dispatch doesn't
146
+ * need to look ahead at the next schema kind.
164
147
  */
165
- function stepIntoYjs(current, segment, identity) {
148
+ const stepIntoYjs = (current, _nextSchema, segment, identity) => {
166
149
  const resolved = segment.resolve();
167
150
  if (current instanceof Y.Map) return current.get(identity ?? resolved);
168
151
  if (current instanceof Y.Array) return current.get(resolved);
169
152
  if (current instanceof Y.Text) throw new Error(`yjs-resolve: cannot step into Y.Text`);
170
- }
153
+ };
171
154
  /**
172
155
  * Resolve a Yjs shared type (or plain value) at the given path.
173
156
  *
174
- * Left-folds over path segments using `advanceSchema` for pure schema
175
- * descent and `stepIntoYjs` for Yjs-specific navigation.
176
- *
177
- * When a `binding` is provided, each step computes the absolute schema
178
- * path and looks up the identity hash from `binding.forward`. This
179
- * identity hash is used instead of the field name at every product-field
180
- * boundary (root and nested).
157
+ * Thin wrapper around `foldPath(stepIntoYjs, ...)`. Returns the
158
+ * `PathFoldResult` shape from core — `{ resolved, schema }`.
181
159
  *
182
- * Returns both the Yjs shared type (or plain value) and the schema at
183
- * the terminal position. For an empty path, returns the root map and
184
- * root schema.
160
+ * When a `binding` is provided, every product-field boundary uses the
161
+ * identity hash from `binding.forward` instead of the field name.
185
162
  *
186
- * @param rootMap - The root `Y.Map` obtained via `doc.getMap("root")`
187
- * @param rootSchema - The root document schema
188
- * @param path - The path to resolve
189
- * @param binding - Optional SchemaBinding for identity-keyed navigation.
163
+ * For an empty path, returns the root map and root schema.
190
164
  */
191
165
  function resolveYjsType(rootMap, rootSchema, path, binding) {
192
- let current = rootMap;
193
- let schema = rootSchema;
194
- let absPath = "";
195
- for (let i = 0; i < path.length; i++) {
196
- const seg = path.segments[i];
197
- if (!seg) throw new Error(`Missing segment at index ${i}`);
198
- const nextSchema = advanceSchema(schema, seg);
199
- let identity;
200
- if (binding && seg.role === "key") {
201
- const segStr = seg.resolve();
202
- absPath = absPath ? `${absPath}.${segStr}` : segStr;
203
- identity = binding.forward.get(absPath);
204
- }
205
- current = stepIntoYjs(current, seg, identity);
206
- schema = nextSchema;
207
- if (schema[KIND] === "sum" && i + 1 < path.length) {
208
- for (let j = i + 1; j < path.length; j++) {
209
- const remaining = path.segments[j];
210
- if (!remaining) throw new Error(`Missing segment at index ${j}`);
211
- current = current?.[remaining.resolve()];
212
- }
213
- return {
214
- resolved: current,
215
- schema
216
- };
217
- }
218
- }
219
- return {
220
- resolved: current,
221
- schema
222
- };
166
+ return foldPath(rootMap, rootSchema, path, stepIntoYjs, binding);
223
167
  }
224
168
  //#endregion
225
169
  //#region src/change-mapping.ts
@@ -254,6 +198,7 @@ function applyChangeToYjs(rootMap, rootSchema, path, change, binding) {
254
198
  return;
255
199
  case "increment": throw new Error(`Yjs substrate does not support "${change.type}" changes. Counter requires a CRDT backend that supports counters (e.g. Loro). Attempted IncrementChange with amount=${change.amount} at path [${pathToString(path)}].`);
256
200
  case "tree": throw new Error(`Yjs substrate does not support "${change.type}" changes. Tree requires a CRDT backend that supports trees (e.g. Loro). Attempted TreeChange at path [${pathToString(path)}].`);
201
+ case "set-op": throw new Error(`Yjs substrate does not support "${change.type}" changes. Schema.set requires "add-wins-per-key" which is not in YjsLaws. Attempted SetChange at path [${pathToString(path)}].`);
257
202
  default: throw new Error(`applyChangeToYjs: unsupported change type "${change.type}"`);
258
203
  }
259
204
  }
@@ -284,7 +229,7 @@ function applyRichTextChange(rootMap, rootSchema, path, change, binding) {
284
229
  function applySequenceChange(rootMap, rootSchema, path, change, binding) {
285
230
  const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
286
231
  if (!(resolved instanceof Y.Array)) throw new Error(`applyChangeToYjs: SequenceChange target at path [${pathToString(path)}] is not a Y.Array`);
287
- const itemSchema = getItemSchema(resolveSchemaAtPath(rootSchema, path));
232
+ const itemSchema = getItemSchema(pathSchema(rootSchema, path));
288
233
  let cursor = 0;
289
234
  for (const instruction of change.instructions) if ("retain" in instruction) cursor += instruction.retain;
290
235
  else if ("delete" in instruction) resolved.delete(cursor, instruction.delete);
@@ -298,13 +243,13 @@ function applySequenceChange(rootMap, rootSchema, path, change, binding) {
298
243
  function applyMapChange(rootMap, rootSchema, path, change, binding) {
299
244
  const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
300
245
  if (!(resolved instanceof Y.Map)) throw new Error(`applyChangeToYjs: MapChange target at path [${pathToString(path)}] is not a Y.Map`);
301
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
246
+ const targetSchema = pathSchema(rootSchema, path);
302
247
  if (change.delete) for (const key of change.delete) resolved.delete(key);
303
248
  if (change.set) for (const [key, value] of Object.entries(change.set)) {
304
249
  const yjsValue = maybeCreateSharedType(value, getFieldSchema(targetSchema, key));
305
250
  let mapKey = key;
306
251
  if (binding && targetSchema[KIND] === "product") {
307
- const parentAbsPath = path.segments.filter((s) => s.role === "key").map((s) => s.resolve()).join(".");
252
+ const parentAbsPath = path.segments.filter((s) => s.role === "field").map((s) => s.resolve()).join(".");
308
253
  const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key;
309
254
  const identity = binding.forward.get(absPath);
310
255
  if (identity) mapKey = identity;
@@ -319,18 +264,18 @@ function applyReplaceChange(rootMap, rootSchema, path, change, binding) {
319
264
  const parentPath = path.slice(0, -1);
320
265
  const { resolved: parent } = resolveYjsType(rootMap, rootSchema, parentPath, binding);
321
266
  const resolved = lastSeg.resolve();
322
- if (parent instanceof Y.Map && lastSeg.role === "key") {
323
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
267
+ if (parent instanceof Y.Map && (lastSeg.role === "field" || lastSeg.role === "entry")) {
268
+ const targetSchema = pathSchema(rootSchema, path);
324
269
  const yjsValue = maybeCreateSharedType(change.value, targetSchema);
325
270
  let mapKey = resolved;
326
- if (binding) {
327
- const absPath = path.segments.filter((s) => s.role === "key").map((s) => s.resolve()).join(".");
271
+ if (binding && lastSeg.role === "field") {
272
+ const absPath = path.segments.filter((s) => s.role === "field").map((s) => s.resolve()).join(".");
328
273
  const identity = binding.forward.get(absPath);
329
274
  if (identity) mapKey = identity;
330
275
  }
331
276
  parent.set(mapKey, yjsValue);
332
277
  } else if (parent instanceof Y.Array && lastSeg.role === "index") {
333
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
278
+ const targetSchema = pathSchema(rootSchema, path);
334
279
  const yjsValue = maybeCreateSharedType(change.value, targetSchema);
335
280
  parent.delete(resolved, 1);
336
281
  parent.insert(resolved, [yjsValue]);
@@ -431,7 +376,7 @@ function createStructuredMap(obj, productSchema) {
431
376
  function eventsToOps(events, schema, binding) {
432
377
  const ops = [];
433
378
  for (const event of events) {
434
- const kynetaPath = yjsPathToKynetaPath(event.path, binding);
379
+ const kynetaPath = yjsPathToKynetaPath(event.path, schema, binding);
435
380
  const change = eventToChange(event, schema, kynetaPath, binding);
436
381
  if (change) ops.push({
437
382
  path: kynetaPath,
@@ -441,21 +386,44 @@ function eventsToOps(events, schema, binding) {
441
386
  return expandMapOpsToLeaves(ops, schema);
442
387
  }
443
388
  /**
444
- * Convert a Yjs event path (array of string | number) to a kyneta Path.
389
+ * Convert a Yjs event path to a kyneta `RawPath`, walking the schema
390
+ * alongside so each segment is classified as field / entry / index by
391
+ * the current schema kind.
445
392
  *
446
- * `event.path` from `observeDeep` is relative to the observed type.
447
- * Strings become key segments, numbers become index segments.
393
+ * Why schema-aware and not "did the inverse lookup hit?": the binding's
394
+ * inverse map only covers declared product-field positions reachable
395
+ * without crossing a runtime-keyed container. A declared struct field
396
+ * nested under a `record(...)` value type is reachable via Yjs but
397
+ * absent from `binding.inverse` — without the schema walk it would be
398
+ * misclassified as an entry and then rejected by `advanceSchema`.
448
399
  */
449
- function yjsPathToKynetaPath(yjsPath, binding) {
400
+ function yjsPathToKynetaPath(yjsPath, rootSchema, binding) {
450
401
  let path = RawPath.empty;
402
+ let schema = rootSchema;
451
403
  for (const segment of yjsPath) if (typeof segment === "string") {
404
+ let leaf = segment;
452
405
  const absPath = binding?.inverse.get(segment);
453
406
  if (absPath) {
454
407
  const lastDot = absPath.lastIndexOf(".");
455
- const leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath;
408
+ leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath;
409
+ }
410
+ const kind = schema?.[KIND];
411
+ if (kind === "product") {
456
412
  path = path.field(leaf);
457
- } else path = path.field(segment);
458
- } else if (typeof segment === "number") path = path.item(segment);
413
+ schema = schema?.fields[leaf];
414
+ } else if (kind === "map" || kind === "set" || kind === "tree") {
415
+ path = path.entry(leaf);
416
+ schema = schema?.item;
417
+ } else {
418
+ path = path.entry(leaf);
419
+ schema = void 0;
420
+ }
421
+ } else if (typeof segment === "number") {
422
+ path = path.item(segment);
423
+ const kind = schema?.[KIND];
424
+ if (kind === "sequence" || kind === "movable") schema = schema.item;
425
+ else schema = void 0;
426
+ }
459
427
  return path;
460
428
  }
461
429
  /**
@@ -470,7 +438,7 @@ function yjsPathToKynetaPath(yjsPath, binding) {
470
438
  */
471
439
  function eventToChange(event, rootSchema, kynetaPath, binding) {
472
440
  if (event.target instanceof Y.Text) {
473
- if (resolveSchemaAtPath(rootSchema, kynetaPath)[KIND] === "richtext") return richTextEventToChange(event);
441
+ if (pathSchema(rootSchema, kynetaPath)[KIND] === "richtext") return richTextEventToChange(event);
474
442
  return textEventToChange(event);
475
443
  }
476
444
  if (event.target instanceof Y.Array) return arrayEventToChange(event);
@@ -583,17 +551,6 @@ function extractEventValue(value) {
583
551
  return value;
584
552
  }
585
553
  /**
586
- * Resolve the schema at a given path by walking through advanceSchema.
587
- */
588
- function resolveSchemaAtPath(rootSchema, path) {
589
- let schema = rootSchema;
590
- for (const seg of path.segments) {
591
- schema = advanceSchema(schema, seg);
592
- if (schema[KIND] === "sum") return schema;
593
- }
594
- return schema;
595
- }
596
- /**
597
554
  * Get the item schema from a sequence schema, if available.
598
555
  */
599
556
  function getItemSchema(schema) {
@@ -612,35 +569,7 @@ function pathToString(path) {
612
569
  return path.segments.map((seg) => String(seg.resolve())).join(".");
613
570
  }
614
571
  //#endregion
615
- //#region src/position.ts
616
- /** Map kyneta Side to Yjs assoc. Left → -1 (left-sticky), Right → 0 (right-sticky). */
617
- function toYjsAssoc(side) {
618
- return side === "left" ? -1 : 0;
619
- }
620
- /** Map Yjs assoc to kyneta Side. Negative → left, non-negative → right. */
621
- function fromYjsAssoc(assoc) {
622
- return assoc < 0 ? "left" : "right";
623
- }
624
- var YjsPosition = class {
625
- rpos;
626
- doc;
627
- side;
628
- constructor(rpos, doc) {
629
- this.rpos = rpos;
630
- this.doc = doc;
631
- this.side = fromYjsAssoc(rpos.assoc);
632
- }
633
- resolve() {
634
- const abs = Y.createAbsolutePositionFromRelativePosition(this.rpos, this.doc);
635
- return abs ? abs.index : null;
636
- }
637
- encode() {
638
- return Y.encodeRelativePosition(this.rpos);
639
- }
640
- transform(_instructions) {}
641
- };
642
- //#endregion
643
- //#region src/reader.ts
572
+ //#region src/yjs-extract.ts
644
573
  /**
645
574
  * Extract a plain value from a Yjs shared type or return a plain value as-is.
646
575
  *
@@ -674,50 +603,71 @@ function yTextToRichTextDelta(ytext) {
674
603
  }
675
604
  return spans;
676
605
  }
677
- /**
678
- * Creates a Reader that navigates the Yjs shared type tree live,
679
- * using the schema as a type witness to determine navigation at each
680
- * path segment.
681
- *
682
- * The reader is a live view — mutations to the underlying Y.Doc
683
- * (via `doc.transact()`, or `Y.applyUpdate()`) are immediately
684
- * visible through the reader.
685
- *
686
- * Internally obtains the root map via `doc.getMap("root")`.
687
- *
688
- * @param doc - The Y.Doc to read from.
689
- * @param schema - The root schema for the document.
690
- * @param binding - Optional SchemaBinding for identity-keyed navigation.
691
- */
692
- function yjsReader(doc, schema, binding) {
693
- const rootMap = doc.getMap("root");
606
+ //#endregion
607
+ //#region src/materialize.ts
608
+ function createYjsResolver(rootMap, rootSchema, binding) {
694
609
  return {
695
- read(path) {
696
- if (path.length === 0) return rootMap.toJSON();
697
- const { resolved, schema: nodeSchema } = resolveYjsType(rootMap, schema, path, binding);
698
- if (nodeSchema[KIND] === "richtext" && resolved instanceof Y.Text) return yTextToRichTextDelta(resolved);
699
- return extractValue(resolved);
610
+ resolveValue(path) {
611
+ return extractValue(resolveYjsType(rootMap, rootSchema, path, binding).resolved);
612
+ },
613
+ resolveText(path) {
614
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
615
+ if (resolved instanceof Y.Text) return resolved.toJSON();
616
+ const value = extractValue(resolved);
617
+ return typeof value === "string" ? value : void 0;
700
618
  },
701
- arrayLength(path) {
702
- const { resolved } = resolveYjsType(rootMap, schema, path, binding);
619
+ resolveCounter(_path) {},
620
+ resolveRichText(path) {
621
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
622
+ if (resolved instanceof Y.Text) return yTextToRichTextDelta(resolved);
623
+ },
624
+ resolveLength(path) {
625
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
703
626
  if (resolved instanceof Y.Array) return resolved.length;
704
- if (Array.isArray(resolved)) return resolved.length;
705
- return 0;
627
+ return Array.isArray(resolved) ? resolved.length : 0;
706
628
  },
707
- keys(path) {
708
- const { resolved } = resolveYjsType(rootMap, schema, path, binding);
629
+ resolveKeys(path) {
630
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
709
631
  if (resolved instanceof Y.Map) return Array.from(resolved.keys());
710
- if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && !Array.isArray(resolved)) return Object.keys(resolved);
711
- return [];
632
+ return isNonNullObject(resolved) ? Object.keys(resolved) : [];
712
633
  },
713
- hasKey(path, key) {
714
- const { resolved } = resolveYjsType(rootMap, schema, path, binding);
715
- if (resolved instanceof Y.Map) return resolved.has(key);
716
- if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && !Array.isArray(resolved)) return key in resolved;
717
- return false;
634
+ resolveForest(_path) {
635
+ return [];
718
636
  }
719
637
  };
720
638
  }
639
+ function materializeYjsShadow(doc, schema, binding) {
640
+ const resolver = createYjsResolver(doc.getMap("root"), schema, binding);
641
+ return interpret(schema, createMaterializeInterpreter(resolver), materializeContextFromResolver(resolver));
642
+ }
643
+ //#endregion
644
+ //#region src/position.ts
645
+ /** Map kyneta Side to Yjs assoc. Left → -1 (left-sticky), Right → 0 (right-sticky). */
646
+ function toYjsAssoc(side) {
647
+ return side === "left" ? -1 : 0;
648
+ }
649
+ /** Map Yjs assoc to kyneta Side. Negative → left, non-negative → right. */
650
+ function fromYjsAssoc(assoc) {
651
+ return assoc < 0 ? "left" : "right";
652
+ }
653
+ var YjsPosition = class {
654
+ rpos;
655
+ doc;
656
+ side;
657
+ constructor(rpos, doc) {
658
+ this.rpos = rpos;
659
+ this.doc = doc;
660
+ this.side = fromYjsAssoc(rpos.assoc);
661
+ }
662
+ resolve() {
663
+ const abs = Y.createAbsolutePositionFromRelativePosition(this.rpos, this.doc);
664
+ return abs ? abs.index : null;
665
+ }
666
+ encode() {
667
+ return Y.encodeRelativePosition(this.rpos);
668
+ }
669
+ transform(_instructions) {}
670
+ };
721
671
  //#endregion
722
672
  //#region src/version.ts
723
673
  /**
@@ -892,33 +842,35 @@ const KYNETA_ORIGIN = "kyneta-prepare";
892
842
  */
893
843
  function createYjsSubstrate(doc, schema, binding) {
894
844
  const pendingChanges = [];
895
- let inOurTransaction = false;
896
845
  let pendingMergeOrigin;
897
846
  let cachedCtx;
898
847
  let accumulatedDs = Y.createDeleteSetFromStructStore(doc.store);
899
848
  const rootMap = doc.getMap("root");
900
- const reader = yjsReader(doc, schema, binding);
849
+ const shadow = materializeYjsShadow(doc, schema, binding);
850
+ const reader = plainReader(shadow);
901
851
  const substrate = {
902
852
  [BACKING_DOC]: doc,
903
853
  reader,
904
- prepare(path, change) {
905
- if (!inOurTransaction) pendingChanges.push({
854
+ prepare(path, change, options) {
855
+ if (!options?.replay) applyChange(shadow, path, change);
856
+ if (options?.replay) return;
857
+ pendingChanges.push({
906
858
  path,
907
859
  change
908
860
  });
909
861
  },
910
- onFlush(_origin) {
911
- if (!inOurTransaction && pendingChanges.length > 0) {
912
- inOurTransaction = true;
913
- try {
914
- doc.transact(() => {
915
- for (const { path, change } of pendingChanges) applyChangeToYjs(rootMap, schema, path, change, binding);
916
- }, KYNETA_ORIGIN);
917
- pendingChanges.length = 0;
918
- } finally {
919
- inOurTransaction = false;
920
- }
862
+ onFlush(options) {
863
+ if (options?.replay) {
864
+ const fresh = materializeYjsShadow(doc, schema, binding);
865
+ for (const key of Object.keys(fresh)) shadow[key] = fresh[key];
866
+ for (const key of Object.keys(shadow)) if (!(key in fresh)) delete shadow[key];
867
+ return;
921
868
  }
869
+ if (pendingChanges.length === 0) return;
870
+ doc.transact(() => {
871
+ for (const { path, change } of pendingChanges) applyChangeToYjs(rootMap, schema, path, change, binding);
872
+ }, KYNETA_ORIGIN);
873
+ pendingChanges.length = 0;
922
874
  },
923
875
  context() {
924
876
  if (!cachedCtx) {
@@ -971,11 +923,11 @@ function createYjsSubstrate(doc, schema, binding) {
971
923
  return null;
972
924
  }
973
925
  },
974
- merge(payload, origin) {
926
+ merge(payload, options) {
975
927
  if (payload.encoding !== "binary" || !(payload.data instanceof Uint8Array)) throw new Error("YjsSubstrate.merge expects binary-encoded payloads. If you recently switched CRDT backends, stale clients may be sending incompatible data.");
976
- pendingMergeOrigin = origin;
928
+ pendingMergeOrigin = options?.origin;
977
929
  try {
978
- Y.applyUpdate(doc, payload.data, origin ?? "remote");
930
+ Y.applyUpdate(doc, payload.data, options?.origin ?? "remote");
979
931
  } finally {
980
932
  pendingMergeOrigin = void 0;
981
933
  }
@@ -987,13 +939,10 @@ function createYjsSubstrate(doc, schema, binding) {
987
939
  if (ops.length === 0) return;
988
940
  if (transaction.deleteSet.clients.size > 0) accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet]);
989
941
  const origin = pendingMergeOrigin ?? (typeof transaction.origin === "string" ? transaction.origin : void 0);
990
- const ctx = substrate.context();
991
- inOurTransaction = true;
992
- try {
993
- executeBatch(ctx, ops, origin);
994
- } finally {
995
- inOurTransaction = false;
996
- }
942
+ executeBatch(substrate.context(), ops, {
943
+ origin,
944
+ replay: true
945
+ });
997
946
  });
998
947
  doc.on("afterTransaction", (transaction) => {
999
948
  if (transaction.origin === KYNETA_ORIGIN && transaction.deleteSet.clients.size > 0) accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet]);
@@ -1078,7 +1027,7 @@ function createYjsReplica(doc) {
1078
1027
  return null;
1079
1028
  }
1080
1029
  },
1081
- merge(payload, _origin) {
1030
+ merge(payload, _options) {
1082
1031
  if (payload.encoding !== "binary" || !(payload.data instanceof Uint8Array)) throw new Error("YjsReplica.merge expects binary-encoded payloads. If you recently switched CRDT backends, stale clients may be sending incompatible data.");
1083
1032
  Y.applyUpdate(currentDoc, payload.data);
1084
1033
  }
@@ -1111,13 +1060,13 @@ const yjsSubstrateFactory = {
1111
1060
  upgrade(replica, schema) {
1112
1061
  const doc = replica[BACKING_DOC];
1113
1062
  const binding = trivialBinding(schema);
1114
- ensureContainers(doc, schema, true, binding);
1063
+ ensureContainers(doc, schema, binding);
1115
1064
  return createYjsSubstrate(doc, schema, binding);
1116
1065
  },
1117
1066
  create(schema) {
1118
1067
  const doc = new Y.Doc();
1119
1068
  const binding = trivialBinding(schema);
1120
- ensureContainers(doc, schema, false, binding);
1069
+ ensureContainers(doc, schema, binding);
1121
1070
  return createYjsSubstrate(doc, schema, binding);
1122
1071
  },
1123
1072
  fromEntirety(payload, schema) {
@@ -1164,13 +1113,13 @@ function createYjsFactory(peerId, binding) {
1164
1113
  upgrade(replica, schema) {
1165
1114
  const doc = replica[BACKING_DOC];
1166
1115
  doc.clientID = numericClientId;
1167
- ensureContainers(doc, schema, true, binding);
1116
+ ensureContainers(doc, schema, binding);
1168
1117
  return createYjsSubstrate(doc, schema, binding);
1169
1118
  },
1170
1119
  create(schema) {
1171
1120
  const doc = new Y.Doc();
1172
1121
  doc.clientID = numericClientId;
1173
- ensureContainers(doc, schema, false, binding);
1122
+ ensureContainers(doc, schema, binding);
1174
1123
  return createYjsSubstrate(doc, schema, binding);
1175
1124
  },
1176
1125
  fromEntirety(payload, schema) {
@@ -1202,6 +1151,6 @@ const yjs = createBindingTarget({
1202
1151
  syncProtocol: SYNC_COLLABORATIVE
1203
1152
  });
1204
1153
  //#endregion
1205
- export { NATIVE, Schema, YjsPosition, YjsVersion, applyChangeToYjs, applyChanges, change, createDoc, createRef, createYjsSubstrate, ensureContainers, eventsToOps, exportEntirety, exportSince, fromYjsAssoc, merge, resolveYjsType, stepIntoYjs, subscribe, subscribeNode, toYjsAssoc, unwrap, version, yjs, yjsReader, yjsReplicaFactory, yjsSubstrateFactory };
1154
+ export { NATIVE, Schema, YjsPosition, YjsVersion, applyChangeToYjs, applyChanges, change, createDoc, createRef, createYjsSubstrate, ensureContainers, eventsToOps, exportEntirety, exportSince, fromYjsAssoc, merge, resolveYjsType, stepIntoYjs, subscribe, subscribeNode, toYjsAssoc, unwrap, version, yjs, yjsReplicaFactory, yjsSubstrateFactory };
1206
1155
 
1207
1156
  //# sourceMappingURL=index.js.map