@jxsuite/collab 0.5.1 → 0.6.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.5.1",
3
+ "version": "0.6.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": {
@@ -32,7 +32,7 @@
32
32
  "test:coverage": "bun test --isolate --coverage"
33
33
  },
34
34
  "dependencies": {
35
- "@jxsuite/schema": "^1.4.0",
35
+ "@jxsuite/schema": "^1.5.0",
36
36
  "lib0": "^0.2.99",
37
37
  "y-protocols": "^1.0.6",
38
38
  "yjs": "13.6.27"
package/src/op-bridge.ts CHANGED
@@ -14,7 +14,17 @@
14
14
  import * as Y from "yjs";
15
15
  import type { JxPath } from "@jxsuite/schema/types";
16
16
  import type { JxDocOp } from "./ops.ts";
17
- import { resolveYPath, toYChildren, toYNode, yValueToJson } from "./schema.ts";
17
+ import {
18
+ applyTextEdit,
19
+ GRANULAR_OBJECT_KEYS,
20
+ mergeYObject,
21
+ resolveYPath,
22
+ TEXT_KEYS,
23
+ toYChild,
24
+ toYChildren,
25
+ toYObject,
26
+ yValueToJson,
27
+ } from "./schema.ts";
18
28
 
19
29
  /** Origin of Y transactions produced by mirroring the local tab's ops. */
20
30
  export const LOCAL_ORIGIN = "jx-local";
@@ -59,8 +69,47 @@ function cloneJson<T>(value: T): T {
59
69
  return JSON.parse(JSON.stringify(value)) as T;
60
70
  }
61
71
 
62
- function toYChild(node: unknown): unknown {
63
- return typeof node === "string" ? node : toYNode(node);
72
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ /**
77
+ * Write a node key, preserving CRDT identity wherever the schema stores it granularly.
78
+ *
79
+ * Studio's mutators record whole-value `set-key` ops — `mutateUpdateStyle` replaces the entire
80
+ * style object, an inline-edit commit replaces the entire `textContent`. Writing those straight
81
+ * through with `Y.Map.set` mints a fresh Y type each time and throws away whatever a peer
82
+ * concurrently did to the old one, which is exactly how "two people typing in one paragraph" became
83
+ * last-writer-wins.
84
+ *
85
+ * So a whole-value op is diffed down onto the existing structure: text through {@link applyTextEdit}
86
+ * (character-level), granular objects through {@link mergeYObject} (per-property). Only a TYPE
87
+ * change — string text becoming a `$ref`, an object becoming a scalar — falls back to replacement,
88
+ * because then there is no shared structure left to preserve.
89
+ */
90
+ function setNodeKey(node: Y.Map<unknown>, key: string, value: unknown): void {
91
+ const existing = node.get(key);
92
+ if (key === "children" && Array.isArray(value)) {
93
+ node.set(key, toYChildren(value));
94
+ return;
95
+ }
96
+ if (TEXT_KEYS.has(key) && typeof value === "string") {
97
+ if (existing instanceof Y.Text) {
98
+ applyTextEdit(existing, value);
99
+ } else {
100
+ node.set(key, new Y.Text(value));
101
+ }
102
+ return;
103
+ }
104
+ if (GRANULAR_OBJECT_KEYS.has(key) && isPlainObject(value)) {
105
+ if (existing instanceof Y.Map) {
106
+ mergeYObject(existing, value);
107
+ } else {
108
+ node.set(key, toYObject(value));
109
+ }
110
+ return;
111
+ }
112
+ node.set(key, cloneJson(value));
64
113
  }
65
114
 
66
115
  function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
@@ -69,10 +118,8 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
69
118
  const node = requireNode(doc, op.path);
70
119
  if (op.value === undefined) {
71
120
  node.delete(op.key);
72
- } else if (op.key === "children" && Array.isArray(op.value)) {
73
- node.set(op.key, toYChildren(op.value));
74
121
  } else {
75
- node.set(op.key, cloneJson(op.value));
122
+ setNodeKey(node, op.key, op.value);
76
123
  }
77
124
  return;
78
125
  }
@@ -97,6 +144,14 @@ function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
97
144
  if (op.index < 0 || op.index >= children.length) {
98
145
  throw new CollabPathError([...op.parentPath, "children", op.index]);
99
146
  }
147
+ // Text child → text child: edit the existing Y.Text in place. A delete+insert would replace the
148
+ // Whole element and discard a peer's concurrent characters, which is the same clobber
149
+ // `setNodeKey` avoids for `textContent` — bare-string children are the other half of prose.
150
+ const existing = children.get(op.index);
151
+ if (existing instanceof Y.Text && typeof op.node === "string") {
152
+ applyTextEdit(existing, op.node);
153
+ return;
154
+ }
100
155
  children.delete(op.index, 1);
101
156
  children.insert(op.index, [toYChild(op.node)]);
102
157
  return;
@@ -139,6 +194,34 @@ export function applyDocOpsToY(doc: Y.Doc, ops: readonly JxDocOp[], origin: unkn
139
194
  }, origin);
140
195
  }
141
196
 
197
+ /**
198
+ * When `path` addresses a granular container (or something nested inside one), the node that owns
199
+ * it and the key it sits under; otherwise null.
200
+ *
201
+ * The event path is relative to the structure map, so the first segment matching a granular key
202
+ * marks the boundary: everything before it is the node path, and anything after it is interior
203
+ * detail the op log does not model.
204
+ *
205
+ * `children`/index segments need no special handling: they are not granular keys, so the scan
206
+ * passes over them and the returned node path keeps the full prefix — `["children", 0, "style"]`
207
+ * resolves to the style of the node at `children/0`, not of the root.
208
+ */
209
+ function granularOwner(path: JxPath): { nodePath: JxPath; key: string } | null {
210
+ for (let i = 0; i < path.length; i++) {
211
+ const segment = path[i];
212
+ if (typeof segment !== "string") {
213
+ continue;
214
+ }
215
+ if (GRANULAR_OBJECT_KEYS.has(segment)) {
216
+ return { key: segment, nodePath: path.slice(0, i) };
217
+ }
218
+ if (TEXT_KEYS.has(segment)) {
219
+ return { key: segment, nodePath: path.slice(0, i) };
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
142
225
  function isStructurePath(segments: readonly (string | number)[]): segments is JxPath {
143
226
  return segments.every((seg) => typeof seg === "string" || typeof seg === "number");
144
227
  }
@@ -179,6 +262,45 @@ export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] |
179
262
  const ops: JxDocOp[] = [];
180
263
  for (const event of events) {
181
264
  const path = event.path as JxPath;
265
+ /*
266
+ * A remote edit inside a granular container (a `Y.Text` for prose, a nested `Y.Map` for
267
+ * style/attributes/$props) reports its event at the CONTAINER's path, not the node's. Studio's op
268
+ * vocabulary addresses nodes, so those collapse back to one whole-value op for the owning key —
269
+ * the same shape a local mutator would have produced. Granularity buys merge; it deliberately does
270
+ * not leak into the op log, the canvas patcher, or the history ring.
271
+ */
272
+ const granular = granularOwner(path);
273
+ if (granular) {
274
+ const { doc } = event.target as { doc?: Y.Doc | null };
275
+ if (!doc) {
276
+ return null;
277
+ }
278
+ const container = resolveYPath(doc, [...granular.nodePath, granular.key]);
279
+ if (container === undefined) {
280
+ return null;
281
+ }
282
+ ops.push({
283
+ key: granular.key,
284
+ op: "set-key",
285
+ path: granular.nodePath,
286
+ value: yValueToJson(container),
287
+ });
288
+ continue;
289
+ }
290
+ if (event instanceof Y.YTextEvent) {
291
+ // A bare-string child: the text IS the child, so it round-trips as a child replacement.
292
+ const index = path.at(-1);
293
+ if (path.at(-2) === "children" && typeof index === "number") {
294
+ ops.push({
295
+ index,
296
+ node: (event.target as Y.Text).toString(),
297
+ op: "set-child",
298
+ parentPath: path.slice(0, -2),
299
+ });
300
+ continue;
301
+ }
302
+ return null;
303
+ }
182
304
  if (event instanceof Y.YMapEvent) {
183
305
  const target = event.target as Y.Map<unknown>;
184
306
  for (const [key, change] of event.changes.keys) {
package/src/schema.ts CHANGED
@@ -4,15 +4,24 @@
4
4
  * Layout (all top-level types, created on demand): - `meta: Y.Map` — schemaVersion,
5
5
  * structureSeeded, canonical ("structure" | "source"), canonicalRev (representation-flip counter —
6
6
  * distinct from the provider's wire docEpoch), sourceFormat. - `frontmatter: Y.Map` — per-field
7
- * plain-JSON values (LWW per field). - `structure: Y.Map` — the root JxMutableNode. Every node is a
8
- * Y.Map whose ONLY nested Y container is "children" (a Y.Array of node Y.Maps and plain strings);
9
- * every other key holds a plain deep-JSON value merged whole (LWW). The CRDT granularity
10
- * deliberately equals Studio's op-log granularity — mutators emit whole-value set-keys and children
11
- * splices, so deeper Y types would add merge surface nothing can target. - `source: Y.Text` — the
12
- * serialized source. THE authoritative file content: providers persist
7
+ * plain-JSON values (LWW per field). - `structure: Y.Map` — the root JxMutableNode. - `source:
8
+ * Y.Text` — the serialized source. THE authoritative file content: providers persist
13
9
  * `getText("source").toString()` and never parse or serialize Jx documents themselves; the
14
10
  * structure tree is a Studio-side projection kept in sync by the bridge.
15
11
  *
12
+ * **CRDT granularity is deliberately FINER than Studio's op-log granularity.** Every node is a
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.
15
+ *
16
+ * The two granularities used to match, on the reasoning that mutators emit whole-value set-keys so
17
+ * deeper Y types would add merge surface nothing targets. That reasoning was wrong: it conflated
18
+ * what an EDIT addresses with what a MERGE must resolve. Two authors typing in one paragraph, or
19
+ * setting `color` and `padding` on one element, produce whole-value ops that a whole-value CRDT can
20
+ * only resolve by discarding one of them. The bridge therefore diffs a whole-value op down onto the
21
+ * granular representation ({@link applyTextEdit}, {@link mergeYObject}) and {@link yValueToJson}
22
+ * reassembles whole values on the way out — so `JxDocOp`, the canvas patcher and the history ring
23
+ * are unchanged, while concurrent edits actually merge.
24
+ *
16
25
  * Seeding contract: providers seed only `source` (from file bytes). Clients derive `structure` on
17
26
  * first sync via {@link seedStructure}, which is convergence-safe under concurrent seeders because
18
27
  * it only performs whole-key Y.Map sets (per-key LWW picks one seeder's subtrees wholesale) — never
@@ -27,7 +36,16 @@ export const FRONTMATTER_KEY = "frontmatter";
27
36
  export const STRUCTURE_KEY = "structure";
28
37
  export const SOURCE_KEY = "source";
29
38
 
30
- export const COLLAB_SCHEMA_VERSION = 1;
39
+ /**
40
+ * Bumped to 2 when prose moved to `Y.Text` and style/attributes/$props to nested `Y.Map`s.
41
+ *
42
+ * Written into `meta` at seed time and not currently validated on load. A room persisted under 1
43
+ * still READS correctly ({@link yValueToJson} passes plain values through) and self-heals on the
44
+ * first edit to a given key, since the bridge replaces a non-granular container. Two clients
45
+ * running DIFFERENT versions in one room would disagree about merge granularity — see §5 of
46
+ * specs/collab.md on version skew, which this does not attempt to solve.
47
+ */
48
+ export const COLLAB_SCHEMA_VERSION = 2;
31
49
 
32
50
  export function metaMap(doc: Y.Doc): Y.Map<unknown> {
33
51
  return doc.getMap(META_KEY);
@@ -55,17 +73,137 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
55
73
  return typeof value === "object" && value !== null && !Array.isArray(value);
56
74
  }
57
75
 
58
- /** A children array becomes a Y.Array of node Y.Maps and plain strings. */
76
+ /**
77
+ * Node keys whose STRING value is stored as a `Y.Text` rather than an opaque string.
78
+ *
79
+ * This is the character-level-collaboration decision. Whole-paragraph last-writer-wins was never a
80
+ * Yjs limitation — it was this schema storing prose as a plain LWW value, so two people typing in
81
+ * one paragraph clobbered each other and only `source` (which the canvas does not edit) merged per
82
+ * character.
83
+ *
84
+ * `innerHTML` is deliberately absent: it is an opaque markup blob, never caret-edited.
85
+ */
86
+ export const TEXT_KEYS: ReadonlySet<string> = new Set(["textContent"]);
87
+
88
+ /**
89
+ * Node keys stored as a nested `Y.Map` so concurrent edits to DIFFERENT properties both survive.
90
+ *
91
+ * `mutateUpdateStyle` and friends record a whole-object `set-key`, so writing the object as one LWW
92
+ * value meant A's `color` and B's `padding` raced and one lost. Per-property merge fixes that
93
+ * without touching the op vocabulary: {@link yValueToJson} reassembles the whole object on the way
94
+ * out, so `JxDocOp`, the canvas patcher and the history ring keep seeing whole values.
95
+ *
96
+ * Declaration VALUES stay plain (a CSS value is never partially edited); only the key space is
97
+ * granular. Nesting recurses, which covers `style`'s media (`@--sm`) and pseudo (`&:hover`)
98
+ * blocks.
99
+ */
100
+ export const GRANULAR_OBJECT_KEYS: ReadonlySet<string> = new Set(["style", "attributes", "$props"]);
101
+
102
+ /**
103
+ * Rewrite a `Y.Text` to `next` with a minimal edit (common prefix/suffix trimmed).
104
+ *
105
+ * Minimal rather than replace-all for two reasons: the wire carries only the delta, and — the
106
+ * load-bearing one — the surviving characters keep their identity, so a concurrent remote insert
107
+ * inside the untouched region merges instead of being clobbered. Returns false when already equal.
108
+ */
109
+ export function applyTextEdit(text: Y.Text, next: string): boolean {
110
+ const current = text.toString();
111
+ if (current === next) {
112
+ return false;
113
+ }
114
+ let start = 0;
115
+ const minLen = Math.min(current.length, next.length);
116
+ while (start < minLen && current[start] === next[start]) {
117
+ start += 1;
118
+ }
119
+ let endCurrent = current.length;
120
+ let endNext = next.length;
121
+ while (endCurrent > start && endNext > start && current[endCurrent - 1] === next[endNext - 1]) {
122
+ endCurrent -= 1;
123
+ endNext -= 1;
124
+ }
125
+ if (endCurrent > start) {
126
+ text.delete(start, endCurrent - start);
127
+ }
128
+ if (endNext > start) {
129
+ text.insert(start, next.slice(start, endNext));
130
+ }
131
+ return true;
132
+ }
133
+
134
+ /** A plain object becomes a Y.Map, recursing into nested objects; other values stay plain JSON. */
135
+ export function toYObject(value: Record<string, unknown>): Y.Map<unknown> {
136
+ const map = new Y.Map<unknown>();
137
+ for (const [key, entry] of Object.entries(value)) {
138
+ if (entry === undefined) {
139
+ continue;
140
+ }
141
+ map.set(key, isPlainObject(entry) ? toYObject(entry) : cloneJson(entry));
142
+ }
143
+ return map;
144
+ }
145
+
146
+ /**
147
+ * Merge a plain object into an existing Y.Map per key: set changed keys, delete removed ones,
148
+ * recurse into nested maps. Whole-value replacement would mint a new Y.Map and discard a peer's
149
+ * concurrent edit to an untouched sibling property — the very thing granularity exists to prevent.
150
+ */
151
+ export function mergeYObject(map: Y.Map<unknown>, value: Record<string, unknown>): void {
152
+ // Detached key list: deleting from a Y.Map while iterating its live keys is unsafe.
153
+ // oxlint-disable-next-line unicorn/no-useless-spread -- the copy is the point (mutation during iteration)
154
+ for (const key of [...map.keys()]) {
155
+ if (!(key in value) || value[key] === undefined) {
156
+ map.delete(key);
157
+ }
158
+ }
159
+ for (const [key, entry] of Object.entries(value)) {
160
+ if (entry === undefined) {
161
+ continue;
162
+ }
163
+ const existing = map.get(key);
164
+ if (isPlainObject(entry)) {
165
+ if (existing instanceof Y.Map) {
166
+ mergeYObject(existing, entry);
167
+ } else {
168
+ map.set(key, toYObject(entry));
169
+ }
170
+ } else if (!deepEqualJson(existing, entry)) {
171
+ map.set(key, cloneJson(entry));
172
+ }
173
+ }
174
+ }
175
+
176
+ /** Structural equality over plain JSON, used to skip no-op key writes. */
177
+ function deepEqualJson(a: unknown, b: unknown): boolean {
178
+ if (a === b) {
179
+ return true;
180
+ }
181
+ if (a instanceof Y.Map || a instanceof Y.Array || a instanceof Y.Text) {
182
+ return false;
183
+ }
184
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
185
+ }
186
+
187
+ /** A child value: a bare string becomes a Y.Text (per-character merge), a node becomes a Y.Map. */
188
+ export function toYChild(child: unknown): unknown {
189
+ return typeof child === "string" ? new Y.Text(child) : toYNode(child);
190
+ }
191
+
192
+ /** A children array becomes a Y.Array of node Y.Maps and Y.Texts. */
59
193
  export function toYChildren(children: readonly unknown[]): Y.Array<unknown> {
60
194
  const arr = new Y.Array<unknown>();
61
195
  arr.insert(
62
196
  0,
63
- children.map((child) => (typeof child === "string" ? child : toYNode(child))),
197
+ children.map((child) => toYChild(child)),
64
198
  );
65
199
  return arr;
66
200
  }
67
201
 
68
- /** A node object becomes a Y.Map; only its "children" key nests further Y types. */
202
+ /**
203
+ * A node object becomes a Y.Map. Nesting is no longer limited to `children`: text keys become
204
+ * `Y.Text` and {@link GRANULAR_OBJECT_KEYS} become nested `Y.Map`s, so the CRDT merges at the
205
+ * granularity edits actually happen at. Everything else stays a whole-JSON LWW value.
206
+ */
69
207
  export function toYNode(node: unknown): Y.Map<unknown> {
70
208
  if (!isPlainObject(node)) {
71
209
  throw new TypeError("collab-schema: a document node must be a plain object");
@@ -77,6 +215,10 @@ export function toYNode(node: unknown): Y.Map<unknown> {
77
215
  }
78
216
  if (key === "children" && Array.isArray(value)) {
79
217
  map.set(key, toYChildren(value));
218
+ } else if (TEXT_KEYS.has(key) && typeof value === "string") {
219
+ map.set(key, new Y.Text(value));
220
+ } else if (GRANULAR_OBJECT_KEYS.has(key) && isPlainObject(value)) {
221
+ map.set(key, toYObject(value));
80
222
  } else {
81
223
  map.set(key, cloneJson(value));
82
224
  }
@@ -84,7 +226,7 @@ export function toYNode(node: unknown): Y.Map<unknown> {
84
226
  return map;
85
227
  }
86
228
 
87
- /** Convert any structure value (Y.Map node, Y.Array children, or plain leaf) back to JSON. */
229
+ /** Convert any structure value (Y.Map, Y.Array, Y.Text, or plain leaf) back to JSON. */
88
230
  export function yValueToJson(value: unknown): unknown {
89
231
  if (value instanceof Y.Map) {
90
232
  const out: Record<string, unknown> = {};
@@ -96,6 +238,9 @@ export function yValueToJson(value: unknown): unknown {
96
238
  if (value instanceof Y.Array) {
97
239
  return value.toArray().map((entry) => yValueToJson(entry));
98
240
  }
241
+ if (value instanceof Y.Text) {
242
+ return value.toString();
243
+ }
99
244
  return value;
100
245
  }
101
246
 
@@ -132,28 +277,11 @@ export function resolveYPath(doc: Y.Doc, path: JxPath): unknown {
132
277
  */
133
278
  export function updateSourceText(doc: Y.Doc, next: string, origin?: unknown): boolean {
134
279
  const text = sourceText(doc);
135
- const current = text.toString();
136
- if (current === next) {
280
+ if (text.toString() === next) {
137
281
  return false;
138
282
  }
139
- let start = 0;
140
- const minLen = Math.min(current.length, next.length);
141
- while (start < minLen && current[start] === next[start]) {
142
- start += 1;
143
- }
144
- let endCurrent = current.length;
145
- let endNext = next.length;
146
- while (endCurrent > start && endNext > start && current[endCurrent - 1] === next[endNext - 1]) {
147
- endCurrent -= 1;
148
- endNext -= 1;
149
- }
150
283
  doc.transact(() => {
151
- if (endCurrent > start) {
152
- text.delete(start, endCurrent - start);
153
- }
154
- if (endNext > start) {
155
- text.insert(start, next.slice(start, endNext));
156
- }
284
+ applyTextEdit(text, next);
157
285
  }, origin);
158
286
  return true;
159
287
  }