@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/README.md +2 -0
- package/dist/index.d.ts +17 -61
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +222 -207
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/__tests__/create.test.ts +11 -0
- package/src/__tests__/eager-write-coherence.test.ts +321 -0
- package/src/__tests__/materialize.test.ts +227 -0
- package/src/__tests__/position.test.ts +7 -7
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +56 -3
- package/src/bind-yjs.ts +3 -5
- package/src/change-mapping.ts +73 -48
- package/src/index.ts +1 -2
- package/src/materialize.ts +109 -0
- package/src/populate.ts +35 -37
- package/src/substrate.ts +277 -111
- package/src/yjs-extract.ts +52 -0
- package/src/yjs-resolve.ts +30 -95
- package/src/__tests__/reader.test.ts +0 -685
- package/src/reader.ts +0 -174
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BACKING_DOC, KIND, NATIVE, RawPath, STRUCTURAL_YJS_CLIENT_ID, SYNC_COLLABORATIVE, 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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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,
|
|
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"` →
|
|
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
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
175
|
-
*
|
|
159
|
+
* Thin wrapper around `foldPath(stepIntoYjs, ...)`. Returns the
|
|
160
|
+
* `PathFoldResult` shape from core — `{ resolved, schema }`.
|
|
176
161
|
*
|
|
177
|
-
* When a `binding` is provided,
|
|
178
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 === "
|
|
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("
|
|
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 === "
|
|
323
|
-
const targetSchema =
|
|
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 === "
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
447
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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 (
|
|
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/
|
|
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
|
-
|
|
679
|
-
|
|
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
|
-
|
|
696
|
-
|
|
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
|
-
|
|
702
|
-
const { resolved } = resolveYjsType(rootMap,
|
|
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
|
-
|
|
705
|
-
return 0;
|
|
630
|
+
return Array.isArray(resolved) ? resolved.length : 0;
|
|
706
631
|
},
|
|
707
|
-
|
|
708
|
-
const { resolved } = resolveYjsType(rootMap,
|
|
632
|
+
resolveKeys(path) {
|
|
633
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
|
|
709
634
|
if (resolved instanceof Y.Map) return Array.from(resolved.keys());
|
|
710
|
-
|
|
711
|
-
return [];
|
|
635
|
+
return isNonNullObject(resolved) ? Object.keys(resolved) : [];
|
|
712
636
|
},
|
|
713
|
-
|
|
714
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
911
|
-
if (options?.replay)
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|