@kyneta/yjs-schema 1.3.1 → 1.4.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,892 +1,1191 @@
1
- // src/index.ts
2
- import {
3
- applyChanges,
4
- change,
5
- createDoc,
6
- createRef,
7
- exportEntirety,
8
- exportSince,
9
- merge,
10
- NATIVE,
11
- Schema,
12
- subscribe,
13
- subscribeNode,
14
- unwrap,
15
- version
16
- } from "@kyneta/schema";
17
-
18
- // src/bind-yjs.ts
19
- import {
20
- BACKING_DOC as BACKING_DOC2,
21
- createSubstrateNamespace,
22
- STRUCTURAL_YJS_CLIENT_ID as STRUCTURAL_YJS_CLIENT_ID2
23
- } from "@kyneta/schema";
24
- import * as Y6 from "yjs";
25
-
26
- // src/populate.ts
27
- import { KIND, STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema";
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";
28
2
  import * as Y from "yjs";
29
- function ensureContainers(doc, schema, conditional = false) {
30
- const rootMap = doc.getMap("root");
31
- if (schema[KIND] !== "product") {
32
- return;
33
- }
34
- const savedClientID = doc.clientID;
35
- doc.clientID = STRUCTURAL_YJS_CLIENT_ID;
36
- try {
37
- doc.transact(() => {
38
- for (const [key, fieldSchema] of Object.entries(schema.fields).sort(
39
- ([a], [b]) => a.localeCompare(b)
40
- )) {
41
- if (conditional && rootMap.has(key)) continue;
42
- ensureRootField(rootMap, key, fieldSchema);
43
- }
44
- });
45
- } finally {
46
- doc.clientID = savedClientID;
47
- }
48
- }
49
- function ensureRootField(rootMap, key, fieldSchema) {
50
- switch (fieldSchema[KIND]) {
51
- case "text":
52
- rootMap.set(key, new Y.Text());
53
- return;
54
- case "product":
55
- rootMap.set(key, ensureMapContainers(fieldSchema));
56
- return;
57
- case "sequence":
58
- rootMap.set(key, new Y.Array());
59
- return;
60
- case "map":
61
- rootMap.set(key, new Y.Map());
62
- return;
63
- case "scalar":
64
- case "sum": {
65
- const zero = Zero.structural(fieldSchema);
66
- if (zero !== void 0) {
67
- rootMap.set(key, zero);
68
- }
69
- return;
70
- }
71
- case "counter":
72
- case "set":
73
- case "tree":
74
- case "movable":
75
- throw new Error(
76
- `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". Supported kinds: text, product, sequence, map, scalar, sum. Encountered unsupported kind at root field "${key}".`
77
- );
78
- }
79
- }
80
- function ensureMapContainers(schema) {
81
- const map = new Y.Map();
82
- if (schema[KIND] !== "product") return map;
83
- for (const [key, fieldSchema] of Object.entries(
84
- schema.fields
85
- ).sort(([a], [b]) => a.localeCompare(b))) {
86
- switch (fieldSchema[KIND]) {
87
- case "text":
88
- map.set(key, new Y.Text());
89
- break;
90
- case "product":
91
- map.set(key, ensureMapContainers(fieldSchema));
92
- break;
93
- case "sequence":
94
- map.set(key, new Y.Array());
95
- break;
96
- case "map":
97
- map.set(key, new Y.Map());
98
- break;
99
- case "scalar":
100
- case "sum": {
101
- const zero = Zero.structural(fieldSchema);
102
- if (zero !== void 0) {
103
- map.set(key, zero);
104
- }
105
- break;
106
- }
107
- case "counter":
108
- case "set":
109
- case "tree":
110
- case "movable":
111
- throw new Error(
112
- `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". Supported kinds: text, product, sequence, map, scalar, sum. Encountered unsupported kind at nested field "${key}".`
113
- );
114
- }
115
- }
116
- return map;
3
+ import { createSnapshot, decodeStateVector, encodeSnapshot, encodeStateVector, snapshot } from "yjs";
4
+ //#region src/populate.ts
5
+ /**
6
+ * Ensure that a Y.Doc's root map contains the correct Yjs shared types
7
+ * matching the schema structure.
8
+ *
9
+ * Obtains the root map via `doc.getMap("root")`, reads the root product
10
+ * schema's fields, and creates empty containers for each field within a
11
+ * single `doc.transact()` call for atomicity.
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.
21
+ *
22
+ * **Structural identity:** This function temporarily sets `doc.clientID`
23
+ * to `STRUCTURAL_YJS_CLIENT_ID` (0) for the duration of container creation,
24
+ * then restores the caller's clientID. This produces byte-identical
25
+ * structural ops across all peers, enabling Yjs deduplication on merge.
26
+ *
27
+ * **Identity-keying:** When a `binding` is provided, each root field's
28
+ * key in the root Y.Map is the identity hash from `binding.forward`
29
+ * instead of the field name. Nested product fields are similarly keyed
30
+ * via `ensureMapContainers`.
31
+ *
32
+ * @param doc - The Y.Doc to prepare
33
+ * @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
+ * @param binding - Optional SchemaBinding for identity-keyed containers.
37
+ */
38
+ function ensureContainers(doc, schema, conditional = false, binding) {
39
+ const rootMap = doc.getMap("root");
40
+ if (schema[KIND] !== "product") return;
41
+ const savedClientID = doc.clientID;
42
+ doc.clientID = STRUCTURAL_YJS_CLIENT_ID;
43
+ try {
44
+ 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
+ }
50
+ });
51
+ } finally {
52
+ doc.clientID = savedClientID;
53
+ }
117
54
  }
118
-
119
- // src/substrate.ts
120
- import {
121
- BACKING_DOC,
122
- buildWritableContext,
123
- executeBatch,
124
- KIND as KIND3
125
- } from "@kyneta/schema";
126
- import * as Y5 from "yjs";
127
-
128
- // src/change-mapping.ts
129
- import {
130
- advanceSchema as advanceSchema2,
131
- expandMapOpsToLeaves,
132
- KIND as KIND2,
133
- RawPath
134
- } from "@kyneta/schema";
135
- import * as Y3 from "yjs";
136
-
137
- // src/yjs-resolve.ts
138
- import { advanceSchema } from "@kyneta/schema";
139
- import * as Y2 from "yjs";
140
- function stepIntoYjs(current, segment) {
141
- const resolved = segment.resolve();
142
- if (current instanceof Y2.Map) {
143
- return current.get(resolved);
144
- }
145
- if (current instanceof Y2.Array) {
146
- return current.get(resolved);
147
- }
148
- if (current instanceof Y2.Text) {
149
- throw new Error(`yjs-resolve: cannot step into Y.Text`);
150
- }
151
- return void 0;
152
- }
153
- function resolveYjsType(rootMap, rootSchema, path) {
154
- let current = rootMap;
155
- let schema = rootSchema;
156
- for (let i = 0; i < path.length; i++) {
157
- const seg = path.segments[i];
158
- const nextSchema = advanceSchema(schema, seg);
159
- current = stepIntoYjs(current, seg);
160
- schema = nextSchema;
161
- }
162
- return current;
55
+ /**
56
+ * Ensure a root-level Yjs shared type exists for a schema field.
57
+ *
58
+ * Dispatches on `[KIND]`:
59
+ * - `"text"` → empty Y.Text
60
+ * - `"product"` → empty Y.Map (recursive for nested products)
61
+ * - `"sequence"` → empty Y.Array
62
+ * - `"map"` → empty Y.Map
63
+ * - `"scalar"` / `"sum"` → Zero.structural default
64
+ * - `"counter"` / `"set"` / `"tree"` / `"movable"` → throw (not supported by Yjs)
65
+ *
66
+ * @param rootMap - The root Y.Map to set the field on.
67
+ * @param key - The key to use in the root map (identity hash or field name).
68
+ * @param fieldSchema - The schema for this field.
69
+ * @param binding - Optional SchemaBinding for nested identity-keying.
70
+ * @param prefix - The absolute schema path prefix for this field (used for nested lookups).
71
+ */
72
+ function ensureRootField(rootMap, key, fieldSchema, binding, prefix) {
73
+ switch (fieldSchema[KIND]) {
74
+ case "text":
75
+ case "richtext":
76
+ rootMap.set(key, new Y.Text());
77
+ return;
78
+ case "product":
79
+ rootMap.set(key, ensureMapContainers(fieldSchema, binding, prefix));
80
+ return;
81
+ case "sequence":
82
+ rootMap.set(key, new Y.Array());
83
+ return;
84
+ case "map":
85
+ rootMap.set(key, new Y.Map());
86
+ return;
87
+ case "scalar":
88
+ case "sum": {
89
+ const zero = Zero.structural(fieldSchema);
90
+ if (zero !== void 0) rootMap.set(key, zero);
91
+ return;
92
+ }
93
+ case "counter":
94
+ case "set":
95
+ case "tree":
96
+ case "movable": throw new Error(`Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". Supported kinds: text, richtext, product, sequence, map, scalar, sum. Encountered unsupported kind at root field "${key}".`);
97
+ }
163
98
  }
164
-
165
- // src/change-mapping.ts
166
- function applyChangeToYjs(rootMap, rootSchema, path, change2) {
167
- switch (change2.type) {
168
- case "text":
169
- applyTextChange(rootMap, rootSchema, path, change2);
170
- return;
171
- case "sequence":
172
- applySequenceChange(rootMap, rootSchema, path, change2);
173
- return;
174
- case "map":
175
- applyMapChange(rootMap, rootSchema, path, change2);
176
- return;
177
- case "replace":
178
- applyReplaceChange(rootMap, rootSchema, path, change2);
179
- return;
180
- case "increment":
181
- throw new Error(
182
- `Yjs substrate does not support "${change2.type}" changes. Counter requires a CRDT backend that supports counters (e.g. Loro). Attempted IncrementChange with amount=${change2.amount} at path [${pathToString(path)}].`
183
- );
184
- case "tree":
185
- throw new Error(
186
- `Yjs substrate does not support "${change2.type}" changes. Tree requires a CRDT backend that supports trees (e.g. Loro). Attempted TreeChange at path [${pathToString(path)}].`
187
- );
188
- default:
189
- throw new Error(
190
- `applyChangeToYjs: unsupported change type "${change2.type}"`
191
- );
192
- }
193
- }
194
- function applyTextChange(rootMap, rootSchema, path, change2) {
195
- const resolved = resolveYjsType(rootMap, rootSchema, path);
196
- if (!(resolved instanceof Y3.Text)) {
197
- throw new Error(
198
- `applyChangeToYjs: TextChange target at path [${pathToString(path)}] is not a Y.Text`
199
- );
200
- }
201
- resolved.applyDelta(change2.instructions);
202
- }
203
- function applySequenceChange(rootMap, rootSchema, path, change2) {
204
- const resolved = resolveYjsType(rootMap, rootSchema, path);
205
- if (!(resolved instanceof Y3.Array)) {
206
- throw new Error(
207
- `applyChangeToYjs: SequenceChange target at path [${pathToString(path)}] is not a Y.Array`
208
- );
209
- }
210
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
211
- const itemSchema = getItemSchema(targetSchema);
212
- let cursor = 0;
213
- for (const instruction of change2.instructions) {
214
- if ("retain" in instruction) {
215
- cursor += instruction.retain;
216
- } else if ("delete" in instruction) {
217
- resolved.delete(cursor, instruction.delete);
218
- } else if ("insert" in instruction) {
219
- const items = instruction.insert;
220
- const yjsItems = items.map(
221
- (item) => maybeCreateSharedType(item, itemSchema)
222
- );
223
- resolved.insert(cursor, yjsItems);
224
- cursor += items.length;
225
- }
226
- }
227
- }
228
- function applyMapChange(rootMap, rootSchema, path, change2) {
229
- const resolved = resolveYjsType(rootMap, rootSchema, path);
230
- if (!(resolved instanceof Y3.Map)) {
231
- throw new Error(
232
- `applyChangeToYjs: MapChange target at path [${pathToString(path)}] is not a Y.Map`
233
- );
234
- }
235
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
236
- if (change2.delete) {
237
- for (const key of change2.delete) {
238
- resolved.delete(key);
239
- }
240
- }
241
- if (change2.set) {
242
- for (const [key, value] of Object.entries(change2.set)) {
243
- const fieldSchema = getFieldSchema(targetSchema, key);
244
- const yjsValue = maybeCreateSharedType(value, fieldSchema);
245
- resolved.set(key, yjsValue);
246
- }
247
- }
248
- }
249
- function applyReplaceChange(rootMap, rootSchema, path, change2) {
250
- if (path.length === 0) {
251
- throw new Error(
252
- "applyChangeToYjs: ReplaceChange at root path is not supported"
253
- );
254
- }
255
- const lastSeg = path.segments[path.segments.length - 1];
256
- const parentPath = path.slice(0, -1);
257
- const parent = resolveYjsType(rootMap, rootSchema, parentPath);
258
- const resolved = lastSeg.resolve();
259
- if (parent instanceof Y3.Map && lastSeg.role === "key") {
260
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
261
- const yjsValue = maybeCreateSharedType(change2.value, targetSchema);
262
- parent.set(resolved, yjsValue);
263
- } else if (parent instanceof Y3.Array && lastSeg.role === "index") {
264
- const targetSchema = resolveSchemaAtPath(rootSchema, path);
265
- const yjsValue = maybeCreateSharedType(change2.value, targetSchema);
266
- parent.delete(resolved, 1);
267
- parent.insert(resolved, [yjsValue]);
268
- } else {
269
- throw new Error(
270
- `applyChangeToYjs: ReplaceChange parent at path [${pathToString(parentPath)}] is not a Y.Map or Y.Array (got ${typeof parent})`
271
- );
272
- }
99
+ /**
100
+ * Create an empty Y.Map with nested shared type children matching
101
+ * the product schema's field structure.
102
+ *
103
+ * Only creates containers for fields that require Yjs shared types
104
+ * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
105
+ * Scalar and sum fields are set to their structural zero defaults.
106
+ *
107
+ * **Identity-keying:** When a `binding` is provided, computes the
108
+ * absolute schema path for each nested field (`prefix.fieldName`) and
109
+ * looks up the identity hash from `binding.forward`. The identity hash
110
+ * is used as the Y.Map entry key instead of the field name.
111
+ *
112
+ * @param schema - The product schema for this nested map.
113
+ * @param binding - Optional SchemaBinding for identity-keyed containers.
114
+ * @param prefix - The absolute schema path prefix (e.g. "meta" for fields under meta).
115
+ */
116
+ function ensureMapContainers(schema, binding, prefix) {
117
+ const map = new Y.Map();
118
+ if (schema[KIND] !== "product") return map;
119
+ for (const [key, fieldSchema] of Object.entries(schema.fields).sort(([a], [b]) => a.localeCompare(b))) {
120
+ const absPath = prefix ? `${prefix}.${key}` : key;
121
+ const mapKey = binding?.forward.get(absPath) ?? key;
122
+ switch (fieldSchema[KIND]) {
123
+ case "text":
124
+ case "richtext":
125
+ map.set(mapKey, new Y.Text());
126
+ break;
127
+ case "product":
128
+ map.set(mapKey, ensureMapContainers(fieldSchema, binding, absPath));
129
+ break;
130
+ case "sequence":
131
+ map.set(mapKey, new Y.Array());
132
+ break;
133
+ case "map":
134
+ map.set(mapKey, new Y.Map());
135
+ break;
136
+ case "scalar":
137
+ case "sum": {
138
+ const zero = Zero.structural(fieldSchema);
139
+ if (zero !== void 0) map.set(mapKey, zero);
140
+ break;
141
+ }
142
+ case "counter":
143
+ case "set":
144
+ case "tree":
145
+ case "movable": throw new Error(`Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". Supported kinds: text, richtext, product, sequence, map, scalar, sum. Encountered unsupported kind at nested field "${key}".`);
146
+ }
147
+ }
148
+ return map;
149
+ }
150
+ //#endregion
151
+ //#region src/yjs-resolve.ts
152
+ /**
153
+ * Navigate one step deeper into the Yjs shared type tree.
154
+ *
155
+ * Uses `instanceof` for runtime type discrimination:
156
+ * - `Y.Map` → `.get(key)` uses the identity hash when provided
157
+ * - `Y.Array` → `.get(index)`
158
+ * - `Y.Text` → terminal (cannot step further)
159
+ * - Plain value → terminal (return `undefined`)
160
+ *
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
164
+ */
165
+ function stepIntoYjs(current, segment, identity) {
166
+ const resolved = segment.resolve();
167
+ if (current instanceof Y.Map) return current.get(identity ?? resolved);
168
+ if (current instanceof Y.Array) return current.get(resolved);
169
+ if (current instanceof Y.Text) throw new Error(`yjs-resolve: cannot step into Y.Text`);
170
+ }
171
+ /**
172
+ * Resolve a Yjs shared type (or plain value) at the given path.
173
+ *
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).
181
+ *
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.
190
+ */
191
+ 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
+ }
208
+ return {
209
+ resolved: current,
210
+ schema
211
+ };
212
+ }
213
+ //#endregion
214
+ //#region src/change-mapping.ts
215
+ /**
216
+ * Apply a kyneta Change to the Yjs shared type tree imperatively.
217
+ *
218
+ * Resolves the target shared type at `path`, then applies the change
219
+ * via the appropriate Yjs API. Must be called within a `doc.transact()`
220
+ * for atomicity and correct event batching.
221
+ *
222
+ * @param rootMap - The root `Y.Map` obtained via `doc.getMap("root")`
223
+ * @param rootSchema - The root document schema
224
+ * @param path - The path to the target
225
+ * @param change - The kyneta Change to apply
226
+ */
227
+ function applyChangeToYjs(rootMap, rootSchema, path, change, binding) {
228
+ switch (change.type) {
229
+ case "text":
230
+ applyTextChange(rootMap, rootSchema, path, change, binding);
231
+ return;
232
+ case "richtext":
233
+ applyRichTextChange(rootMap, rootSchema, path, change, binding);
234
+ return;
235
+ case "sequence":
236
+ applySequenceChange(rootMap, rootSchema, path, change, binding);
237
+ return;
238
+ case "map":
239
+ applyMapChange(rootMap, rootSchema, path, change, binding);
240
+ return;
241
+ case "replace":
242
+ applyReplaceChange(rootMap, rootSchema, path, change, binding);
243
+ return;
244
+ 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)}].`);
245
+ 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)}].`);
246
+ default: throw new Error(`applyChangeToYjs: unsupported change type "${change.type}"`);
247
+ }
273
248
  }
249
+ function applyTextChange(rootMap, rootSchema, path, change, binding) {
250
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
251
+ if (!(resolved instanceof Y.Text)) throw new Error(`applyChangeToYjs: TextChange target at path [${pathToString(path)}] is not a Y.Text`);
252
+ resolved.applyDelta(change.instructions);
253
+ }
254
+ function applyRichTextChange(rootMap, rootSchema, path, change, binding) {
255
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
256
+ if (!(resolved instanceof Y.Text)) throw new Error(`applyChangeToYjs: RichTextChange target at path [${pathToString(path)}] is not a Y.Text`);
257
+ const delta = change.instructions.map((inst) => {
258
+ if ("retain" in inst) return { retain: inst.retain };
259
+ if ("format" in inst) return {
260
+ retain: inst.format,
261
+ attributes: inst.marks
262
+ };
263
+ if ("insert" in inst) {
264
+ const d = { insert: inst.insert };
265
+ if (inst.marks && Object.keys(inst.marks).length > 0) d.attributes = inst.marks;
266
+ return d;
267
+ }
268
+ if ("delete" in inst) return { delete: inst.delete };
269
+ throw new Error("applyRichTextChange: unknown instruction type");
270
+ });
271
+ resolved.applyDelta(delta);
272
+ }
273
+ function applySequenceChange(rootMap, rootSchema, path, change, binding) {
274
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
275
+ if (!(resolved instanceof Y.Array)) throw new Error(`applyChangeToYjs: SequenceChange target at path [${pathToString(path)}] is not a Y.Array`);
276
+ const itemSchema = getItemSchema(resolveSchemaAtPath(rootSchema, path));
277
+ let cursor = 0;
278
+ for (const instruction of change.instructions) if ("retain" in instruction) cursor += instruction.retain;
279
+ else if ("delete" in instruction) resolved.delete(cursor, instruction.delete);
280
+ else if ("insert" in instruction) {
281
+ const items = instruction.insert;
282
+ const yjsItems = items.map((item) => maybeCreateSharedType(item, itemSchema));
283
+ resolved.insert(cursor, yjsItems);
284
+ cursor += items.length;
285
+ }
286
+ }
287
+ function applyMapChange(rootMap, rootSchema, path, change, binding) {
288
+ const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding);
289
+ if (!(resolved instanceof Y.Map)) throw new Error(`applyChangeToYjs: MapChange target at path [${pathToString(path)}] is not a Y.Map`);
290
+ const targetSchema = resolveSchemaAtPath(rootSchema, path);
291
+ if (change.delete) for (const key of change.delete) resolved.delete(key);
292
+ if (change.set) for (const [key, value] of Object.entries(change.set)) {
293
+ const yjsValue = maybeCreateSharedType(value, getFieldSchema(targetSchema, key));
294
+ let mapKey = key;
295
+ if (binding && targetSchema[KIND] === "product") {
296
+ const parentAbsPath = path.segments.filter((s) => s.role === "key").map((s) => s.resolve()).join(".");
297
+ const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key;
298
+ const identity = binding.forward.get(absPath);
299
+ if (identity) mapKey = identity;
300
+ }
301
+ resolved.set(mapKey, yjsValue);
302
+ }
303
+ }
304
+ function applyReplaceChange(rootMap, rootSchema, path, change, binding) {
305
+ if (path.length === 0) throw new Error("applyChangeToYjs: ReplaceChange at root path is not supported");
306
+ const lastSeg = path.segments.at(-1);
307
+ if (!lastSeg) throw new Error("replaceChangeToDiff: empty path");
308
+ const parentPath = path.slice(0, -1);
309
+ const { resolved: parent } = resolveYjsType(rootMap, rootSchema, parentPath, binding);
310
+ const resolved = lastSeg.resolve();
311
+ if (parent instanceof Y.Map && lastSeg.role === "key") {
312
+ const targetSchema = resolveSchemaAtPath(rootSchema, path);
313
+ const yjsValue = maybeCreateSharedType(change.value, targetSchema);
314
+ let mapKey = resolved;
315
+ if (binding) {
316
+ const absPath = path.segments.filter((s) => s.role === "key").map((s) => s.resolve()).join(".");
317
+ const identity = binding.forward.get(absPath);
318
+ if (identity) mapKey = identity;
319
+ }
320
+ parent.set(mapKey, yjsValue);
321
+ } else if (parent instanceof Y.Array && lastSeg.role === "index") {
322
+ const targetSchema = resolveSchemaAtPath(rootSchema, path);
323
+ const yjsValue = maybeCreateSharedType(change.value, targetSchema);
324
+ parent.delete(resolved, 1);
325
+ parent.insert(resolved, [yjsValue]);
326
+ } else throw new Error(`applyChangeToYjs: ReplaceChange parent at path [${pathToString(parentPath)}] is not a Y.Map or Y.Array (got ${typeof parent})`);
327
+ }
328
+ /**
329
+ * If the schema says the value should be a shared type (product → Y.Map,
330
+ * sequence → Y.Array, text → Y.Text, richtext → Y.Text), create and
331
+ * populate it. Otherwise return the plain value as-is.
332
+ *
333
+ * Uses populate-then-attach: the new shared type is fully populated
334
+ * before being returned for insertion into its parent.
335
+ */
274
336
  function maybeCreateSharedType(value, schema) {
275
- if (schema === void 0) return value;
276
- switch (schema[KIND2]) {
277
- // First-class text → Y.Text
278
- case "text": {
279
- const text = new Y3.Text();
280
- if (typeof value === "string" && value.length > 0) {
281
- text.insert(0, value);
282
- }
283
- return text;
284
- }
285
- case "product": {
286
- if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) {
287
- return value;
288
- }
289
- return createStructuredMap(value, schema);
290
- }
291
- case "sequence": {
292
- if (!Array.isArray(value)) return value;
293
- const arr = new Y3.Array();
294
- const itemSchema = schema.item;
295
- const items = value.map(
296
- (item) => maybeCreateSharedType(item, itemSchema)
297
- );
298
- arr.insert(0, items);
299
- return arr;
300
- }
301
- case "map": {
302
- if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) {
303
- return value;
304
- }
305
- const map = new Y3.Map();
306
- const valueSchema = schema.item;
307
- for (const [k, v] of Object.entries(value)) {
308
- map.set(k, maybeCreateSharedType(v, valueSchema));
309
- }
310
- return map;
311
- }
312
- // Unsupported first-class CRDT types — should not reach here
313
- // (rejected at bind time by caps check)
314
- case "counter":
315
- case "set":
316
- case "tree":
317
- case "movable":
318
- throw new Error(
319
- `Yjs substrate does not support [KIND]="${schema[KIND2]}". This should have been caught at bind() time.`
320
- );
321
- default:
322
- return value;
323
- }
337
+ if (schema === void 0) return value;
338
+ switch (schema[KIND]) {
339
+ case "text": {
340
+ const text = new Y.Text();
341
+ if (typeof value === "string" && value.length > 0) text.insert(0, value);
342
+ return text;
343
+ }
344
+ case "richtext": {
345
+ const text = new Y.Text();
346
+ if (typeof value === "string" && value.length > 0) text.insert(0, value);
347
+ else if (Array.isArray(value)) {
348
+ const delta = value.map((span) => {
349
+ const d = { insert: span.text };
350
+ if (span.marks && Object.keys(span.marks).length > 0) d.attributes = span.marks;
351
+ return d;
352
+ });
353
+ if (delta.length > 0) text.applyDelta(delta);
354
+ }
355
+ return text;
356
+ }
357
+ case "product":
358
+ if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return value;
359
+ return createStructuredMap(value, schema);
360
+ case "sequence": {
361
+ if (!Array.isArray(value)) return value;
362
+ const arr = new Y.Array();
363
+ const itemSchema = schema.item;
364
+ const items = value.map((item) => maybeCreateSharedType(item, itemSchema));
365
+ arr.insert(0, items);
366
+ return arr;
367
+ }
368
+ case "map": {
369
+ if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return value;
370
+ const map = new Y.Map();
371
+ const valueSchema = schema.item;
372
+ for (const [k, v] of Object.entries(value)) map.set(k, maybeCreateSharedType(v, valueSchema));
373
+ return map;
374
+ }
375
+ case "counter":
376
+ case "set":
377
+ case "tree":
378
+ case "movable": throw new Error(`Yjs substrate does not support [KIND]="${schema[KIND]}". This should have been caught at bind() time.`);
379
+ default: return value;
380
+ }
324
381
  }
382
+ /**
383
+ * Create a Y.Map from a plain object, recursively creating nested
384
+ * shared types as guided by the product schema.
385
+ *
386
+ * Follows populate-then-attach: fully populates the map before the
387
+ * caller inserts it into a parent container.
388
+ */
325
389
  function createStructuredMap(obj, productSchema) {
326
- const map = new Y3.Map();
327
- if (productSchema[KIND2] !== "product") {
328
- for (const [key, val] of Object.entries(obj)) {
329
- map.set(key, val);
330
- }
331
- return map;
332
- }
333
- for (const [key, val] of Object.entries(obj)) {
334
- if (val === void 0) continue;
335
- const fieldSchema = productSchema.fields[key];
336
- const yjsVal = fieldSchema ? maybeCreateSharedType(val, fieldSchema) : val;
337
- map.set(key, yjsVal);
338
- }
339
- for (const [key, fieldSchema] of Object.entries(
340
- productSchema.fields
341
- )) {
342
- if (key in obj) continue;
343
- if (fieldSchema[KIND2] === "text") {
344
- map.set(key, new Y3.Text());
345
- }
346
- }
347
- return map;
348
- }
349
- function eventsToOps(events, schema) {
350
- const ops = [];
351
- for (const event of events) {
352
- const kynetaPath = yjsPathToKynetaPath(event.path);
353
- const change2 = eventToChange(event);
354
- if (change2) {
355
- ops.push({ path: kynetaPath, change: change2 });
356
- }
357
- }
358
- return expandMapOpsToLeaves(ops, schema);
359
- }
360
- function yjsPathToKynetaPath(yjsPath) {
361
- let path = RawPath.empty;
362
- for (const segment of yjsPath) {
363
- if (typeof segment === "string") {
364
- path = path.field(segment);
365
- } else if (typeof segment === "number") {
366
- path = path.item(segment);
367
- }
368
- }
369
- return path;
370
- }
371
- function eventToChange(event) {
372
- if (event.target instanceof Y3.Text) {
373
- return textEventToChange(event);
374
- }
375
- if (event.target instanceof Y3.Array) {
376
- return arrayEventToChange(event);
377
- }
378
- if (event.target instanceof Y3.Map) {
379
- return mapEventToChange(event);
380
- }
381
- return null;
390
+ const map = new Y.Map();
391
+ if (productSchema[KIND] !== "product") {
392
+ for (const [key, val] of Object.entries(obj)) map.set(key, val);
393
+ return map;
394
+ }
395
+ for (const [key, val] of Object.entries(obj)) {
396
+ if (val === void 0) continue;
397
+ const fieldSchema = productSchema.fields[key];
398
+ const yjsVal = fieldSchema ? maybeCreateSharedType(val, fieldSchema) : val;
399
+ map.set(key, yjsVal);
400
+ }
401
+ for (const [key, fieldSchema] of Object.entries(productSchema.fields)) {
402
+ if (key in obj) continue;
403
+ if (fieldSchema[KIND] === "text" || fieldSchema[KIND] === "richtext") map.set(key, new Y.Text());
404
+ }
405
+ return map;
382
406
  }
407
+ /**
408
+ * Convert `observeDeep` events into kyneta `Op[]` for changefeed delivery.
409
+ *
410
+ * Each `Y.YEvent` in the array maps to one Op with:
411
+ * - `path`: derived from `event.path` (relative to the observed root Y.Map)
412
+ * - `change`: derived from the event's delta/keys based on target type
413
+ *
414
+ * `event.path` in `observeDeep` is relative to the observed shared type.
415
+ * Since we observe `rootMap` (the single root Y.Map), paths map directly
416
+ * to kyneta `PathSegment[]`.
417
+ *
418
+ * @param events - The events from the `observeDeep` callback
419
+ */
420
+ function eventsToOps(events, schema, binding) {
421
+ const ops = [];
422
+ for (const event of events) {
423
+ const kynetaPath = yjsPathToKynetaPath(event.path, binding);
424
+ const change = eventToChange(event, schema, kynetaPath, binding);
425
+ if (change) ops.push({
426
+ path: kynetaPath,
427
+ change
428
+ });
429
+ }
430
+ return expandMapOpsToLeaves(ops, schema);
431
+ }
432
+ /**
433
+ * Convert a Yjs event path (array of string | number) to a kyneta Path.
434
+ *
435
+ * `event.path` from `observeDeep` is relative to the observed type.
436
+ * Strings become key segments, numbers become index segments.
437
+ */
438
+ function yjsPathToKynetaPath(yjsPath, binding) {
439
+ let path = RawPath.empty;
440
+ for (const segment of yjsPath) if (typeof segment === "string") {
441
+ const absPath = binding?.inverse.get(segment);
442
+ if (absPath) {
443
+ const lastDot = absPath.lastIndexOf(".");
444
+ const leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath;
445
+ path = path.field(leaf);
446
+ } else path = path.field(segment);
447
+ } else if (typeof segment === "number") path = path.item(segment);
448
+ return path;
449
+ }
450
+ /**
451
+ * Convert a single Yjs event into a kyneta Change.
452
+ *
453
+ * For Y.Text events, dispatches to either `textEventToChange` or
454
+ * `richTextEventToChange` based on the schema at the event's path.
455
+ * Both text and richtext produce `Y.YTextEvent`, so schema awareness
456
+ * is required for correct dispatch.
457
+ *
458
+ * Returns null for event types we can't map.
459
+ */
460
+ function eventToChange(event, rootSchema, kynetaPath, binding) {
461
+ if (event.target instanceof Y.Text) {
462
+ if (resolveSchemaAtPath(rootSchema, kynetaPath)[KIND] === "richtext") return richTextEventToChange(event);
463
+ return textEventToChange(event);
464
+ }
465
+ if (event.target instanceof Y.Array) return arrayEventToChange(event);
466
+ if (event.target instanceof Y.Map) return mapEventToChange(event, binding);
467
+ return null;
468
+ }
469
+ /**
470
+ * Y.Text event → TextChange.
471
+ *
472
+ * `event.delta` uses the Quill Delta format, structurally identical to
473
+ * kyneta `TextInstruction[]`. We strip the `attributes` field (rich text
474
+ * formatting not surfaced by kyneta plain text).
475
+ */
383
476
  function textEventToChange(event) {
384
- const instructions = [];
385
- for (const delta of event.delta) {
386
- if (delta.retain !== void 0) {
387
- instructions.push({ retain: delta.retain });
388
- } else if (delta.insert !== void 0) {
389
- instructions.push({ insert: delta.insert });
390
- } else if (delta.delete !== void 0) {
391
- instructions.push({ delete: delta.delete });
392
- }
393
- }
394
- return { type: "text", instructions };
477
+ const instructions = [];
478
+ for (const delta of event.delta) if (delta.retain !== void 0) instructions.push({ retain: delta.retain });
479
+ else if (delta.insert !== void 0) instructions.push({ insert: delta.insert });
480
+ else if (delta.delete !== void 0) instructions.push({ delete: delta.delete });
481
+ return {
482
+ type: "text",
483
+ instructions
484
+ };
485
+ }
486
+ /**
487
+ * Y.Text event RichTextChange.
488
+ *
489
+ * `event.delta` uses the Quill Delta format. We map each delta op to a
490
+ * `RichTextInstruction`, preserving `attributes` as `marks` for format
491
+ * and insert instructions.
492
+ */
493
+ function richTextEventToChange(event) {
494
+ const instructions = [];
495
+ for (const delta of event.delta) if (delta.retain !== void 0) {
496
+ const attrs = delta.attributes;
497
+ if (attrs && Object.keys(attrs).length > 0) instructions.push({
498
+ format: delta.retain,
499
+ marks: attrs
500
+ });
501
+ else instructions.push({ retain: delta.retain });
502
+ } else if (delta.insert !== void 0) {
503
+ const attrs = delta.attributes;
504
+ if (attrs && Object.keys(attrs).length > 0) instructions.push({
505
+ insert: delta.insert,
506
+ marks: attrs
507
+ });
508
+ else instructions.push({ insert: delta.insert });
509
+ } else if (delta.delete !== void 0) instructions.push({ delete: delta.delete });
510
+ return richTextChange(instructions);
395
511
  }
512
+ /**
513
+ * Y.Array event → SequenceChange.
514
+ *
515
+ * `event.changes.delta` provides the same cursor-based ops as kyneta
516
+ * SequenceInstruction[]. Container values (Y.Map, Y.Array) in insert
517
+ * arrays are converted to plain objects via `.toJSON()`.
518
+ */
396
519
  function arrayEventToChange(event) {
397
- const instructions = [];
398
- for (const delta of event.changes.delta) {
399
- if (delta.retain !== void 0) {
400
- instructions.push({ retain: delta.retain });
401
- } else if (delta.delete !== void 0) {
402
- instructions.push({ delete: delta.delete });
403
- } else if (delta.insert !== void 0) {
404
- const items = delta.insert.map(
405
- (item) => extractEventValue(item)
406
- );
407
- instructions.push({ insert: items });
408
- }
409
- }
410
- return { type: "sequence", instructions };
411
- }
412
- function mapEventToChange(event) {
413
- const set = {};
414
- const deleteKeys = [];
415
- let hasSet = false;
416
- let hasDelete = false;
417
- const target = event.target;
418
- event.changes.keys.forEach((change2, key) => {
419
- if (change2.action === "add" || change2.action === "update") {
420
- const value = target.get(key);
421
- set[key] = extractEventValue(value);
422
- hasSet = true;
423
- } else if (change2.action === "delete") {
424
- deleteKeys.push(key);
425
- hasDelete = true;
426
- }
427
- });
428
- if (!hasSet && !hasDelete) return null;
429
- return {
430
- type: "map",
431
- ...hasSet ? { set } : {},
432
- ...hasDelete ? { delete: deleteKeys } : {}
433
- };
520
+ const instructions = [];
521
+ for (const delta of event.changes.delta) if (delta.retain !== void 0) instructions.push({ retain: delta.retain });
522
+ else if (delta.delete !== void 0) instructions.push({ delete: delta.delete });
523
+ else if (delta.insert !== void 0) {
524
+ const items = delta.insert.map((item) => extractEventValue(item));
525
+ instructions.push({ insert: items });
526
+ }
527
+ return {
528
+ type: "sequence",
529
+ instructions
530
+ };
434
531
  }
532
+ /**
533
+ * Y.Map event → MapChange.
534
+ *
535
+ * `event.changes.keys` is a `Map<string, { action: 'add'|'update'|'delete', ... }>`.
536
+ * - `action: 'add'|'update'` → `set[key] = map.get(key)`
537
+ * - `action: 'delete'` → `delete.push(key)`
538
+ */
539
+ function mapEventToChange(event, binding) {
540
+ const set = {};
541
+ const deleteKeys = [];
542
+ let hasSet = false;
543
+ let hasDelete = false;
544
+ const target = event.target;
545
+ event.changes.keys.forEach((change, key) => {
546
+ const absPath = binding?.inverse.get(key);
547
+ const fieldName = absPath ? absPath.lastIndexOf(".") >= 0 ? absPath.slice(absPath.lastIndexOf(".") + 1) : absPath : key;
548
+ if (change.action === "add" || change.action === "update") {
549
+ set[fieldName] = extractEventValue(target.get(key));
550
+ hasSet = true;
551
+ } else if (change.action === "delete") {
552
+ deleteKeys.push(fieldName);
553
+ hasDelete = true;
554
+ }
555
+ });
556
+ if (!hasSet && !hasDelete) return null;
557
+ return {
558
+ type: "map",
559
+ ...hasSet ? { set } : {},
560
+ ...hasDelete ? { delete: deleteKeys } : {}
561
+ };
562
+ }
563
+ /**
564
+ * Convert a Yjs value from an event into a plain value.
565
+ * Container values (Y.Map, Y.Array, Y.Text) → `.toJSON()`.
566
+ * Plain values → returned as-is.
567
+ */
435
568
  function extractEventValue(value) {
436
- if (value instanceof Y3.Map) return value.toJSON();
437
- if (value instanceof Y3.Array) return value.toJSON();
438
- if (value instanceof Y3.Text) return value.toJSON();
439
- return value;
569
+ if (value instanceof Y.Map) return value.toJSON();
570
+ if (value instanceof Y.Array) return value.toJSON();
571
+ if (value instanceof Y.Text) return value.toJSON();
572
+ return value;
440
573
  }
574
+ /**
575
+ * Resolve the schema at a given path by walking through advanceSchema.
576
+ */
441
577
  function resolveSchemaAtPath(rootSchema, path) {
442
- let schema = rootSchema;
443
- for (const seg of path.segments) {
444
- schema = advanceSchema2(schema, seg);
445
- }
446
- return schema;
578
+ let schema = rootSchema;
579
+ for (const seg of path.segments) schema = advanceSchema(schema, seg);
580
+ return schema;
447
581
  }
582
+ /**
583
+ * Get the item schema from a sequence schema, if available.
584
+ */
448
585
  function getItemSchema(schema) {
449
- if (schema[KIND2] === "sequence") return schema.item;
450
- if (schema[KIND2] === "movable") return schema.item;
451
- return void 0;
586
+ if (schema[KIND] === "sequence") return schema.item;
587
+ if (schema[KIND] === "movable") return schema.item;
452
588
  }
589
+ /**
590
+ * Get the field schema from a product or map schema for a given key.
591
+ */
453
592
  function getFieldSchema(schema, key) {
454
- if (schema[KIND2] === "product") {
455
- return schema.fields[key];
456
- }
457
- if (schema[KIND2] === "map") {
458
- return schema.item;
459
- }
460
- if (schema[KIND2] === "set") {
461
- return schema.item;
462
- }
463
- return void 0;
593
+ if (schema[KIND] === "product") return schema.fields[key];
594
+ if (schema[KIND] === "map") return schema.item;
595
+ if (schema[KIND] === "set") return schema.item;
464
596
  }
465
597
  function pathToString(path) {
466
- return path.segments.map((seg) => String(seg.resolve())).join(".");
598
+ return path.segments.map((seg) => String(seg.resolve())).join(".");
467
599
  }
468
-
469
- // src/reader.ts
470
- import * as Y4 from "yjs";
600
+ //#endregion
601
+ //#region src/position.ts
602
+ /** Map kyneta Side to Yjs assoc. Left → -1 (left-sticky), Right → 0 (right-sticky). */
603
+ function toYjsAssoc(side) {
604
+ return side === "left" ? -1 : 0;
605
+ }
606
+ /** Map Yjs assoc to kyneta Side. Negative → left, non-negative → right. */
607
+ function fromYjsAssoc(assoc) {
608
+ return assoc < 0 ? "left" : "right";
609
+ }
610
+ var YjsPosition = class {
611
+ side;
612
+ constructor(rpos, doc) {
613
+ this.rpos = rpos;
614
+ this.doc = doc;
615
+ this.side = fromYjsAssoc(rpos.assoc);
616
+ }
617
+ resolve() {
618
+ const abs = Y.createAbsolutePositionFromRelativePosition(this.rpos, this.doc);
619
+ return abs ? abs.index : null;
620
+ }
621
+ encode() {
622
+ return Y.encodeRelativePosition(this.rpos);
623
+ }
624
+ transform(_instructions) {}
625
+ };
626
+ //#endregion
627
+ //#region src/reader.ts
628
+ /**
629
+ * Extract a plain value from a Yjs shared type or return a plain value as-is.
630
+ *
631
+ * - Y.Text → `.toJSON()` (string)
632
+ * - Y.Map → `.toJSON()` (plain object snapshot — for product/map reads)
633
+ * - Y.Array → `.toJSON()` (plain array snapshot)
634
+ * - Plain values (string, number, boolean, null) → returned as-is
635
+ */
471
636
  function extractValue(resolved) {
472
- if (resolved instanceof Y4.Text) {
473
- return resolved.toJSON();
474
- }
475
- if (resolved instanceof Y4.Map) {
476
- return resolved.toJSON();
477
- }
478
- if (resolved instanceof Y4.Array) {
479
- return resolved.toJSON();
480
- }
481
- return resolved;
482
- }
483
- function yjsReader(doc, schema) {
484
- const rootMap = doc.getMap("root");
485
- return {
486
- read(path) {
487
- if (path.length === 0) {
488
- return rootMap.toJSON();
489
- }
490
- const resolved = resolveYjsType(rootMap, schema, path);
491
- return extractValue(resolved);
492
- },
493
- arrayLength(path) {
494
- const resolved = resolveYjsType(rootMap, schema, path);
495
- if (resolved instanceof Y4.Array) {
496
- return resolved.length;
497
- }
498
- if (Array.isArray(resolved)) {
499
- return resolved.length;
500
- }
501
- return 0;
502
- },
503
- keys(path) {
504
- const resolved = resolveYjsType(rootMap, schema, path);
505
- if (resolved instanceof Y4.Map) {
506
- return Array.from(resolved.keys());
507
- }
508
- if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && !Array.isArray(resolved)) {
509
- return Object.keys(resolved);
510
- }
511
- return [];
512
- },
513
- hasKey(path, key) {
514
- const resolved = resolveYjsType(rootMap, schema, path);
515
- if (resolved instanceof Y4.Map) {
516
- return resolved.has(key);
517
- }
518
- if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && !Array.isArray(resolved)) {
519
- return key in resolved;
520
- }
521
- return false;
522
- }
523
- };
637
+ if (resolved instanceof Y.Text) return resolved.toJSON();
638
+ if (resolved instanceof Y.Map) return resolved.toJSON();
639
+ if (resolved instanceof Y.Array) return resolved.toJSON();
640
+ return resolved;
524
641
  }
525
-
526
- // src/version.ts
527
- import {
528
- base64ToUint8Array,
529
- uint8ArrayToBase64,
530
- versionVectorCompare,
531
- versionVectorMeet
532
- } from "@kyneta/schema";
533
- import { decodeStateVector } from "yjs";
534
- function encodeStateVector(map) {
535
- const bytes = [];
536
- function writeVarUint(value) {
537
- while (value > 127) {
538
- bytes.push(value & 127 | 128);
539
- value >>>= 7;
540
- }
541
- bytes.push(value & 127);
542
- }
543
- writeVarUint(map.size);
544
- for (const [clientId, clock] of map) {
545
- writeVarUint(clientId);
546
- writeVarUint(clock);
547
- }
548
- return new Uint8Array(bytes);
549
- }
550
- var YjsVersion = class _YjsVersion {
551
- sv;
552
- constructor(sv) {
553
- this.sv = sv;
554
- }
555
- /**
556
- * Serialize the state vector to a base64 string.
557
- *
558
- * The encoding is: raw state vector bytes → base64.
559
- * This is text-safe for embedding in HTML meta tags, URL parameters, etc.
560
- */
561
- serialize() {
562
- return uint8ArrayToBase64(this.sv);
563
- }
564
- /**
565
- * Compare with another version using version-vector partial order.
566
- *
567
- * Delegates to the shared `versionVectorCompare` utility after decoding
568
- * both state vectors via `Y.decodeStateVector()`.
569
- *
570
- * @throws If `other` is not a `YjsVersion`.
571
- */
572
- compare(other) {
573
- if (!(other instanceof _YjsVersion)) {
574
- throw new Error("YjsVersion can only be compared with another YjsVersion");
575
- }
576
- return versionVectorCompare(
577
- decodeStateVector(this.sv),
578
- decodeStateVector(other.sv)
579
- );
580
- }
581
- /**
582
- * Greatest lower bound (lattice meet) of two Yjs versions.
583
- *
584
- * Decodes both state vectors, computes the component-wise minimum
585
- * via the shared `versionVectorMeet` utility, and encodes the result
586
- * back to a Yjs state vector.
587
- *
588
- * @throws If `other` is not a `YjsVersion`.
589
- */
590
- meet(other) {
591
- if (!(other instanceof _YjsVersion)) {
592
- throw new Error("YjsVersion can only be meet'd with another YjsVersion");
593
- }
594
- const thisMap = decodeStateVector(this.sv);
595
- const otherMap = decodeStateVector(other.sv);
596
- const result = versionVectorMeet(thisMap, otherMap);
597
- return new _YjsVersion(encodeStateVector(result));
598
- }
599
- /**
600
- * Parse a serialized YjsVersion string back into a YjsVersion.
601
- *
602
- * The inverse of `serialize()`: base64 → `Uint8Array`.
603
- */
604
- static parse(serialized) {
605
- if (serialized === "") {
606
- throw new Error("Invalid YjsVersion value: (empty string)");
607
- }
608
- const bytes = base64ToUint8Array(serialized);
609
- return new _YjsVersion(bytes);
610
- }
642
+ /**
643
+ * Convert a Y.Text's delta (Quill format) to a kyneta RichTextDelta.
644
+ *
645
+ * Yjs `.toDelta()` returns `{ insert: string, attributes?: Record<string, any> }[]`.
646
+ * Kyneta RichTextDelta is `{ text: string, marks?: MarkMap }[]`.
647
+ */
648
+ function yTextToRichTextDelta(ytext) {
649
+ const delta = ytext.toDelta();
650
+ const spans = [];
651
+ for (const d of delta) {
652
+ if (typeof d.insert !== "string") continue;
653
+ const span = d.attributes && Object.keys(d.attributes).length > 0 ? {
654
+ text: d.insert,
655
+ marks: d.attributes
656
+ } : { text: d.insert };
657
+ spans.push(span);
658
+ }
659
+ return spans;
660
+ }
661
+ /**
662
+ * Creates a Reader that navigates the Yjs shared type tree live,
663
+ * using the schema as a type witness to determine navigation at each
664
+ * path segment.
665
+ *
666
+ * The reader is a live view — mutations to the underlying Y.Doc
667
+ * (via `doc.transact()`, or `Y.applyUpdate()`) are immediately
668
+ * visible through the reader.
669
+ *
670
+ * Internally obtains the root map via `doc.getMap("root")`.
671
+ *
672
+ * @param doc - The Y.Doc to read from.
673
+ * @param schema - The root schema for the document.
674
+ * @param binding - Optional SchemaBinding for identity-keyed navigation.
675
+ */
676
+ function yjsReader(doc, schema, binding) {
677
+ const rootMap = doc.getMap("root");
678
+ return {
679
+ read(path) {
680
+ if (path.length === 0) return rootMap.toJSON();
681
+ const { resolved, schema: nodeSchema } = resolveYjsType(rootMap, schema, path, binding);
682
+ if (nodeSchema[KIND] === "richtext" && resolved instanceof Y.Text) return yTextToRichTextDelta(resolved);
683
+ return extractValue(resolved);
684
+ },
685
+ arrayLength(path) {
686
+ const { resolved } = resolveYjsType(rootMap, schema, path, binding);
687
+ if (resolved instanceof Y.Array) return resolved.length;
688
+ if (Array.isArray(resolved)) return resolved.length;
689
+ return 0;
690
+ },
691
+ keys(path) {
692
+ const { resolved } = resolveYjsType(rootMap, schema, path, binding);
693
+ if (resolved instanceof Y.Map) return Array.from(resolved.keys());
694
+ if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && !Array.isArray(resolved)) return Object.keys(resolved);
695
+ return [];
696
+ },
697
+ hasKey(path, key) {
698
+ const { resolved } = resolveYjsType(rootMap, schema, path, binding);
699
+ if (resolved instanceof Y.Map) return resolved.has(key);
700
+ if (resolved !== null && resolved !== void 0 && typeof resolved === "object" && !Array.isArray(resolved)) return key in resolved;
701
+ return false;
702
+ }
703
+ };
704
+ }
705
+ //#endregion
706
+ //#region src/version.ts
707
+ /**
708
+ * Encode a state vector map to Yjs's binary state vector format.
709
+ *
710
+ * Yjs does not export `encodeStateVector(map)` — only `Y.encodeStateVector(doc)`
711
+ * which requires a full doc. This implements the same binary format directly:
712
+ * `[entryCount: varint, (clientId: varint, clock: varint)*]`
713
+ *
714
+ * Each value is encoded as an unsigned LEB128 varint.
715
+ */
716
+ function encodeStateVector$1(map) {
717
+ const bytes = [];
718
+ function writeVarUint(value) {
719
+ while (value > 127) {
720
+ bytes.push(value & 127 | 128);
721
+ value >>>= 7;
722
+ }
723
+ bytes.push(value & 127);
724
+ }
725
+ writeVarUint(map.size);
726
+ for (const [clientId, clock] of map) {
727
+ writeVarUint(clientId);
728
+ writeVarUint(clock);
729
+ }
730
+ return new Uint8Array(bytes);
731
+ }
732
+ function arraysEqual(a, b) {
733
+ if (a.length !== b.length) return false;
734
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
735
+ return true;
736
+ }
737
+ /**
738
+ * A Version wrapping a Yjs snapshot (state vector + delete set).
739
+ *
740
+ * The state vector tracks which insertions from each client have been
741
+ * observed. The delete set tracks which of those items have been
742
+ * tombstoned. Together they fully describe a Yjs document's state.
743
+ *
744
+ * - `sv` is used by `exportSince()` to compute the minimal update payload
745
+ * via `Y.encodeStateAsUpdate(doc, sv)`.
746
+ * - `snapshotBytes` is the encoded Yjs `Snapshot` (SV + delete set),
747
+ * used for equality comparison: two documents are "equal" only when
748
+ * both their inserts and deletes match.
749
+ *
750
+ * `compare()` first performs standard version-vector partial-order
751
+ * comparison on the state vectors. If the SVs are equal, it falls
752
+ * through to a byte-level comparison of the snapshot bytes — if they
753
+ * differ (same inserts, different deletes), the result is "concurrent",
754
+ * ensuring the sync protocol pushes the divergent deletes.
755
+ */
756
+ var YjsVersion = class YjsVersion {
757
+ /** Encoded state vector — used by exportSince(). */
758
+ sv;
759
+ /**
760
+ * Encoded Yjs snapshot (state vector + delete set) — used for equality
761
+ * comparison. Two documents are "equal" only if both their inserts
762
+ * (state vector) and deletes (delete set) match.
763
+ */
764
+ snapshotBytes;
765
+ constructor(sv, snapshotBytes) {
766
+ this.sv = sv;
767
+ this.snapshotBytes = snapshotBytes ?? sv;
768
+ }
769
+ /**
770
+ * Construct a version from a live `Y.Doc` by snapshotting its full state.
771
+ *
772
+ * Walks the struct store to derive the delete set — O(n) in the number
773
+ * of items. Use {@link fromDeleteSet} for the incremental path.
774
+ */
775
+ static fromDoc(doc) {
776
+ return new YjsVersion(encodeStateVector(doc), encodeSnapshot(snapshot(doc)));
777
+ }
778
+ /**
779
+ * Construct a version from a `Y.Doc`'s state vector and an externally
780
+ * maintained delete set — the incremental path that avoids a struct
781
+ * store walk.
782
+ *
783
+ * @param doc The live Y.Doc (for the state vector).
784
+ * @param ds An accumulated delete set, kept in sync by merging
785
+ * `transaction.deleteSet` on each transaction.
786
+ */
787
+ static fromDeleteSet(doc, ds) {
788
+ const sv = encodeStateVector(doc);
789
+ return new YjsVersion(sv, encodeSnapshot(createSnapshot(ds, decodeStateVector(sv))));
790
+ }
791
+ /**
792
+ * Serialize to a text-safe string.
793
+ *
794
+ * Format: `base64(sv) + "." + base64(snapshotBytes)`.
795
+ * The "." separator is unambiguous since base64 never contains ".".
796
+ */
797
+ serialize() {
798
+ const svB64 = uint8ArrayToBase64(this.sv);
799
+ const snapB64 = uint8ArrayToBase64(this.snapshotBytes);
800
+ return svB64 + "." + snapB64;
801
+ }
802
+ /**
803
+ * Compare with another version using version-vector partial order,
804
+ * extended with delete-set equality checking.
805
+ *
806
+ * 1. Decode both state vectors and compare via `versionVectorCompare`.
807
+ * 2. If the SV comparison yields anything other than "equal", return it.
808
+ * 3. If the SVs are equal, compare snapshot bytes for byte equality.
809
+ * If they differ (same inserts, different deletes), return "concurrent"
810
+ * — both sides may have tombstones the other lacks.
811
+ * 4. "equal" is returned only when BOTH the state vector AND the
812
+ * delete set match.
813
+ *
814
+ * @throws If `other` is not a `YjsVersion`.
815
+ */
816
+ compare(other) {
817
+ if (!(other instanceof YjsVersion)) throw new Error("YjsVersion can only be compared with another YjsVersion");
818
+ const svResult = versionVectorCompare(decodeStateVector(this.sv), decodeStateVector(other.sv));
819
+ if (svResult !== "equal") return svResult;
820
+ return arraysEqual(this.snapshotBytes, other.snapshotBytes) ? "equal" : "concurrent";
821
+ }
822
+ /**
823
+ * Greatest lower bound (lattice meet) of two Yjs versions.
824
+ *
825
+ * Decodes both state vectors, computes the component-wise minimum
826
+ * via `versionVectorMeet`, and encodes the result back to a Yjs
827
+ * state vector.
828
+ *
829
+ * The meet snapshot uses the meet SV with no delete-set information
830
+ * (conservative lower bound). meet() feeds into advance(), which Yjs
831
+ * does not support incrementally, so this is safe.
832
+ *
833
+ * @throws If `other` is not a `YjsVersion`.
834
+ */
835
+ meet(other) {
836
+ if (!(other instanceof YjsVersion)) throw new Error("YjsVersion can only be meet'd with another YjsVersion");
837
+ return new YjsVersion(encodeStateVector$1(versionVectorMeet(decodeStateVector(this.sv), decodeStateVector(other.sv))));
838
+ }
839
+ /**
840
+ * Parse a serialized YjsVersion string back into a YjsVersion.
841
+ *
842
+ * New format: `base64(sv) + "." + base64(snapshotBytes)`.
843
+ * Legacy format (no "."): `base64(sv)` only — constructed with
844
+ * `snapshotBytes` equal to the SV bytes. When compared against a
845
+ * new-format version with matching SVs, the differing snapshot bytes
846
+ * yield "concurrent", triggering a safe redundant sync push.
847
+ */
848
+ static parse(serialized) {
849
+ if (serialized === "") throw new Error("Invalid YjsVersion value: (empty string)");
850
+ const dotIndex = serialized.indexOf(".");
851
+ if (dotIndex === -1) return new YjsVersion(base64ToUint8Array(serialized));
852
+ return new YjsVersion(base64ToUint8Array(serialized.slice(0, dotIndex)), base64ToUint8Array(serialized.slice(dotIndex + 1)));
853
+ }
611
854
  };
612
-
613
- // src/substrate.ts
614
- var KYNETA_ORIGIN = "kyneta-prepare";
615
- function createYjsSubstrate(doc, schema) {
616
- const pendingChanges = [];
617
- let inOurTransaction = false;
618
- let pendingMergeOrigin;
619
- let cachedCtx;
620
- const rootMap = doc.getMap("root");
621
- const reader = yjsReader(doc, schema);
622
- const substrate = {
623
- [BACKING_DOC]: doc,
624
- reader,
625
- prepare(path, change2) {
626
- if (!inOurTransaction) {
627
- pendingChanges.push({ path, change: change2 });
628
- }
629
- },
630
- onFlush(_origin) {
631
- if (!inOurTransaction && pendingChanges.length > 0) {
632
- inOurTransaction = true;
633
- try {
634
- doc.transact(() => {
635
- for (const { path, change: change2 } of pendingChanges) {
636
- applyChangeToYjs(rootMap, schema, path, change2);
637
- }
638
- }, KYNETA_ORIGIN);
639
- pendingChanges.length = 0;
640
- } finally {
641
- inOurTransaction = false;
642
- }
643
- }
644
- },
645
- context() {
646
- if (!cachedCtx) {
647
- cachedCtx = buildWritableContext(substrate);
648
- cachedCtx.nativeResolver = (nodeSchema, path) => {
649
- if (path.segments.length === 0) return doc;
650
- if (nodeSchema[KIND3] === "scalar" || nodeSchema[KIND3] === "sum")
651
- return void 0;
652
- return resolveYjsType(rootMap, schema, path);
653
- };
654
- }
655
- return cachedCtx;
656
- },
657
- version() {
658
- return new YjsVersion(Y5.encodeStateVector(doc));
659
- },
660
- baseVersion() {
661
- return new YjsVersion(new Uint8Array([0]));
662
- },
663
- advance(_to) {
664
- throw new Error(
665
- "advance() on a live Yjs substrate is not yet supported. Use advance() on a YjsReplica instead."
666
- );
667
- },
668
- exportEntirety() {
669
- return {
670
- kind: "entirety",
671
- encoding: "binary",
672
- data: Y5.encodeStateAsUpdate(doc)
673
- };
674
- },
675
- exportSince(since) {
676
- try {
677
- const bytes = Y5.encodeStateAsUpdate(doc, since.sv);
678
- return { kind: "since", encoding: "binary", data: bytes };
679
- } catch {
680
- return null;
681
- }
682
- },
683
- merge(payload, origin) {
684
- if (payload.encoding !== "binary" || !(payload.data instanceof Uint8Array)) {
685
- throw new Error(
686
- "YjsSubstrate.merge expects binary-encoded payloads. If you recently switched CRDT backends, stale clients may be sending incompatible data."
687
- );
688
- }
689
- pendingMergeOrigin = origin;
690
- try {
691
- Y5.applyUpdate(doc, payload.data, origin ?? "remote");
692
- } finally {
693
- pendingMergeOrigin = void 0;
694
- }
695
- }
696
- };
697
- rootMap.observeDeep((events, transaction) => {
698
- if (transaction.origin === KYNETA_ORIGIN) {
699
- return;
700
- }
701
- const ops = eventsToOps(events, schema);
702
- if (ops.length === 0) {
703
- return;
704
- }
705
- const origin = pendingMergeOrigin ?? (typeof transaction.origin === "string" ? transaction.origin : void 0);
706
- const ctx = substrate.context();
707
- inOurTransaction = true;
708
- try {
709
- executeBatch(ctx, ops, origin);
710
- } finally {
711
- inOurTransaction = false;
712
- }
713
- });
714
- return substrate;
855
+ //#endregion
856
+ //#region src/substrate.ts
857
+ const KYNETA_ORIGIN = "kyneta-prepare";
858
+ /**
859
+ * Creates a `Substrate<YjsVersion>` wrapping a user-provided Y.Doc.
860
+ *
861
+ * This is the "bring your own doc" entry point. The user creates and
862
+ * manages the Y.Doc (possibly via a Yjs provider); this function wraps
863
+ * it with a schema-aware overlay providing typed reads, writes,
864
+ * versioning, and export/merge through the standard Substrate interface.
865
+ *
866
+ * **Event bridge contract:** A persistent `observeDeep` handler is
867
+ * registered on the root Y.Map at construction time. All non-kyneta
868
+ * mutations to the Y.Doc (merges, external local writes) are bridged
869
+ * to the kyneta changefeed. Subscribing to the kyneta doc observes all
870
+ * mutations regardless of source.
871
+ *
872
+ * @param doc - The Y.Doc to wrap. The substrate does NOT own the doc;
873
+ * the caller is responsible for its lifecycle.
874
+ * @param schema - The root schema for the document.
875
+ * @param binding - Optional SchemaBinding for identity-keyed containers.
876
+ */
877
+ function createYjsSubstrate(doc, schema, binding) {
878
+ const pendingChanges = [];
879
+ let inOurTransaction = false;
880
+ let pendingMergeOrigin;
881
+ let cachedCtx;
882
+ let accumulatedDs = Y.createDeleteSetFromStructStore(doc.store);
883
+ const rootMap = doc.getMap("root");
884
+ const reader = yjsReader(doc, schema, binding);
885
+ const substrate = {
886
+ [BACKING_DOC]: doc,
887
+ reader,
888
+ prepare(path, change) {
889
+ if (!inOurTransaction) pendingChanges.push({
890
+ path,
891
+ change
892
+ });
893
+ },
894
+ onFlush(_origin) {
895
+ if (!inOurTransaction && pendingChanges.length > 0) {
896
+ inOurTransaction = true;
897
+ try {
898
+ doc.transact(() => {
899
+ for (const { path, change } of pendingChanges) applyChangeToYjs(rootMap, schema, path, change, binding);
900
+ }, KYNETA_ORIGIN);
901
+ pendingChanges.length = 0;
902
+ } finally {
903
+ inOurTransaction = false;
904
+ }
905
+ }
906
+ },
907
+ context() {
908
+ if (!cachedCtx) {
909
+ cachedCtx = buildWritableContext(substrate);
910
+ cachedCtx.nativeResolver = (nodeSchema, path) => {
911
+ if (path.segments.length === 0) return doc;
912
+ if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum") return void 0;
913
+ return resolveYjsType(rootMap, schema, path, binding).resolved;
914
+ };
915
+ cachedCtx.positionResolver = (_nodeSchema, path) => {
916
+ return {
917
+ createPosition(index, side) {
918
+ const { resolved: ytype } = resolveYjsType(rootMap, schema, path, binding);
919
+ if (!(ytype instanceof Y.Text)) throw new Error(`positionResolver: path does not resolve to a Y.Text`);
920
+ const assoc = toYjsAssoc(side);
921
+ return new YjsPosition(Y.createRelativePositionFromTypeIndex(ytype, index, assoc), doc);
922
+ },
923
+ decodePosition(bytes) {
924
+ return new YjsPosition(Y.decodeRelativePosition(bytes), doc);
925
+ }
926
+ };
927
+ };
928
+ }
929
+ return cachedCtx;
930
+ },
931
+ version() {
932
+ return YjsVersion.fromDeleteSet(doc, accumulatedDs);
933
+ },
934
+ baseVersion() {
935
+ return new YjsVersion(new Uint8Array([0]));
936
+ },
937
+ advance(_to) {
938
+ throw new Error("advance() on a live Yjs substrate is not yet supported. Use advance() on a YjsReplica instead.");
939
+ },
940
+ exportEntirety() {
941
+ return {
942
+ kind: "entirety",
943
+ encoding: "binary",
944
+ data: Y.encodeStateAsUpdate(doc)
945
+ };
946
+ },
947
+ exportSince(since) {
948
+ try {
949
+ return {
950
+ kind: "since",
951
+ encoding: "binary",
952
+ data: Y.encodeStateAsUpdate(doc, since.sv)
953
+ };
954
+ } catch {
955
+ return null;
956
+ }
957
+ },
958
+ merge(payload, origin) {
959
+ 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.");
960
+ pendingMergeOrigin = origin;
961
+ try {
962
+ Y.applyUpdate(doc, payload.data, origin ?? "remote");
963
+ } finally {
964
+ pendingMergeOrigin = void 0;
965
+ }
966
+ }
967
+ };
968
+ rootMap.observeDeep((events, transaction) => {
969
+ if (transaction.origin === KYNETA_ORIGIN) return;
970
+ const ops = eventsToOps(events, schema, binding);
971
+ if (ops.length === 0) return;
972
+ if (transaction.deleteSet.clients.size > 0) accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet]);
973
+ const origin = pendingMergeOrigin ?? (typeof transaction.origin === "string" ? transaction.origin : void 0);
974
+ const ctx = substrate.context();
975
+ inOurTransaction = true;
976
+ try {
977
+ executeBatch(ctx, ops, origin);
978
+ } finally {
979
+ inOurTransaction = false;
980
+ }
981
+ });
982
+ doc.on("afterTransaction", (transaction) => {
983
+ if (transaction.origin === KYNETA_ORIGIN && transaction.deleteSet.clients.size > 0) accumulatedDs = Y.mergeDeleteSets([accumulatedDs, transaction.deleteSet]);
984
+ });
985
+ return substrate;
986
+ }
987
+ /**
988
+ * Factory for constructing Yjs-backed substrates.
989
+ *
990
+ * - `create(schema)` — creates a fresh Y.Doc with empty containers
991
+ * matching the schema structure. No seed data — initial content
992
+ * should be applied via `change()` after construction.
993
+ * - `fromEntirety(payload, schema)` — creates a Y.Doc from an entirety
994
+ * payload, returns a substrate.
995
+ * - `parseVersion(serialized)` — deserializes a YjsVersion.
996
+ *
997
+ * Uses trivialBinding for identity-keying: every path maps to
998
+ * `deriveIdentity(path, 1)` (generation 1, no renames).
999
+ */
1000
+ /**
1001
+ * Compute a trivial SchemaBinding for a schema with no migration history.
1002
+ * Every product field maps to `deriveIdentity(path, 1)`.
1003
+ */
1004
+ function trivialBinding(schema) {
1005
+ if (schema[KIND] === "product") return deriveSchemaBinding(schema, {});
1006
+ return {
1007
+ forward: /* @__PURE__ */ new Map(),
1008
+ inverse: /* @__PURE__ */ new Map()
1009
+ };
715
1010
  }
1011
+ /**
1012
+ * Schema-free replica factory for Yjs substrates.
1013
+ *
1014
+ * Constructs headless `Replica<YjsVersion>` instances backed by bare
1015
+ * `Y.Doc`s — no schema walking, no container initialization, no
1016
+ * Reader, no event bridge, no changefeed. Just the CRDT runtime
1017
+ * with version tracking and export/merge.
1018
+ *
1019
+ * Used by conduit participants (stores, routing servers)
1020
+ * that need to accumulate state, compute per-peer deltas, and compact
1021
+ * storage without ever interpreting document fields.
1022
+ */
716
1023
  function createYjsReplica(doc) {
717
- let currentDoc = doc;
718
- let currentBase = new YjsVersion(Y5.encodeStateVector(new Y5.Doc()));
719
- return {
720
- get [BACKING_DOC]() {
721
- return currentDoc;
722
- },
723
- version() {
724
- return new YjsVersion(Y5.encodeStateVector(currentDoc));
725
- },
726
- baseVersion() {
727
- return currentBase;
728
- },
729
- advance(to) {
730
- const baseCmp = currentBase.compare(to);
731
- if (baseCmp === "ahead") {
732
- throw new Error("advance(): target is behind base version");
733
- }
734
- const currentCmp = to.compare(this.version());
735
- if (currentCmp === "ahead") {
736
- throw new Error("advance(): target is ahead of current version");
737
- }
738
- if (currentCmp !== "equal") return;
739
- const update = Y5.encodeStateAsUpdate(currentDoc);
740
- const newDoc = new Y5.Doc();
741
- Y5.applyUpdate(newDoc, update);
742
- currentDoc = newDoc;
743
- currentBase = new YjsVersion(Y5.encodeStateVector(currentDoc));
744
- },
745
- exportEntirety() {
746
- return {
747
- kind: "entirety",
748
- encoding: "binary",
749
- data: Y5.encodeStateAsUpdate(currentDoc)
750
- };
751
- },
752
- exportSince(since) {
753
- try {
754
- const bytes = Y5.encodeStateAsUpdate(currentDoc, since.sv);
755
- return { kind: "since", encoding: "binary", data: bytes };
756
- } catch {
757
- return null;
758
- }
759
- },
760
- merge(payload, _origin) {
761
- if (payload.encoding !== "binary" || !(payload.data instanceof Uint8Array)) {
762
- throw new Error(
763
- "YjsReplica.merge expects binary-encoded payloads. If you recently switched CRDT backends, stale clients may be sending incompatible data."
764
- );
765
- }
766
- Y5.applyUpdate(currentDoc, payload.data);
767
- }
768
- };
769
- }
770
- var yjsReplicaFactory = {
771
- replicaType: ["yjs", 1, 0],
772
- createEmpty() {
773
- return createYjsReplica(new Y5.Doc());
774
- },
775
- fromEntirety(payload) {
776
- if (payload.encoding !== "binary" || !(payload.data instanceof Uint8Array)) {
777
- throw new Error(
778
- "YjsReplicaFactory.fromEntirety only supports binary-encoded payloads"
779
- );
780
- }
781
- const doc = new Y5.Doc();
782
- Y5.applyUpdate(doc, payload.data);
783
- return createYjsReplica(doc);
784
- },
785
- parseVersion(serialized) {
786
- return YjsVersion.parse(serialized);
787
- }
1024
+ let currentDoc = doc;
1025
+ let currentBase = new YjsVersion(Y.encodeStateVector(new Y.Doc()));
1026
+ return {
1027
+ get [BACKING_DOC]() {
1028
+ return currentDoc;
1029
+ },
1030
+ version() {
1031
+ return YjsVersion.fromDoc(currentDoc);
1032
+ },
1033
+ baseVersion() {
1034
+ return currentBase;
1035
+ },
1036
+ advance(to) {
1037
+ if (currentBase.compare(to) === "ahead") throw new Error("advance(): target is behind base version");
1038
+ const currentCmp = to.compare(this.version());
1039
+ if (currentCmp === "ahead") throw new Error("advance(): target is ahead of current version");
1040
+ if (currentCmp !== "equal") return;
1041
+ const update = Y.encodeStateAsUpdate(currentDoc);
1042
+ const newDoc = new Y.Doc();
1043
+ Y.applyUpdate(newDoc, update);
1044
+ currentDoc = newDoc;
1045
+ currentBase = YjsVersion.fromDoc(currentDoc);
1046
+ },
1047
+ exportEntirety() {
1048
+ return {
1049
+ kind: "entirety",
1050
+ encoding: "binary",
1051
+ data: Y.encodeStateAsUpdate(currentDoc)
1052
+ };
1053
+ },
1054
+ exportSince(since) {
1055
+ try {
1056
+ return {
1057
+ kind: "since",
1058
+ encoding: "binary",
1059
+ data: Y.encodeStateAsUpdate(currentDoc, since.sv)
1060
+ };
1061
+ } catch {
1062
+ return null;
1063
+ }
1064
+ },
1065
+ merge(payload, _origin) {
1066
+ 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.");
1067
+ Y.applyUpdate(currentDoc, payload.data);
1068
+ }
1069
+ };
1070
+ }
1071
+ const yjsReplicaFactory = {
1072
+ replicaType: [
1073
+ "yjs",
1074
+ 1,
1075
+ 0
1076
+ ],
1077
+ createEmpty() {
1078
+ return createYjsReplica(new Y.Doc());
1079
+ },
1080
+ fromEntirety(payload) {
1081
+ if (payload.encoding !== "binary" || !(payload.data instanceof Uint8Array)) throw new Error("YjsReplicaFactory.fromEntirety only supports binary-encoded payloads");
1082
+ const doc = new Y.Doc();
1083
+ Y.applyUpdate(doc, payload.data);
1084
+ return createYjsReplica(doc);
1085
+ },
1086
+ parseVersion(serialized) {
1087
+ return YjsVersion.parse(serialized);
1088
+ }
788
1089
  };
789
- var yjsSubstrateFactory = {
790
- replica: yjsReplicaFactory,
791
- createReplica() {
792
- return createYjsReplica(new Y5.Doc());
793
- },
794
- upgrade(replica, schema) {
795
- const doc = replica[BACKING_DOC];
796
- ensureContainers(doc, schema, true);
797
- return createYjsSubstrate(doc, schema);
798
- },
799
- create(schema) {
800
- const doc = new Y5.Doc();
801
- ensureContainers(doc, schema);
802
- return createYjsSubstrate(doc, schema);
803
- },
804
- fromEntirety(payload, schema) {
805
- const replica = this.createReplica();
806
- replica.merge(payload);
807
- return this.upgrade(replica, schema);
808
- },
809
- parseVersion(serialized) {
810
- return YjsVersion.parse(serialized);
811
- }
1090
+ const yjsSubstrateFactory = {
1091
+ replica: yjsReplicaFactory,
1092
+ createReplica() {
1093
+ return createYjsReplica(new Y.Doc());
1094
+ },
1095
+ upgrade(replica, schema) {
1096
+ const doc = replica[BACKING_DOC];
1097
+ const binding = trivialBinding(schema);
1098
+ ensureContainers(doc, schema, true, binding);
1099
+ return createYjsSubstrate(doc, schema, binding);
1100
+ },
1101
+ create(schema) {
1102
+ const doc = new Y.Doc();
1103
+ const binding = trivialBinding(schema);
1104
+ ensureContainers(doc, schema, false, binding);
1105
+ return createYjsSubstrate(doc, schema, binding);
1106
+ },
1107
+ fromEntirety(payload, schema) {
1108
+ const replica = this.createReplica();
1109
+ replica.merge(payload);
1110
+ return this.upgrade(replica, schema);
1111
+ },
1112
+ parseVersion(serialized) {
1113
+ return YjsVersion.parse(serialized);
1114
+ }
812
1115
  };
813
-
814
- // src/bind-yjs.ts
1116
+ //#endregion
1117
+ //#region src/bind-yjs.ts
1118
+ /**
1119
+ * Hash a string peerId to a deterministic numeric Yjs clientID.
1120
+ *
1121
+ * Yjs clientIDs are unsigned 32-bit integers. We use FNV-1a hash to
1122
+ * produce a deterministic uint32 from the string peerId.
1123
+ *
1124
+ * The hash is deterministic: the same string always produces the same
1125
+ * numeric clientID, across restarts and across machines.
1126
+ */
815
1127
  function hashPeerId(peerId) {
816
- let hash = 2166136261;
817
- for (let i = 0; i < peerId.length; i++) {
818
- hash ^= peerId.charCodeAt(i);
819
- hash = Math.imul(hash, 16777619);
820
- }
821
- const result = hash >>> 0;
822
- return result === STRUCTURAL_YJS_CLIENT_ID2 ? 1 : result;
823
- }
824
- function createYjsFactory(peerId) {
825
- const numericClientId = hashPeerId(peerId);
826
- return {
827
- replica: yjsReplicaFactory,
828
- createReplica() {
829
- return createYjsReplica(new Y6.Doc());
830
- },
831
- upgrade(replica, schema) {
832
- const doc = replica[BACKING_DOC2];
833
- doc.clientID = numericClientId;
834
- ensureContainers(doc, schema, true);
835
- return createYjsSubstrate(doc, schema);
836
- },
837
- create(schema) {
838
- const doc = new Y6.Doc();
839
- doc.clientID = numericClientId;
840
- ensureContainers(doc, schema);
841
- return createYjsSubstrate(doc, schema);
842
- },
843
- fromEntirety(payload, schema) {
844
- const replica = this.createReplica();
845
- replica.merge(payload);
846
- return this.upgrade(replica, schema);
847
- },
848
- parseVersion(serialized) {
849
- return YjsVersion.parse(serialized);
850
- }
851
- };
852
- }
853
- var yjs = createSubstrateNamespace({
854
- strategies: {
855
- collaborative: {
856
- factory: (ctx) => createYjsFactory(ctx.peerId),
857
- replicaFactory: yjsReplicaFactory
858
- },
859
- ephemeral: {
860
- factory: (ctx) => createYjsFactory(ctx.peerId),
861
- replicaFactory: yjsReplicaFactory
862
- }
863
- },
864
- defaultStrategy: "collaborative"
1128
+ let hash = 2166136261;
1129
+ for (let i = 0; i < peerId.length; i++) {
1130
+ hash ^= peerId.charCodeAt(i);
1131
+ hash = Math.imul(hash, 16777619);
1132
+ }
1133
+ const result = hash >>> 0;
1134
+ return result === STRUCTURAL_YJS_CLIENT_ID ? 1 : result;
1135
+ }
1136
+ /**
1137
+ * Create a SubstrateFactory<YjsVersion> that sets doc.clientID
1138
+ * on every new Y.Doc with a deterministic uint32 clientID derived
1139
+ * from the exchange's string peerId.
1140
+ */
1141
+ function createYjsFactory(peerId, binding) {
1142
+ const numericClientId = hashPeerId(peerId);
1143
+ return {
1144
+ replica: yjsReplicaFactory,
1145
+ createReplica() {
1146
+ return createYjsReplica(new Y.Doc());
1147
+ },
1148
+ upgrade(replica, schema) {
1149
+ const doc = replica[BACKING_DOC];
1150
+ doc.clientID = numericClientId;
1151
+ ensureContainers(doc, schema, true, binding);
1152
+ return createYjsSubstrate(doc, schema, binding);
1153
+ },
1154
+ create(schema) {
1155
+ const doc = new Y.Doc();
1156
+ doc.clientID = numericClientId;
1157
+ ensureContainers(doc, schema, false, binding);
1158
+ return createYjsSubstrate(doc, schema, binding);
1159
+ },
1160
+ fromEntirety(payload, schema) {
1161
+ const replica = this.createReplica();
1162
+ replica.merge(payload);
1163
+ return this.upgrade(replica, schema);
1164
+ },
1165
+ parseVersion(serialized) {
1166
+ return YjsVersion.parse(serialized);
1167
+ }
1168
+ };
1169
+ }
1170
+ /**
1171
+ * The Yjs CRDT binding target.
1172
+ *
1173
+ * - `yjs.bind(schema)` — bind a schema to Yjs with collaborative sync
1174
+ * - `yjs.replica()` — create a collaborative replica
1175
+ *
1176
+ * Laws are constrained to `YjsLaws` — schemas requiring composition laws
1177
+ * outside this set (e.g. `"additive"` from `Schema.counter()`,
1178
+ * `"positional-ot-move"` from `Schema.movableList()`) are rejected at
1179
+ * compile time.
1180
+ *
1181
+ * To access the underlying Y.Doc, use `unwrap(ref)` from `@kyneta/schema`.
1182
+ */
1183
+ const yjs = createBindingTarget({
1184
+ factory: (ctx) => createYjsFactory(ctx.peerId, ctx.binding),
1185
+ replicaFactory: yjsReplicaFactory,
1186
+ syncProtocol: SYNC_COLLABORATIVE
865
1187
  });
866
- export {
867
- NATIVE,
868
- Schema,
869
- YjsVersion,
870
- applyChangeToYjs,
871
- applyChanges,
872
- change,
873
- createDoc,
874
- createRef,
875
- createYjsSubstrate,
876
- ensureContainers,
877
- eventsToOps,
878
- exportEntirety,
879
- exportSince,
880
- merge,
881
- resolveYjsType,
882
- stepIntoYjs,
883
- subscribe,
884
- subscribeNode,
885
- unwrap,
886
- version,
887
- yjs,
888
- yjsReader,
889
- yjsReplicaFactory,
890
- yjsSubstrateFactory
891
- };
1188
+ //#endregion
1189
+ 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 };
1190
+
892
1191
  //# sourceMappingURL=index.js.map