@jxsuite/collab 0.11.9 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/collab",
3
- "version": "0.11.9",
3
+ "version": "0.12.0",
4
4
  "description": "Realtime co-editing for Jx Studio — Y.Doc schema, op bridge, structural differ, wire envelope, and provider contracts shared by every collab backend",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,7 +35,7 @@
35
35
  "test:coverage": "bun test --isolate --coverage"
36
36
  },
37
37
  "dependencies": {
38
- "@jxsuite/schema": "^5.5.0",
38
+ "@jxsuite/schema": "^6.0.0",
39
39
  "lib0": "^0.2.119",
40
40
  "y-protocols": "^1.0.7",
41
41
  "yjs": "13.6.33"
package/src/diff.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  */
11
11
 
12
12
  import { cloneValue } from "@jxsuite/schema/doc-ops";
13
+ import { isJsonObject } from "@jxsuite/schema/guards";
13
14
  import type { JxMutableNode } from "@jxsuite/schema/types";
14
- import { metaMap, structureMap, toYChildren } from "./schema.ts";
15
+ import { metaMap, structureMap, TEXT_OBJECT_KEYS, toYChildren, toYTextObject } from "./schema.ts";
15
16
  import type * as Y from "yjs";
16
17
 
17
18
  export * from "./diff-core.ts";
@@ -36,6 +37,9 @@ export function replaceYStructure(doc: Y.Doc, document: JxMutableNode, origin: s
36
37
  }
37
38
  if (key === "children" && Array.isArray(value)) {
38
39
  structure.set(key, toYChildren(value));
40
+ } else if (TEXT_OBJECT_KEYS.has(key) && isJsonObject(value)) {
41
+ // The scripts exist only on the root: left plain here, they would never be texts again.
42
+ structure.set(key, toYTextObject(value));
39
43
  } else {
40
44
  structure.set(key, cloneValue(value));
41
45
  }
package/src/op-bridge.ts CHANGED
@@ -20,11 +20,14 @@ import {
20
20
  applyTextEdit,
21
21
  GRANULAR_OBJECT_KEYS,
22
22
  mergeYObject,
23
+ mergeYTextObject,
23
24
  resolveYPath,
24
25
  TEXT_KEYS,
26
+ TEXT_OBJECT_KEYS,
25
27
  toYChild,
26
28
  toYChildren,
27
29
  toYObject,
30
+ toYTextObject,
28
31
  yChildToJson,
29
32
  yValueToJson,
30
33
  } from "./schema.ts";
@@ -103,6 +106,14 @@ function setNodeKey(node: Y.Map<YStructureValue>, key: string, value: JxDocument
103
106
  }
104
107
  return;
105
108
  }
109
+ if (TEXT_OBJECT_KEYS.has(key) && isJsonObject(value)) {
110
+ if (existing instanceof Y.Map) {
111
+ mergeYTextObject(existing, value);
112
+ } else {
113
+ node.set(key, toYTextObject(value));
114
+ }
115
+ return;
116
+ }
106
117
  node.set(key, cloneValue(value));
107
118
  }
108
119
 
@@ -208,7 +219,7 @@ function granularOwner(path: JxPath): { nodePath: JxPath; key: string } | null {
208
219
  if (!isString(segment)) {
209
220
  continue;
210
221
  }
211
- if (GRANULAR_OBJECT_KEYS.has(segment)) {
222
+ if (GRANULAR_OBJECT_KEYS.has(segment) || TEXT_OBJECT_KEYS.has(segment)) {
212
223
  return { key: segment, nodePath: path.slice(0, i) };
213
224
  }
214
225
  if (TEXT_KEYS.has(segment)) {
package/src/schema.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  *
12
12
  * **CRDT granularity is deliberately FINER than Studio's op-log granularity.** Every node is a
13
13
  * Y.Map; within it, "children" is a Y.Array, {@link TEXT_KEYS} are `Y.Text`,
14
- * {@link GRANULAR_OBJECT_KEYS} are nested Y.Maps, and everything else is a whole-JSON LWW value.
14
+ * {@link GRANULAR_OBJECT_KEYS} are nested Y.Maps, {@link TEXT_OBJECT_KEYS} are a Y.Map of `Y.Text`,
15
+ * and everything else is a whole-JSON LWW value.
15
16
  *
16
17
  * The two granularities used to match, on the reasoning that mutators emit whole-value set-keys so
17
18
  * deeper Y types would add merge surface nothing targets. That reasoning was wrong: it conflated
@@ -46,6 +47,11 @@ export const SOURCE_KEY = "source";
46
47
  * first edit to a given key, since the bridge replaces a non-granular container. Two clients
47
48
  * running DIFFERENT versions in one room would disagree about merge granularity — see §5 of
48
49
  * specs/collab.md on version skew, which this does not attempt to solve.
50
+ *
51
+ * Not bumped when a document's script texts moved to `Y.Text` ({@link TEXT_OBJECT_KEYS}): a client
52
+ * from before reads the texts as the strings they are and writes `$script` as one value, which a
53
+ * client from after reads as it is and turns back into texts on its next edit. Neither misreads the
54
+ * other; they merge per script rather than per character, which is what both did before.
49
55
  */
50
56
  export const COLLAB_SCHEMA_VERSION = 2;
51
57
 
@@ -122,6 +128,17 @@ export const TEXT_KEYS: ReadonlySet<string> = new Set(["textContent"]);
122
128
  */
123
129
  export const GRANULAR_OBJECT_KEYS: ReadonlySet<string> = new Set(["style", "attributes", "$props"]);
124
130
 
131
+ /**
132
+ * Node keys stored as a nested `Y.Map` whose STRING values are each a `Y.Text`.
133
+ *
134
+ * `$script` is a document's own modules (`spec.md` §23.2): two texts, each of which may run to
135
+ * hundreds of lines and is edited a character at a time. Stored whole, two people typing in one
136
+ * script took turns losing everything the other had typed since the last keystroke that landed. The
137
+ * key space is granular as well, so one person writing the client module and another the server
138
+ * module are not in each other's way at all.
139
+ */
140
+ export const TEXT_OBJECT_KEYS: ReadonlySet<string> = new Set(["$script"]);
141
+
125
142
  /**
126
143
  * Rewrite a `Y.Text` to `next` with a minimal edit (common prefix/suffix trimmed).
127
144
  *
@@ -196,6 +213,48 @@ export function mergeYObject(map: Y.Map<YStructureValue>, value: DocumentRecord)
196
213
  }
197
214
  }
198
215
 
216
+ /** A plain object becomes a Y.Map whose string values are each a Y.Text; other values stay plain. */
217
+ export function toYTextObject(value: DocumentRecord): Y.Map<YStructureValue> {
218
+ const map = new Y.Map<YStructureValue>();
219
+ for (const [key, entry] of Object.entries(value)) {
220
+ if (entry !== undefined) {
221
+ map.set(key, isString(entry) ? new Y.Text(entry) : cloneValue(entry));
222
+ }
223
+ }
224
+ return map;
225
+ }
226
+
227
+ /**
228
+ * Merge a plain object into an existing Y.Map of texts: a string onto the `Y.Text` already there as
229
+ * a minimal edit, so the characters a peer is typing into the same text keep their place. Removed
230
+ * keys are deleted, and a value that is no longer a string replaces its text, since there is
231
+ * nothing left to merge with.
232
+ */
233
+ export function mergeYTextObject(map: Y.Map<YStructureValue>, value: DocumentRecord): void {
234
+ // Detached key list: deleting from a Y.Map while iterating its live keys is unsafe.
235
+ // oxlint-disable-next-line unicorn/no-useless-spread -- the copy is the point (mutation during iteration)
236
+ for (const key of [...map.keys()]) {
237
+ if (value[key] === undefined) {
238
+ map.delete(key);
239
+ }
240
+ }
241
+ for (const [key, entry] of Object.entries(value)) {
242
+ if (entry === undefined) {
243
+ continue;
244
+ }
245
+ const existing = map.get(key);
246
+ if (!isString(entry)) {
247
+ if (!map.has(key) || !deepEqualJson(existing, entry)) {
248
+ map.set(key, cloneValue(entry));
249
+ }
250
+ } else if (existing instanceof Y.Text) {
251
+ applyTextEdit(existing, entry);
252
+ } else {
253
+ map.set(key, new Y.Text(entry));
254
+ }
255
+ }
256
+ }
257
+
199
258
  /** Structural equality over plain JSON, used to skip no-op key writes. */
200
259
  function deepEqualJson(a: YStructureValue | undefined, b: JxDocumentValue): boolean {
201
260
  if (a === b) {
@@ -245,6 +304,8 @@ export function toYNode(node: JxDocumentValue | undefined): Y.Map<YStructureValu
245
304
  map.set(key, new Y.Text(value));
246
305
  } else if (GRANULAR_OBJECT_KEYS.has(key) && isDocumentRecord(value)) {
247
306
  map.set(key, toYObject(value));
307
+ } else if (TEXT_OBJECT_KEYS.has(key) && isDocumentRecord(value)) {
308
+ map.set(key, toYTextObject(value));
248
309
  } else {
249
310
  map.set(key, cloneValue(value));
250
311
  }
@@ -357,6 +418,9 @@ export function seedStructure(
357
418
  }
358
419
  if (key === "children" && Array.isArray(value)) {
359
420
  structure.set(key, toYChildren(value));
421
+ } else if (TEXT_OBJECT_KEYS.has(key) && isDocumentRecord(value)) {
422
+ // A document's scripts exist only on the root, so this is the one place they are seeded.
423
+ structure.set(key, toYTextObject(value));
360
424
  } else {
361
425
  structure.set(key, cloneValue(value));
362
426
  }