@jxsuite/collab 0.11.10 → 0.12.2
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 +2 -2
- package/src/diff.ts +5 -1
- package/src/op-bridge.ts +12 -1
- package/src/schema.ts +65 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.2",
|
|
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": "^
|
|
38
|
+
"@jxsuite/schema": "^6.0.2",
|
|
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,
|
|
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
|
}
|