@kyneta/yjs-schema 1.6.1 → 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.
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, RECORD_INVERSE, RawPath, STRUCTURAL_YJS_CLIENT_ID, SYNC_COLLABORATIVE, Schema, applyChange, applyChanges, base64ToUint8Array, buildWritableContext, change, createBindingTarget, createDoc, createMaterializeInterpreter, createRef, deepClonePreState, deriveSchemaBinding, executeBatch, expandMapOpsToLeaves, exportEntirety, exportSince, findJsonBoundary, foldPath, interpret, invert, isJsonBoundary, isNonNullObject, isPlainObject, materializeContextFromResolver, merge, pathSchema, plainReader, richTextChange, subscribe, subscribeNode, syncShadow, 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,8 @@ 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;
64
+ if (isJsonBoundary(fieldSchema)) return;
73
65
  switch (fieldSchema[KIND]) {
74
66
  case "text":
75
67
  case "richtext":
@@ -85,11 +77,7 @@ function ensureRootField(rootMap, key, fieldSchema, binding, prefix) {
85
77
  rootMap.set(key, new Y.Map());
86
78
  return;
87
79
  case "scalar":
88
- case "sum": {
89
- const zero = Zero.structural(fieldSchema);
90
- if (zero !== void 0) rootMap.set(key, zero);
91
- return;
92
- }
80
+ case "sum": return;
93
81
  case "counter":
94
82
  case "set":
95
83
  case "tree":
@@ -102,7 +90,7 @@ function ensureRootField(rootMap, key, fieldSchema, binding, prefix) {
102
90
  *
103
91
  * Only creates containers for fields that require Yjs shared types
104
92
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
105
- * Scalar and sum fields are set to their structural zero defaults.
93
+ * Scalar and sum fields are skipped (materializer zero fallback).
106
94
  *
107
95
  * **Identity-keying:** When a `binding` is provided, computes the
108
96
  * absolute schema path for each nested field (`prefix.fieldName`) and
@@ -119,6 +107,7 @@ function ensureMapContainers(schema, binding, prefix) {
119
107
  for (const [key, fieldSchema] of Object.entries(schema.fields).sort(([a], [b]) => a.localeCompare(b))) {
120
108
  const absPath = prefix ? `${prefix}.${key}` : key;
121
109
  const mapKey = binding?.forward.get(absPath) ?? key;
110
+ if (isJsonBoundary(fieldSchema)) continue;
122
111
  switch (fieldSchema[KIND]) {
123
112
  case "text":
124
113
  case "richtext":
@@ -134,11 +123,7 @@ function ensureMapContainers(schema, binding, prefix) {
134
123
  map.set(mapKey, new Y.Map());
135
124
  break;
136
125
  case "scalar":
137
- case "sum": {
138
- const zero = Zero.structural(fieldSchema);
139
- if (zero !== void 0) map.set(mapKey, zero);
140
- break;
141
- }
126
+ case "sum": break;
142
127
  case "counter":
143
128
  case "set":
144
129
  case "tree":
@@ -158,68 +143,29 @@ function ensureMapContainers(schema, binding, prefix) {
158
143
  * - `Y.Text` → terminal (cannot step further)
159
144
  * - Plain value → terminal (return `undefined`)
160
145
  *
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
146
+ * `_nextSchema` is part of the `PathStepper` contract for Loro's root
147
+ * dispatch but is unused here Yjs's `instanceof` dispatch doesn't
148
+ * need to look ahead at the next schema kind.
164
149
  */
165
- function stepIntoYjs(current, segment, identity) {
150
+ const stepIntoYjs = (current, _nextSchema, segment, identity) => {
166
151
  const resolved = segment.resolve();
167
152
  if (current instanceof Y.Map) return current.get(identity ?? resolved);
168
153
  if (current instanceof Y.Array) return current.get(resolved);
169
154
  if (current instanceof Y.Text) throw new Error(`yjs-resolve: cannot step into Y.Text`);
170
- }
155
+ };
171
156
  /**
172
157
  * Resolve a Yjs shared type (or plain value) at the given path.
173
158
  *
174
- * Left-folds over path segments using `advanceSchema` for pure schema
175
- * descent and `stepIntoYjs` for Yjs-specific navigation.
159
+ * Thin wrapper around `foldPath(stepIntoYjs, ...)`. Returns the
160
+ * `PathFoldResult` shape from core — `{ resolved, schema }`.
176
161
  *
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).
162
+ * When a `binding` is provided, every product-field boundary uses the
163
+ * identity hash from `binding.forward` instead of the field name.
181
164
  *
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.
185
- *
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.
165
+ * For an empty path, returns the root map and root schema.
190
166
  */
191
167
  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
- };
168
+ return foldPath(rootMap, rootSchema, path, stepIntoYjs, binding);
223
169
  }
224
170
  //#endregion
225
171
  //#region src/change-mapping.ts
@@ -254,6 +200,7 @@ function applyChangeToYjs(rootMap, rootSchema, path, change, binding) {
254
200
  return;
255
201
  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
202
  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)}].`);
203
+ 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
204
  default: throw new Error(`applyChangeToYjs: unsupported change type "${change.type}"`);
258
205
  }
259
206
  }
@@ -284,7 +231,7 @@ function applyRichTextChange(rootMap, rootSchema, path, change, binding) {
284
231
  function applySequenceChange(rootMap, rootSchema, path, change, binding) {
285
232
  const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
286
233
  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));
234
+ const itemSchema = getItemSchema(pathSchema(rootSchema, path));
288
235
  let cursor = 0;
289
236
  for (const instruction of change.instructions) if ("retain" in instruction) cursor += instruction.retain;
290
237
  else if ("delete" in instruction) resolved.delete(cursor, instruction.delete);
@@ -298,13 +245,13 @@ function applySequenceChange(rootMap, rootSchema, path, change, binding) {
298
245
  function applyMapChange(rootMap, rootSchema, path, change, binding) {
299
246
  const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
300
247
  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);
248
+ const targetSchema = pathSchema(rootSchema, path);
302
249
  if (change.delete) for (const key of change.delete) resolved.delete(key);
303
250
  if (change.set) for (const [key, value] of Object.entries(change.set)) {
304
251
  const yjsValue = maybeCreateSharedType(value, getFieldSchema(targetSchema, key));
305
252
  let mapKey = key;
306
253
  if (binding && targetSchema[KIND] === "product") {
307
- const parentAbsPath = path.segments.filter((s) => s.role === "key").map((s) => s.resolve()).join(".");
254
+ const parentAbsPath = path.segments.filter((s) => s.role === "field").map((s) => s.resolve()).join(".");
308
255
  const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key;
309
256
  const identity = binding.forward.get(absPath);
310
257
  if (identity) mapKey = identity;
@@ -313,24 +260,24 @@ function applyMapChange(rootMap, rootSchema, path, change, binding) {
313
260
  }
314
261
  }
315
262
  function applyReplaceChange(rootMap, rootSchema, path, change, binding) {
316
- if (path.length === 0) throw new Error("applyChangeToYjs: ReplaceChange at root path is not supported");
263
+ if (path.length === 0) throw new Error("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 })`).");
317
264
  const lastSeg = path.segments.at(-1);
318
265
  if (!lastSeg) throw new Error("replaceChangeToDiff: empty path");
319
266
  const parentPath = path.slice(0, -1);
320
267
  const { resolved: parent } = resolveYjsType(rootMap, rootSchema, parentPath, binding);
321
268
  const resolved = lastSeg.resolve();
322
- if (parent instanceof Y.Map && lastSeg.role === "key") {
323
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
269
+ if (parent instanceof Y.Map && (lastSeg.role === "field" || lastSeg.role === "entry")) {
270
+ const targetSchema = pathSchema(rootSchema, path);
324
271
  const yjsValue = maybeCreateSharedType(change.value, targetSchema);
325
272
  let mapKey = resolved;
326
- if (binding) {
327
- const absPath = path.segments.filter((s) => s.role === "key").map((s) => s.resolve()).join(".");
273
+ if (binding && lastSeg.role === "field") {
274
+ const absPath = path.segments.filter((s) => s.role === "field").map((s) => s.resolve()).join(".");
328
275
  const identity = binding.forward.get(absPath);
329
276
  if (identity) mapKey = identity;
330
277
  }
331
278
  parent.set(mapKey, yjsValue);
332
279
  } else if (parent instanceof Y.Array && lastSeg.role === "index") {
333
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
280
+ const targetSchema = pathSchema(rootSchema, path);
334
281
  const yjsValue = maybeCreateSharedType(change.value, targetSchema);
335
282
  parent.delete(resolved, 1);
336
283
  parent.insert(resolved, [yjsValue]);
@@ -346,6 +293,7 @@ function applyReplaceChange(rootMap, rootSchema, path, change, binding) {
346
293
  */
347
294
  function maybeCreateSharedType(value, schema) {
348
295
  if (schema === void 0) return value;
296
+ if (isJsonBoundary(schema)) return value;
349
297
  switch (schema[KIND]) {
350
298
  case "text": {
351
299
  const text = new Y.Text();
@@ -366,7 +314,7 @@ function maybeCreateSharedType(value, schema) {
366
314
  return text;
367
315
  }
368
316
  case "product":
369
- if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return value;
317
+ if (!isPlainObject(value)) return value;
370
318
  return createStructuredMap(value, schema);
371
319
  case "sequence": {
372
320
  if (!Array.isArray(value)) return value;
@@ -377,7 +325,7 @@ function maybeCreateSharedType(value, schema) {
377
325
  return arr;
378
326
  }
379
327
  case "map": {
380
- if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return value;
328
+ if (!isPlainObject(value)) return value;
381
329
  const map = new Y.Map();
382
330
  const valueSchema = schema.item;
383
331
  for (const [k, v] of Object.entries(value)) map.set(k, maybeCreateSharedType(v, valueSchema));
@@ -431,7 +379,7 @@ function createStructuredMap(obj, productSchema) {
431
379
  function eventsToOps(events, schema, binding) {
432
380
  const ops = [];
433
381
  for (const event of events) {
434
- const kynetaPath = yjsPathToKynetaPath(event.path, binding);
382
+ const kynetaPath = yjsPathToKynetaPath(event.path, schema, binding);
435
383
  const change = eventToChange(event, schema, kynetaPath, binding);
436
384
  if (change) ops.push({
437
385
  path: kynetaPath,
@@ -441,21 +389,44 @@ function eventsToOps(events, schema, binding) {
441
389
  return expandMapOpsToLeaves(ops, schema);
442
390
  }
443
391
  /**
444
- * Convert a Yjs event path (array of string | number) to a kyneta Path.
392
+ * Convert a Yjs event path to a kyneta `RawPath`, walking the schema
393
+ * alongside so each segment is classified as field / entry / index by
394
+ * the current schema kind.
445
395
  *
446
- * `event.path` from `observeDeep` is relative to the observed type.
447
- * Strings become key segments, numbers become index segments.
396
+ * Why schema-aware and not "did the inverse lookup hit?": the binding's
397
+ * inverse map only covers declared product-field positions reachable
398
+ * without crossing a runtime-keyed container. A declared struct field
399
+ * nested under a `record(...)` value type is reachable via Yjs but
400
+ * absent from `binding.inverse` — without the schema walk it would be
401
+ * misclassified as an entry and then rejected by `advanceSchema`.
448
402
  */
449
- function yjsPathToKynetaPath(yjsPath, binding) {
403
+ function yjsPathToKynetaPath(yjsPath, rootSchema, binding) {
450
404
  let path = RawPath.empty;
405
+ let schema = rootSchema;
451
406
  for (const segment of yjsPath) if (typeof segment === "string") {
407
+ let leaf = segment;
452
408
  const absPath = binding?.inverse.get(segment);
453
409
  if (absPath) {
454
410
  const lastDot = absPath.lastIndexOf(".");
455
- const leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath;
411
+ leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath;
412
+ }
413
+ const kind = schema?.[KIND];
414
+ if (kind === "product") {
456
415
  path = path.field(leaf);
457
- } else path = path.field(segment);
458
- } else if (typeof segment === "number") path = path.item(segment);
416
+ schema = schema?.fields[leaf];
417
+ } else if (kind === "map" || kind === "set" || kind === "tree") {
418
+ path = path.entry(leaf);
419
+ schema = schema?.item;
420
+ } else {
421
+ path = path.entry(leaf);
422
+ schema = void 0;
423
+ }
424
+ } else if (typeof segment === "number") {
425
+ path = path.item(segment);
426
+ const kind = schema?.[KIND];
427
+ if (kind === "sequence" || kind === "movable") schema = schema.item;
428
+ else schema = void 0;
429
+ }
459
430
  return path;
460
431
  }
461
432
  /**
@@ -470,7 +441,7 @@ function yjsPathToKynetaPath(yjsPath, binding) {
470
441
  */
471
442
  function eventToChange(event, rootSchema, kynetaPath, binding) {
472
443
  if (event.target instanceof Y.Text) {
473
- if (resolveSchemaAtPath(rootSchema, kynetaPath)[KIND] === "richtext") return richTextEventToChange(event);
444
+ if (pathSchema(rootSchema, kynetaPath)[KIND] === "richtext") return richTextEventToChange(event);
474
445
  return textEventToChange(event);
475
446
  }
476
447
  if (event.target instanceof Y.Array) return arrayEventToChange(event);
@@ -583,17 +554,6 @@ function extractEventValue(value) {
583
554
  return value;
584
555
  }
585
556
  /**
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
557
  * Get the item schema from a sequence schema, if available.
598
558
  */
599
559
  function getItemSchema(schema) {
@@ -612,35 +572,7 @@ function pathToString(path) {
612
572
  return path.segments.map((seg) => String(seg.resolve())).join(".");
613
573
  }
614
574
  //#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
575
+ //#region src/yjs-extract.ts
644
576
  /**
645
577
  * Extract a plain value from a Yjs shared type or return a plain value as-is.
646
578
  *
@@ -674,50 +606,71 @@ function yTextToRichTextDelta(ytext) {
674
606
  }
675
607
  return spans;
676
608
  }
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");
609
+ //#endregion
610
+ //#region src/materialize.ts
611
+ function createYjsResolver(rootMap, rootSchema, binding) {
694
612
  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);
613
+ resolveValue(path) {
614
+ return extractValue(resolveYjsType(rootMap, rootSchema, path, binding).resolved);
700
615
  },
701
- arrayLength(path) {
702
- const { resolved } = resolveYjsType(rootMap, schema, path, binding);
616
+ resolveText(path) {
617
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
618
+ if (resolved instanceof Y.Text) return resolved.toJSON();
619
+ const value = extractValue(resolved);
620
+ return typeof value === "string" ? value : void 0;
621
+ },
622
+ resolveCounter(_path) {},
623
+ resolveRichText(path) {
624
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
625
+ if (resolved instanceof Y.Text) return yTextToRichTextDelta(resolved);
626
+ },
627
+ resolveLength(path) {
628
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
703
629
  if (resolved instanceof Y.Array) return resolved.length;
704
- if (Array.isArray(resolved)) return resolved.length;
705
- return 0;
630
+ return Array.isArray(resolved) ? resolved.length : 0;
706
631
  },
707
- keys(path) {
708
- const { resolved } = resolveYjsType(rootMap, schema, path, binding);
632
+ resolveKeys(path) {
633
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
709
634
  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 [];
635
+ return isNonNullObject(resolved) ? Object.keys(resolved) : [];
712
636
  },
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;
637
+ resolveForest(_path) {
638
+ return [];
718
639
  }
719
640
  };
720
641
  }
642
+ function materializeYjsShadow(doc, schema, binding) {
643
+ const resolver = createYjsResolver(doc.getMap("root"), schema, binding);
644
+ return interpret(schema, createMaterializeInterpreter(resolver), materializeContextFromResolver(resolver));
645
+ }
646
+ //#endregion
647
+ //#region src/position.ts
648
+ /** Map kyneta Side to Yjs assoc. Left → -1 (left-sticky), Right → 0 (right-sticky). */
649
+ function toYjsAssoc(side) {
650
+ return side === "left" ? -1 : 0;
651
+ }
652
+ /** Map Yjs assoc to kyneta Side. Negative → left, non-negative → right. */
653
+ function fromYjsAssoc(assoc) {
654
+ return assoc < 0 ? "left" : "right";
655
+ }
656
+ var YjsPosition = class {
657
+ rpos;
658
+ doc;
659
+ side;
660
+ constructor(rpos, doc) {
661
+ this.rpos = rpos;
662
+ this.doc = doc;
663
+ this.side = fromYjsAssoc(rpos.assoc);
664
+ }
665
+ resolve() {
666
+ const abs = Y.createAbsolutePositionFromRelativePosition(this.rpos, this.doc);
667
+ return abs ? abs.index : null;
668
+ }
669
+ encode() {
670
+ return Y.encodeRelativePosition(this.rpos);
671
+ }
672
+ transform(_instructions) {}
673
+ };
721
674
  //#endregion
722
675
  //#region src/version.ts
723
676
  /**
@@ -870,7 +823,7 @@ var YjsVersion = class YjsVersion {
870
823
  };
871
824
  //#endregion
872
825
  //#region src/substrate.ts
873
- const KYNETA_ORIGIN = "kyneta-prepare";
826
+ const KYNETA_MARK = Symbol("kyneta:own-commit");
874
827
  /**
875
828
  * Creates a `Substrate<YjsVersion>` wrapping a user-provided Y.Doc.
876
829
  *
@@ -891,39 +844,105 @@ const KYNETA_ORIGIN = "kyneta-prepare";
891
844
  * @param binding - Optional SchemaBinding for identity-keyed containers.
892
845
  */
893
846
  function createYjsSubstrate(doc, schema, binding) {
894
- const pendingChanges = [];
847
+ const jsonBoundaryBuffer = /* @__PURE__ */ new Map();
895
848
  let pendingMergeOrigin;
896
849
  let cachedCtx;
897
- let accumulatedDs = Y.createDeleteSetFromStructStore(doc.store);
898
850
  const rootMap = doc.getMap("root");
899
- const reader = yjsReader(doc, schema, binding);
851
+ const shadow = materializeYjsShadow(doc, schema, binding);
852
+ const reader = plainReader(shadow);
853
+ /**
854
+ * Compute the identity-aware boundary key (or numeric index) for a
855
+ * json-boundary write at `prefixLength`. Mirrors the Loro substrate's
856
+ * `boundaryKey`; field segments inside a bound product get the
857
+ * identity hash, others pass through raw.
858
+ */
859
+ function boundaryKey(path, prefixLength) {
860
+ const seg = path.segments[prefixLength];
861
+ if (seg.role === "field" && binding) {
862
+ const absPath = path.segments.slice(0, prefixLength + 1).filter((s) => s.role === "field").map((s) => s.resolve()).join(".");
863
+ const identity = binding.forward.get(absPath);
864
+ if (identity) return identity;
865
+ }
866
+ return seg.resolve();
867
+ }
868
+ /**
869
+ * Buffer a json-boundary write. The boundary value is the entire σ
870
+ * subtree at the boundary path — already updated by the preceding
871
+ * `applyChange(shadow, ...)`. Subsequent writes inside the same
872
+ * subtree overwrite this entry (last-write-wins by σ snapshot).
873
+ *
874
+ * Returns silently when the parent container can't be resolved
875
+ * (root-level json fields land in `rootMap` directly — Yjs's
876
+ * root is the rootMap, so the parentResolved is `rootMap`).
877
+ */
878
+ function stageJsonBoundaryWrite(path, prefixLength) {
879
+ const { resolved: parent } = resolveYjsType(rootMap, schema, path.slice(0, prefixLength), binding);
880
+ const value = path.slice(0, prefixLength + 1).read(shadow);
881
+ const key = boundaryKey(path, prefixLength);
882
+ let target;
883
+ if (parent instanceof Y.Map) target = parent;
884
+ else if (parent instanceof Y.Array) target = parent;
885
+ else throw new Error(`yjs substrate: json-boundary write to unsupported parent type at path ${path.format()}`);
886
+ const slot = `${`${target._item?.id?.client ?? "root"}:${target._item?.id?.clock ?? "root"}`}/${String(key)}`;
887
+ jsonBoundaryBuffer.set(slot, {
888
+ target,
889
+ key,
890
+ value
891
+ });
892
+ }
893
+ /**
894
+ * Drain the json-boundary buffer into λ. Called from `afterBatch`
895
+ * inside the ambient `Y.transact` opened by `runBatch`. Each entry
896
+ * is applied as `target.set(key, value)` for Y.Map parents or as a
897
+ * delete+insert for Y.Array parents (Yjs Arrays don't have a
898
+ * `set(index, value)` primitive — replace = delete one + insert one).
899
+ */
900
+ function flushJsonBoundaryBuffer() {
901
+ if (jsonBoundaryBuffer.size === 0) return;
902
+ for (const { target, key, value } of jsonBoundaryBuffer.values()) if (target instanceof Y.Map) target.set(String(key), value);
903
+ else {
904
+ const index = key;
905
+ target.delete(index, 1);
906
+ target.insert(index, [value]);
907
+ }
908
+ jsonBoundaryBuffer.clear();
909
+ }
900
910
  const substrate = {
901
911
  [BACKING_DOC]: doc,
902
912
  reader,
903
913
  prepare(path, change, options) {
904
914
  if (options?.replay) return;
905
- pendingChanges.push({
906
- path,
907
- change
908
- });
915
+ const record = options?.[RECORD_INVERSE];
916
+ if (record && !options?.compensating) record(path, invert(deepClonePreState(path.read(shadow)), change));
917
+ applyChange(shadow, path, change);
918
+ const boundary = findJsonBoundary(schema, path, binding);
919
+ if (boundary !== null) {
920
+ stageJsonBoundaryWrite(path, boundary.prefixLength);
921
+ return;
922
+ }
923
+ applyChangeToYjs(rootMap, schema, path, change, binding);
909
924
  },
910
- onFlush(options) {
911
- if (options?.replay) return;
912
- if (pendingChanges.length === 0) return;
913
- doc.transact(() => {
914
- for (const { path, change } of pendingChanges) applyChangeToYjs(rootMap, schema, path, change, binding);
915
- }, KYNETA_ORIGIN);
916
- pendingChanges.length = 0;
925
+ afterBatch(options) {
926
+ if (options?.replay) {
927
+ syncShadow(shadow, materializeYjsShadow(doc, schema, binding));
928
+ return;
929
+ }
930
+ flushJsonBoundaryBuffer();
931
+ },
932
+ runBatch(work, options) {
933
+ doc.transact((tr) => {
934
+ tr.meta.set(KYNETA_MARK, true);
935
+ work();
936
+ }, options?.origin);
917
937
  },
918
938
  context() {
919
- if (!cachedCtx) {
920
- cachedCtx = buildWritableContext(substrate);
921
- cachedCtx.nativeResolver = (nodeSchema, path) => {
939
+ if (!cachedCtx) cachedCtx = buildWritableContext(substrate, {
940
+ nativeResolver: (nodeSchema, path) => {
922
941
  if (path.segments.length === 0) return doc;
923
942
  if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum") return void 0;
924
943
  return resolveYjsType(rootMap, schema, path, binding).resolved;
925
- };
926
- cachedCtx.positionResolver = (_nodeSchema, path) => {
944
+ },
945
+ positionResolver: (_nodeSchema, path) => {
927
946
  return {
928
947
  createPosition(index, side) {
929
948
  const { resolved: ytype } = resolveYjsType(rootMap, schema, path, binding);
@@ -935,12 +954,12 @@ function createYjsSubstrate(doc, schema, binding) {
935
954
  return new YjsPosition(Y.decodeRelativePosition(bytes), doc);
936
955
  }
937
956
  };
938
- };
939
- }
957
+ }
958
+ });
940
959
  return cachedCtx;
941
960
  },
942
961
  version() {
943
- return YjsVersion.fromDeleteSet(doc, accumulatedDs);
962
+ return YjsVersion.fromDeleteSet(doc, Y.createDeleteSetFromStructStore(doc.store));
944
963
  },
945
964
  baseVersion() {
946
965
  return new YjsVersion(new Uint8Array([0]));
@@ -977,19 +996,15 @@ function createYjsSubstrate(doc, schema, binding) {
977
996
  }
978
997
  };
979
998
  rootMap.observeDeep((events, transaction) => {
980
- if (transaction.origin === KYNETA_ORIGIN) return;
999
+ if (transaction.meta.get(KYNETA_MARK)) return;
981
1000
  const ops = eventsToOps(events, schema, binding);
982
1001
  if (ops.length === 0) return;
983
- if (transaction.deleteSet.clients.size > 0) accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet]);
984
1002
  const origin = pendingMergeOrigin ?? (typeof transaction.origin === "string" ? transaction.origin : void 0);
985
1003
  executeBatch(substrate.context(), ops, {
986
1004
  origin,
987
1005
  replay: true
988
1006
  });
989
1007
  });
990
- doc.on("afterTransaction", (transaction) => {
991
- if (transaction.origin === KYNETA_ORIGIN && transaction.deleteSet.clients.size > 0) accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet]);
992
- });
993
1008
  return substrate;
994
1009
  }
995
1010
  /**
@@ -1103,13 +1118,13 @@ const yjsSubstrateFactory = {
1103
1118
  upgrade(replica, schema) {
1104
1119
  const doc = replica[BACKING_DOC];
1105
1120
  const binding = trivialBinding(schema);
1106
- ensureContainers(doc, schema, true, binding);
1121
+ ensureContainers(doc, schema, binding);
1107
1122
  return createYjsSubstrate(doc, schema, binding);
1108
1123
  },
1109
1124
  create(schema) {
1110
1125
  const doc = new Y.Doc();
1111
1126
  const binding = trivialBinding(schema);
1112
- ensureContainers(doc, schema, false, binding);
1127
+ ensureContainers(doc, schema, binding);
1113
1128
  return createYjsSubstrate(doc, schema, binding);
1114
1129
  },
1115
1130
  fromEntirety(payload, schema) {
@@ -1156,13 +1171,13 @@ function createYjsFactory(peerId, binding) {
1156
1171
  upgrade(replica, schema) {
1157
1172
  const doc = replica[BACKING_DOC];
1158
1173
  doc.clientID = numericClientId;
1159
- ensureContainers(doc, schema, true, binding);
1174
+ ensureContainers(doc, schema, binding);
1160
1175
  return createYjsSubstrate(doc, schema, binding);
1161
1176
  },
1162
1177
  create(schema) {
1163
1178
  const doc = new Y.Doc();
1164
1179
  doc.clientID = numericClientId;
1165
- ensureContainers(doc, schema, false, binding);
1180
+ ensureContainers(doc, schema, binding);
1166
1181
  return createYjsSubstrate(doc, schema, binding);
1167
1182
  },
1168
1183
  fromEntirety(payload, schema) {
@@ -1194,6 +1209,6 @@ const yjs = createBindingTarget({
1194
1209
  syncProtocol: SYNC_COLLABORATIVE
1195
1210
  });
1196
1211
  //#endregion
1197
- 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 };
1212
+ 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 };
1198
1213
 
1199
1214
  //# sourceMappingURL=index.js.map