@jxsuite/collab 0.8.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -44
- package/package.json +3 -2
- package/src/diff-core.ts +290 -0
- package/src/diff.ts +9 -239
package/README.md
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
# @jxsuite/collab
|
|
2
2
|
|
|
3
|
-
Realtime co-editing for Jx Studio: the shared Y.Doc schema for a Jx document, the bridge between
|
|
4
|
-
Studio's transactional op-log and Yjs, a structural differ, the binary wire envelope every collab
|
|
5
|
-
backend speaks, and the provider contract Studio consumes through its Platform Abstraction Layer.
|
|
3
|
+
Realtime co-editing for Jx Studio: the shared Y.Doc schema for a Jx document, the bridge between Studio's transactional op-log and Yjs, a structural differ, the binary wire envelope every collab backend speaks, and the provider contract Studio consumes through its Platform Abstraction Layer.
|
|
6
4
|
|
|
7
|
-
Everything here is environment-agnostic TypeScript (no DOM, no Node/Bun APIs): the same modules run
|
|
8
|
-
in the browser, in the `@jxsuite/server` dev server, and on Cloudflare Workers.
|
|
5
|
+
Everything here is environment-agnostic TypeScript (no DOM, no Node/Bun APIs): the same modules run in the browser, in the `@jxsuite/server` dev server, and on Cloudflare Workers.
|
|
9
6
|
|
|
10
7
|
## The document model
|
|
11
8
|
|
|
@@ -18,28 +15,16 @@ One `Y.Doc` per open file:
|
|
|
18
15
|
| `frontmatter: Y.Map` | Per-field plain-JSON values. |
|
|
19
16
|
| `meta: Y.Map` | `schemaVersion`, `structureSeeded`, `canonical`, `canonicalRev`, `sourceFormat`. |
|
|
20
17
|
|
|
21
|
-
The CRDT granularity deliberately equals Studio's op-log granularity: mutators emit whole-value
|
|
22
|
-
`set-key`s and children splices, so those are the only merge points.
|
|
18
|
+
The CRDT granularity deliberately equals Studio's op-log granularity: mutators emit whole-value `set-key`s and children splices, so those are the only merge points.
|
|
23
19
|
|
|
24
|
-
**Seeding contract.** Providers seed only `source` from file bytes; they never parse or serialize
|
|
25
|
-
Jx documents. Clients derive `structure` on first sync via `seedStructure()`, which is safe under
|
|
26
|
-
concurrent seeders because it only performs whole-key `Y.Map` sets (per-key LWW keeps one seeder's
|
|
27
|
-
subtrees wholesale; it never uses array inserts, which would duplicate). A client's `Y.Doc` must
|
|
28
|
-
start empty; never seed locally before the provider's sync completes.
|
|
20
|
+
**Seeding contract.** Providers seed only `source` from file bytes; they never parse or serialize Jx documents. Clients derive `structure` on first sync via `seedStructure()`, which is safe under concurrent seeders because it only performs whole-key `Y.Map` sets (per-key LWW keeps one seeder's subtrees wholesale; it never uses array inserts, which would duplicate). A client's `Y.Doc` must start empty; never seed locally before the provider's sync completes.
|
|
29
21
|
|
|
30
22
|
## The op bridge
|
|
31
23
|
|
|
32
|
-
- `applyDocOpsToY(doc, ops, origin)` replays Studio's recorded forward `JxDocOp`s onto the Y tree
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
- `
|
|
36
|
-
Call it **inside** the `observeDeep` callback. It returns `null` for shapes it cannot convert
|
|
37
|
-
safely; reconcile by diffing instead.
|
|
38
|
-
- `diffDocs(a, b)` produces ops transforming `a` into `b` (invariant: `apply(clone(a), diff(a,b))
|
|
39
|
-
≡ b`); returns `null` past `maxOps`, in which case `replaceYStructure()` hard-replaces and bumps
|
|
40
|
-
`meta.canonicalRev` so stale mirror writes are discarded.
|
|
41
|
-
- `applyDocOpToDoc` (in `./ops`, yjs-free) is the canonical plain-JSON applier shared with
|
|
42
|
-
Studio's history replay and canvas shadow doc.
|
|
24
|
+
- `applyDocOpsToY(doc, ops, origin)` replays Studio's recorded forward `JxDocOp`s onto the Y tree in one origin-tagged transaction (`LOCAL_ORIGIN` for user edits, `MIRROR_ORIGIN` for derived writes, `SEED_ORIGIN` for bootstraps).
|
|
25
|
+
- `yEventsToDocOps(events)` converts a remote transaction's deep events back into `JxDocOp`s. Call it **inside** the `observeDeep` callback. It returns `null` for shapes it cannot convert safely; reconcile by diffing instead.
|
|
26
|
+
- `diffDocs(a, b)` produces ops transforming `a` into `b` (invariant: `apply(clone(a), diff(a,b)) ≡ b`); returns `null` past `maxOps`, in which case `replaceYStructure()` hard-replaces and bumps `meta.canonicalRev` so stale mirror writes are discarded.
|
|
27
|
+
- `applyDocOpToDoc` (in `./ops`, yjs-free) is the canonical plain-JSON applier shared with Studio's history replay and canvas shadow doc.
|
|
43
28
|
|
|
44
29
|
## The wire envelope (`./envelope`)
|
|
45
30
|
|
|
@@ -53,34 +38,18 @@ frame := varUint frameType, body
|
|
|
53
38
|
3 CONTROL := varString json — hello | open | opened | doc-reset | flush | flush-ack | error
|
|
54
39
|
```
|
|
55
40
|
|
|
56
|
-
`docEpoch` is the server-owned lifetime counter of a doc's Y history. **Y history is never deleted
|
|
57
|
-
or replaced without an epoch bump**: out-of-band content replacement (git discard/pull/rename,
|
|
58
|
-
non-collab writes) bumps it and broadcasts `doc-reset`; clients destroy their `Y.Doc` and re-open.
|
|
59
|
-
Stale-epoch frames are dropped. This invariant is what prevents divergent-history duplicate-content
|
|
60
|
-
merges.
|
|
41
|
+
`docEpoch` is the server-owned lifetime counter of a doc's Y history. **Y history is never deleted or replaced without an epoch bump**: out-of-band content replacement (git discard/pull/rename, non-collab writes) bumps it and broadcasts `doc-reset`; clients destroy their `Y.Doc` and re-open. Stale-epoch frames are dropped. This invariant is what prevents divergent-history duplicate-content merges.
|
|
61
42
|
|
|
62
|
-
Session flow: connect → server sends `hello{login, name?, avatarUrl?, color, permission}` → client
|
|
63
|
-
sends `open{path}` per document → server ensures the room (loads persisted Y state or seeds
|
|
64
|
-
`source` from the file) → `opened{path, epoch}` → y-protocols sync handshake → updates flow.
|
|
65
|
-
Read-only sockets receive sync and may publish awareness, but their doc updates are dropped with
|
|
66
|
-
one `error{read-only}`.
|
|
43
|
+
Session flow: connect → server sends `hello{login, name?, avatarUrl?, color, permission}` → client sends `open{path}` per document → server ensures the room (loads persisted Y state or seeds `source` from the file) → `opened{path, epoch}` → y-protocols sync handshake → updates flow. Read-only sockets receive sync and may publish awareness, but their doc updates are dropped with one `error{read-only}`.
|
|
67
44
|
|
|
68
45
|
## Awareness (`./awareness-types`)
|
|
69
46
|
|
|
70
|
-
One project-level `Awareness` per connection. State shape:
|
|
71
|
-
`{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, structuralSelection?, selection? }`.
|
|
72
|
-
File-tree presence (the plain `selection` field is the code view's in-buffer cursor) and
|
|
73
|
-
per-document cursor overlays both filter the same states. Identity comes from the server `hello`;
|
|
74
|
-
colors are deterministic (`colorForKey`).
|
|
47
|
+
One project-level `Awareness` per connection. State shape: `{ user: {login, name?, avatarUrl?, color}, focusedPath, mode?, structuralSelection?, selection? }`. File-tree presence (the plain `selection` field is the code view's in-buffer cursor) and per-document cursor overlays both filter the same states. Identity comes from the server `hello`; colors are deterministic (`colorForKey`).
|
|
75
48
|
|
|
76
49
|
## Provider contract (`./provider`, type-only)
|
|
77
50
|
|
|
78
|
-
`StudioPlatform.collab?: (docPath) => Promise<CollabHandle | null>` where a handle carries
|
|
79
|
-
`{doc, awareness, whenSynced, identity(), flush(), onStatus(), onReset(), destroy()}`. `onReset`
|
|
80
|
-
fires on a `doc-reset`: the handle is dead, so destroy it and re-acquire.
|
|
51
|
+
`StudioPlatform.collab?: (docPath) => Promise<CollabHandle | null>` where a handle carries `{doc, awareness, whenSynced, identity(), flush(), onStatus(), onReset(), destroy()}`. `onReset` fires on a `doc-reset`: the handle is dead, so destroy it and re-acquire.
|
|
81
52
|
|
|
82
53
|
## Versioning
|
|
83
54
|
|
|
84
|
-
Published to npm as `@jxsuite/collab`, following the monorepo's release train. Like every `@jxsuite`
|
|
85
|
-
package, it ships TypeScript source. Within the monorepo, other packages (`@jxsuite/server`,
|
|
86
|
-
`@jxsuite/studio`) depend on it via `workspace:^`.
|
|
55
|
+
Published to npm as `@jxsuite/collab`, following the monorepo's release train. Like every `@jxsuite` package, it ships TypeScript source. Within the monorepo, other packages (`@jxsuite/server`, `@jxsuite/studio`) depend on it via `workspace:^`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/collab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./schema": "./src/schema.ts",
|
|
19
19
|
"./op-bridge": "./src/op-bridge.ts",
|
|
20
20
|
"./diff": "./src/diff.ts",
|
|
21
|
+
"./diff-core": "./src/diff-core.ts",
|
|
21
22
|
"./envelope": "./src/envelope.ts",
|
|
22
23
|
"./negotiate": "./src/negotiate.ts",
|
|
23
24
|
"./awareness-types": "./src/awareness-types.ts",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"test:coverage": "bun test --isolate --coverage"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@jxsuite/schema": "^2.
|
|
37
|
+
"@jxsuite/schema": "^2.2.0",
|
|
37
38
|
"lib0": "^0.2.117",
|
|
38
39
|
"y-protocols": "^1.0.7",
|
|
39
40
|
"yjs": "13.6.32"
|
package/src/diff-core.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structural differ's pure half: alignment, equality, and `diffDocs`.
|
|
3
|
+
*
|
|
4
|
+
* `diffDocs(a, b)` produces JxDocOps that transform document `a` into `b` when replayed
|
|
5
|
+
* sequentially (property invariant: `apply(clone(a), diffDocs(a, b)) ≡ b`). The collab bridge uses
|
|
6
|
+
* it wherever no op-log exists — whole-document bypass writes (Monaco parse-flush, navigation,
|
|
7
|
+
* reload), un-instrumented transactions, and inbound Y transactions too gnarly for the fast event
|
|
8
|
+
* path.
|
|
9
|
+
*
|
|
10
|
+
* Children alignment: an LCS over strong keys (full-content hashes) pins unchanged nodes as
|
|
11
|
+
* anchors; the gaps between anchors align by a second LCS over weak signatures (tagName +
|
|
12
|
+
* id/$props.key) whose pairs diff recursively. Unmatched items become splices. Reorders of
|
|
13
|
+
* identical siblings therefore degrade to remove+insert — exactly what the Y move mapping does, so
|
|
14
|
+
* no fidelity is lost relative to the wire.
|
|
15
|
+
*
|
|
16
|
+
* **This file has no runtime imports, and that is its reason for existing.** It was the top of
|
|
17
|
+
* `./diff.ts`, which value-imports `./schema.ts` for `replaceYStructure` alone — and `schema.ts`
|
|
18
|
+
* imports yjs. So every consumer of the alignment logic paid for yjs, which put the studio's diff
|
|
19
|
+
* highlighting (`canvas/diff-marks.ts`) behind a lazy import for a computation that never touches a
|
|
20
|
+
* Y type. `diff.ts` re-exports everything here, so nothing that already imported it changed.
|
|
21
|
+
*
|
|
22
|
+
* {@link matchChildren} is exported for that studio caller: `diffDocs` emits a REPLAY SCRIPT whose
|
|
23
|
+
* paths are post-splice coordinates valid only in `b`, and a side-by-side comparison needs both
|
|
24
|
+
* sides' coordinates and the pairing between them. That is what a `ChildMatch` is, and re-deriving
|
|
25
|
+
* it would mean a second definition of "the same node".
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
|
|
29
|
+
import type { JxDocOp } from "./ops.ts";
|
|
30
|
+
|
|
31
|
+
export interface DiffOptions {
|
|
32
|
+
/** Abort (return null) when the diff would exceed this many ops; caller hard-replaces instead. */
|
|
33
|
+
maxOps?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_MAX_OPS = 500;
|
|
37
|
+
|
|
38
|
+
class MaxOpsExceededError extends Error {
|
|
39
|
+
override name = "MaxOpsExceededError";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Order-insensitive for object keys, order-sensitive for arrays. */
|
|
47
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
48
|
+
if (a === b) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
52
|
+
return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
53
|
+
}
|
|
54
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
55
|
+
const keysA = Object.keys(a);
|
|
56
|
+
const keysB = Object.keys(b);
|
|
57
|
+
return (
|
|
58
|
+
keysA.length === keysB.length && keysA.every((key) => key in b && deepEqual(a[key], b[key]))
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function contentHash(text: string): string {
|
|
65
|
+
let hash = 5381;
|
|
66
|
+
for (let i = 0; i < text.length; i++) {
|
|
67
|
+
hash = Math.imul(hash, 33) + (text.codePointAt(i) ?? 0);
|
|
68
|
+
}
|
|
69
|
+
return hash.toString(36);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Full-content identity: equal strong keys mean "treat as the same unchanged child". */
|
|
73
|
+
function strongKey(item: unknown): string {
|
|
74
|
+
return typeof item === "string" ? `s:${item}` : `n:${contentHash(JSON.stringify(item))}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Alignment identity: equal weak keys mean "same logical child, diff its contents". */
|
|
78
|
+
function weakKey(item: unknown): string {
|
|
79
|
+
if (typeof item === "string") {
|
|
80
|
+
return "s";
|
|
81
|
+
}
|
|
82
|
+
const node = item as Record<string, unknown>;
|
|
83
|
+
const attrs = node["attributes"];
|
|
84
|
+
const props = node["$props"];
|
|
85
|
+
const id =
|
|
86
|
+
(isPlainObject(attrs) ? attrs["id"] : undefined) ??
|
|
87
|
+
(isPlainObject(props) ? props["key"] : undefined) ??
|
|
88
|
+
"";
|
|
89
|
+
return `w:${String(node["tagName"] ?? "")}:${String(id)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Longest common subsequence over key arrays; returns strictly-increasing index pairs.
|
|
94
|
+
*
|
|
95
|
+
* `maxCells` bounds the DP TABLE, which is the only unbounded cost in this file. The comment below
|
|
96
|
+
* assumes "typically < 200" children, which a generated listing page falsifies. Over budget this
|
|
97
|
+
* answers "no common subsequence", so the caller degrades that group to plain removals and
|
|
98
|
+
* insertions instead of allocating an n*m table. Unbounded by default, so `diffDocs` is unchanged.
|
|
99
|
+
*/
|
|
100
|
+
function lcsPairs(
|
|
101
|
+
aKeys: readonly string[],
|
|
102
|
+
bKeys: readonly string[],
|
|
103
|
+
maxCells = Number.POSITIVE_INFINITY,
|
|
104
|
+
): [number, number][] {
|
|
105
|
+
const n = aKeys.length;
|
|
106
|
+
const m = bKeys.length;
|
|
107
|
+
if (n === 0 || m === 0 || n * m > maxCells) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
// Classic DP table; children arrays are small (bounded by document size, typically < 200).
|
|
111
|
+
const table: number[][] = Array.from({ length: n + 1 }, () =>
|
|
112
|
+
Array.from({ length: m + 1 }, () => 0),
|
|
113
|
+
);
|
|
114
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
115
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
116
|
+
table[i]![j] =
|
|
117
|
+
aKeys[i] === bKeys[j]
|
|
118
|
+
? table[i + 1]![j + 1]! + 1
|
|
119
|
+
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const pairs: [number, number][] = [];
|
|
123
|
+
let i = 0;
|
|
124
|
+
let j = 0;
|
|
125
|
+
while (i < n && j < m) {
|
|
126
|
+
if (aKeys[i] === bKeys[j]) {
|
|
127
|
+
pairs.push([i, j]);
|
|
128
|
+
i += 1;
|
|
129
|
+
j += 1;
|
|
130
|
+
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
|
|
131
|
+
i += 1;
|
|
132
|
+
} else {
|
|
133
|
+
j += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return pairs;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One aligned pair of children, carrying BOTH sides' indices. */
|
|
140
|
+
export interface ChildMatch {
|
|
141
|
+
aIndex: number;
|
|
142
|
+
bIndex: number;
|
|
143
|
+
/** Strong matches are byte-identical — no recursion needed. */
|
|
144
|
+
identical: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** How much DP table {@link matchChildren} may allocate for one pair of children arrays. */
|
|
148
|
+
export interface MatchOptions {
|
|
149
|
+
/** Skip the LCS and degrade to splices when `a.length * b.length` exceeds this. */
|
|
150
|
+
maxCells?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Anchor by strong LCS, then align each gap by weak LCS. Matches never cross.
|
|
155
|
+
*
|
|
156
|
+
* Exported because both sides' indices are exactly what a side-by-side comparison needs, and
|
|
157
|
+
* {@link diffDocs} throws half of them away: its ops are a replay script addressed in `b`, so a
|
|
158
|
+
* removal names an index in `a` that the very next splice invalidates. Re-deriving the pairing from
|
|
159
|
+
* that output would mean a second definition of "the same node", which is the drift this export
|
|
160
|
+
* exists to prevent.
|
|
161
|
+
*/
|
|
162
|
+
export function matchChildren(
|
|
163
|
+
a: readonly unknown[],
|
|
164
|
+
b: readonly unknown[],
|
|
165
|
+
opts: MatchOptions = {},
|
|
166
|
+
): ChildMatch[] {
|
|
167
|
+
const maxCells = opts.maxCells ?? Number.POSITIVE_INFINITY;
|
|
168
|
+
const anchors = lcsPairs(
|
|
169
|
+
a.map((item) => strongKey(item)),
|
|
170
|
+
b.map((item) => strongKey(item)),
|
|
171
|
+
maxCells,
|
|
172
|
+
);
|
|
173
|
+
const matches: ChildMatch[] = [];
|
|
174
|
+
let prevA = -1;
|
|
175
|
+
let prevB = -1;
|
|
176
|
+
const gaps: [number, number, number, number][] = [];
|
|
177
|
+
for (const [ai, bi] of anchors) {
|
|
178
|
+
gaps.push([prevA + 1, ai, prevB + 1, bi]);
|
|
179
|
+
matches.push({ aIndex: ai, bIndex: bi, identical: true });
|
|
180
|
+
prevA = ai;
|
|
181
|
+
prevB = bi;
|
|
182
|
+
}
|
|
183
|
+
gaps.push([prevA + 1, a.length, prevB + 1, b.length]);
|
|
184
|
+
for (const [aStart, aEnd, bStart, bEnd] of gaps) {
|
|
185
|
+
const aGap = a.slice(aStart, aEnd);
|
|
186
|
+
const bGap = b.slice(bStart, bEnd);
|
|
187
|
+
for (const [gi, gj] of lcsPairs(
|
|
188
|
+
aGap.map((item) => weakKey(item)),
|
|
189
|
+
bGap.map((item) => weakKey(item)),
|
|
190
|
+
maxCells,
|
|
191
|
+
)) {
|
|
192
|
+
matches.push({ aIndex: aStart + gi, bIndex: bStart + gj, identical: false });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return matches.toSorted((x, y) => x.aIndex - y.aIndex);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
class Differ {
|
|
199
|
+
private readonly ops: JxDocOp[] = [];
|
|
200
|
+
private readonly maxOps: number;
|
|
201
|
+
|
|
202
|
+
constructor(maxOps: number) {
|
|
203
|
+
this.maxOps = maxOps;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
result(): JxDocOp[] {
|
|
207
|
+
return this.ops;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private push(op: JxDocOp): void {
|
|
211
|
+
if (this.ops.length >= this.maxOps) {
|
|
212
|
+
throw new MaxOpsExceededError();
|
|
213
|
+
}
|
|
214
|
+
this.ops.push(op);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
diffNode(path: JxPath, a: Record<string, unknown>, b: Record<string, unknown>): void {
|
|
218
|
+
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
219
|
+
if (key === "children") {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (!(key in b)) {
|
|
223
|
+
this.push({ key, op: "set-key", path });
|
|
224
|
+
} else if (!deepEqual(a[key], b[key])) {
|
|
225
|
+
this.push({ key, op: "set-key", path, value: b[key] });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const ca = a["children"];
|
|
229
|
+
const cb = b["children"];
|
|
230
|
+
if (Array.isArray(ca) && Array.isArray(cb)) {
|
|
231
|
+
this.diffChildren(path, ca, cb);
|
|
232
|
+
} else if (!deepEqual(ca, cb)) {
|
|
233
|
+
// Mapped-array children (or one side missing entirely) replace whole, like the mutators do.
|
|
234
|
+
this.push({ key: "children", op: "set-key", path, value: cb });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private diffChildren(path: JxPath, a: readonly unknown[], b: readonly unknown[]): void {
|
|
239
|
+
const matches = matchChildren(a, b);
|
|
240
|
+
const matchedA = new Set(matches.map((match) => match.aIndex));
|
|
241
|
+
const byB = new Map(matches.map((match) => [match.bIndex, match]));
|
|
242
|
+
|
|
243
|
+
// Removals first, in descending original index — each splice leaves lower indices intact.
|
|
244
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
245
|
+
if (!matchedA.has(i)) {
|
|
246
|
+
this.push({ index: i, op: "remove-child", parentPath: path });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Then walk b ascending: inserts land at their final index; matched pairs already sit there
|
|
250
|
+
// (matches never cross), so recursion paths are valid as emitted.
|
|
251
|
+
for (let i = 0; i < b.length; i++) {
|
|
252
|
+
const match = byB.get(i);
|
|
253
|
+
if (!match) {
|
|
254
|
+
this.push({ index: i, node: b[i], op: "insert-child", parentPath: path });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (match.identical) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const aItem = a[match.aIndex];
|
|
261
|
+
const bItem = b[i];
|
|
262
|
+
if (isPlainObject(aItem) && isPlainObject(bItem)) {
|
|
263
|
+
this.diffNode([...path, "children", i], aItem, bItem);
|
|
264
|
+
} else if (!deepEqual(aItem, bItem)) {
|
|
265
|
+
this.push({ index: i, node: bItem, op: "set-child", parentPath: path });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Ops that transform `a` into `b` when replayed sequentially, or null when the change is too large
|
|
273
|
+
* to express surgically (caller should hard-replace and reset).
|
|
274
|
+
*/
|
|
275
|
+
export function diffDocs(
|
|
276
|
+
a: JxMutableNode,
|
|
277
|
+
b: JxMutableNode,
|
|
278
|
+
opts: DiffOptions = {},
|
|
279
|
+
): JxDocOp[] | null {
|
|
280
|
+
const differ = new Differ(opts.maxOps ?? DEFAULT_MAX_OPS);
|
|
281
|
+
try {
|
|
282
|
+
differ.diffNode([], a as Record<string, unknown>, b as Record<string, unknown>);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error instanceof MaxOpsExceededError) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
return differ.result();
|
|
290
|
+
}
|
package/src/diff.ts
CHANGED
|
@@ -1,249 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* replayed sequentially (property invariant: `apply(clone(a), diffDocs(a, b)) ≡ b`). The collab
|
|
4
|
-
* bridge uses it wherever no op-log exists — whole-document bypass writes (Monaco parse-flush,
|
|
5
|
-
* navigation, reload), un-instrumented transactions, and inbound Y transactions too gnarly for the
|
|
6
|
-
* fast event path.
|
|
2
|
+
* `replaceYStructure` — the diff-overflow escape hatch, and the one export here that needs yjs.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* The alignment and equality logic this file used to hold lives in {@link file://./diff-core.ts} and
|
|
5
|
+
* is re-exported below, so every importer of `@jxsuite/collab/diff` is unchanged. It moved because
|
|
6
|
+
* THIS file's `./schema.ts` import is a value import and `schema.ts` imports yjs, while `diff.ts`'s
|
|
7
|
+
* own `import type * as Y` made it look otherwise: `diffDocs` and `deepEqual` are pure, and a
|
|
8
|
+
* caller that wanted them — the studio's diff highlighting — had no way to take them without taking
|
|
9
|
+
* a yjs-sized chunk with them.
|
|
13
10
|
*/
|
|
14
11
|
|
|
15
|
-
import type { JxMutableNode
|
|
16
|
-
import type { JxDocOp } from "./ops.ts";
|
|
12
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
17
13
|
import { metaMap, structureMap, toYChildren } from "./schema.ts";
|
|
18
14
|
import type * as Y from "yjs";
|
|
19
15
|
|
|
20
|
-
export
|
|
21
|
-
/** Abort (return null) when the diff would exceed this many ops; caller hard-replaces instead. */
|
|
22
|
-
maxOps?: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const DEFAULT_MAX_OPS = 500;
|
|
26
|
-
|
|
27
|
-
class MaxOpsExceededError extends Error {
|
|
28
|
-
override name = "MaxOpsExceededError";
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
32
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Order-insensitive for object keys, order-sensitive for arrays. */
|
|
36
|
-
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
37
|
-
if (a === b) {
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
41
|
-
return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
42
|
-
}
|
|
43
|
-
if (isPlainObject(a) && isPlainObject(b)) {
|
|
44
|
-
const keysA = Object.keys(a);
|
|
45
|
-
const keysB = Object.keys(b);
|
|
46
|
-
return (
|
|
47
|
-
keysA.length === keysB.length && keysA.every((key) => key in b && deepEqual(a[key], b[key]))
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function contentHash(text: string): string {
|
|
54
|
-
let hash = 5381;
|
|
55
|
-
for (let i = 0; i < text.length; i++) {
|
|
56
|
-
hash = Math.imul(hash, 33) + (text.codePointAt(i) ?? 0);
|
|
57
|
-
}
|
|
58
|
-
return hash.toString(36);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Full-content identity: equal strong keys mean "treat as the same unchanged child". */
|
|
62
|
-
function strongKey(item: unknown): string {
|
|
63
|
-
return typeof item === "string" ? `s:${item}` : `n:${contentHash(JSON.stringify(item))}`;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Alignment identity: equal weak keys mean "same logical child, diff its contents". */
|
|
67
|
-
function weakKey(item: unknown): string {
|
|
68
|
-
if (typeof item === "string") {
|
|
69
|
-
return "s";
|
|
70
|
-
}
|
|
71
|
-
const node = item as Record<string, unknown>;
|
|
72
|
-
const attrs = node["attributes"];
|
|
73
|
-
const props = node["$props"];
|
|
74
|
-
const id =
|
|
75
|
-
(isPlainObject(attrs) ? attrs["id"] : undefined) ??
|
|
76
|
-
(isPlainObject(props) ? props["key"] : undefined) ??
|
|
77
|
-
"";
|
|
78
|
-
return `w:${String(node["tagName"] ?? "")}:${String(id)}`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Longest common subsequence over key arrays; returns strictly-increasing index pairs. */
|
|
82
|
-
function lcsPairs(aKeys: readonly string[], bKeys: readonly string[]): [number, number][] {
|
|
83
|
-
const n = aKeys.length;
|
|
84
|
-
const m = bKeys.length;
|
|
85
|
-
if (n === 0 || m === 0) {
|
|
86
|
-
return [];
|
|
87
|
-
}
|
|
88
|
-
// Classic DP table; children arrays are small (bounded by document size, typically < 200).
|
|
89
|
-
const table: number[][] = Array.from({ length: n + 1 }, () =>
|
|
90
|
-
Array.from({ length: m + 1 }, () => 0),
|
|
91
|
-
);
|
|
92
|
-
for (let i = n - 1; i >= 0; i--) {
|
|
93
|
-
for (let j = m - 1; j >= 0; j--) {
|
|
94
|
-
table[i]![j] =
|
|
95
|
-
aKeys[i] === bKeys[j]
|
|
96
|
-
? table[i + 1]![j + 1]! + 1
|
|
97
|
-
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const pairs: [number, number][] = [];
|
|
101
|
-
let i = 0;
|
|
102
|
-
let j = 0;
|
|
103
|
-
while (i < n && j < m) {
|
|
104
|
-
if (aKeys[i] === bKeys[j]) {
|
|
105
|
-
pairs.push([i, j]);
|
|
106
|
-
i += 1;
|
|
107
|
-
j += 1;
|
|
108
|
-
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
|
|
109
|
-
i += 1;
|
|
110
|
-
} else {
|
|
111
|
-
j += 1;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return pairs;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
interface ChildMatch {
|
|
118
|
-
aIndex: number;
|
|
119
|
-
bIndex: number;
|
|
120
|
-
/** Strong matches are byte-identical — no recursion needed. */
|
|
121
|
-
identical: boolean;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Anchor by strong LCS, then align each gap by weak LCS. Matches never cross. */
|
|
125
|
-
function matchChildren(a: readonly unknown[], b: readonly unknown[]): ChildMatch[] {
|
|
126
|
-
const anchors = lcsPairs(
|
|
127
|
-
a.map((item) => strongKey(item)),
|
|
128
|
-
b.map((item) => strongKey(item)),
|
|
129
|
-
);
|
|
130
|
-
const matches: ChildMatch[] = [];
|
|
131
|
-
let prevA = -1;
|
|
132
|
-
let prevB = -1;
|
|
133
|
-
const gaps: [number, number, number, number][] = [];
|
|
134
|
-
for (const [ai, bi] of anchors) {
|
|
135
|
-
gaps.push([prevA + 1, ai, prevB + 1, bi]);
|
|
136
|
-
matches.push({ aIndex: ai, bIndex: bi, identical: true });
|
|
137
|
-
prevA = ai;
|
|
138
|
-
prevB = bi;
|
|
139
|
-
}
|
|
140
|
-
gaps.push([prevA + 1, a.length, prevB + 1, b.length]);
|
|
141
|
-
for (const [aStart, aEnd, bStart, bEnd] of gaps) {
|
|
142
|
-
const aGap = a.slice(aStart, aEnd);
|
|
143
|
-
const bGap = b.slice(bStart, bEnd);
|
|
144
|
-
for (const [gi, gj] of lcsPairs(
|
|
145
|
-
aGap.map((item) => weakKey(item)),
|
|
146
|
-
bGap.map((item) => weakKey(item)),
|
|
147
|
-
)) {
|
|
148
|
-
matches.push({ aIndex: aStart + gi, bIndex: bStart + gj, identical: false });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return matches.toSorted((x, y) => x.aIndex - y.aIndex);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
class Differ {
|
|
155
|
-
private readonly ops: JxDocOp[] = [];
|
|
156
|
-
private readonly maxOps: number;
|
|
157
|
-
|
|
158
|
-
constructor(maxOps: number) {
|
|
159
|
-
this.maxOps = maxOps;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
result(): JxDocOp[] {
|
|
163
|
-
return this.ops;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
private push(op: JxDocOp): void {
|
|
167
|
-
if (this.ops.length >= this.maxOps) {
|
|
168
|
-
throw new MaxOpsExceededError();
|
|
169
|
-
}
|
|
170
|
-
this.ops.push(op);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
diffNode(path: JxPath, a: Record<string, unknown>, b: Record<string, unknown>): void {
|
|
174
|
-
for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
175
|
-
if (key === "children") {
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
if (!(key in b)) {
|
|
179
|
-
this.push({ key, op: "set-key", path });
|
|
180
|
-
} else if (!deepEqual(a[key], b[key])) {
|
|
181
|
-
this.push({ key, op: "set-key", path, value: b[key] });
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
const ca = a["children"];
|
|
185
|
-
const cb = b["children"];
|
|
186
|
-
if (Array.isArray(ca) && Array.isArray(cb)) {
|
|
187
|
-
this.diffChildren(path, ca, cb);
|
|
188
|
-
} else if (!deepEqual(ca, cb)) {
|
|
189
|
-
// Mapped-array children (or one side missing entirely) replace whole, like the mutators do.
|
|
190
|
-
this.push({ key: "children", op: "set-key", path, value: cb });
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private diffChildren(path: JxPath, a: readonly unknown[], b: readonly unknown[]): void {
|
|
195
|
-
const matches = matchChildren(a, b);
|
|
196
|
-
const matchedA = new Set(matches.map((match) => match.aIndex));
|
|
197
|
-
const byB = new Map(matches.map((match) => [match.bIndex, match]));
|
|
198
|
-
|
|
199
|
-
// Removals first, in descending original index — each splice leaves lower indices intact.
|
|
200
|
-
for (let i = a.length - 1; i >= 0; i--) {
|
|
201
|
-
if (!matchedA.has(i)) {
|
|
202
|
-
this.push({ index: i, op: "remove-child", parentPath: path });
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
// Then walk b ascending: inserts land at their final index; matched pairs already sit there
|
|
206
|
-
// (matches never cross), so recursion paths are valid as emitted.
|
|
207
|
-
for (let i = 0; i < b.length; i++) {
|
|
208
|
-
const match = byB.get(i);
|
|
209
|
-
if (!match) {
|
|
210
|
-
this.push({ index: i, node: b[i], op: "insert-child", parentPath: path });
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
if (match.identical) {
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
const aItem = a[match.aIndex];
|
|
217
|
-
const bItem = b[i];
|
|
218
|
-
if (isPlainObject(aItem) && isPlainObject(bItem)) {
|
|
219
|
-
this.diffNode([...path, "children", i], aItem, bItem);
|
|
220
|
-
} else if (!deepEqual(aItem, bItem)) {
|
|
221
|
-
this.push({ index: i, node: bItem, op: "set-child", parentPath: path });
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Ops that transform `a` into `b` when replayed sequentially, or null when the change is too large
|
|
229
|
-
* to express surgically (caller should hard-replace and reset).
|
|
230
|
-
*/
|
|
231
|
-
export function diffDocs(
|
|
232
|
-
a: JxMutableNode,
|
|
233
|
-
b: JxMutableNode,
|
|
234
|
-
opts: DiffOptions = {},
|
|
235
|
-
): JxDocOp[] | null {
|
|
236
|
-
const differ = new Differ(opts.maxOps ?? DEFAULT_MAX_OPS);
|
|
237
|
-
try {
|
|
238
|
-
differ.diffNode([], a as Record<string, unknown>, b as Record<string, unknown>);
|
|
239
|
-
} catch (error) {
|
|
240
|
-
if (error instanceof MaxOpsExceededError) {
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
throw error;
|
|
244
|
-
}
|
|
245
|
-
return differ.result();
|
|
246
|
-
}
|
|
16
|
+
export * from "./diff-core.ts";
|
|
247
17
|
|
|
248
18
|
/**
|
|
249
19
|
* Hard-replace the structure tree with `document` (the diff-overflow escape hatch) and bump the
|