@jxsuite/collab 0.8.5 → 0.10.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 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
- in one origin-tagged transaction (`LOCAL_ORIGIN` for user edits, `MIRROR_ORIGIN` for derived
34
- writes, `SEED_ORIGIN` for bootstraps).
35
- - `yEventsToDocOps(events)` converts a remote transaction's deep events back into `JxDocOp`s.
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`, `inverseOf` and `applyDocOpsWithInverse` (in `./ops`, yjs-free) re-export the canonical plain-JSON op vocabulary from `@jxsuite/schema/doc-ops`, shared with Studio's history replay and the canvas shadow doc, so a host without yjs can apply the same ops.
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.8.5",
3
+ "version": "0.10.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,9 +34,9 @@
33
34
  "test:coverage": "bun test --isolate --coverage"
34
35
  },
35
36
  "dependencies": {
36
- "@jxsuite/schema": "^2.1.0",
37
- "lib0": "^0.2.117",
37
+ "@jxsuite/schema": "^2.3.0",
38
+ "lib0": "^0.2.119",
38
39
  "y-protocols": "^1.0.7",
39
- "yjs": "13.6.32"
40
+ "yjs": "13.6.33"
40
41
  }
41
42
  }
@@ -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
- * Structural differ: `diffDocs(a, b)` produces JxDocOps that transform document `a` into `b` when
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
- * Children alignment: an LCS over strong keys (full-content hashes) pins unchanged nodes as
9
- * anchors; the gaps between anchors align by a second LCS over weak signatures (tagName +
10
- * id/$props.key) whose pairs diff recursively. Unmatched items become splices. Reorders of
11
- * identical siblings therefore degrade to remove+insert — exactly what the Y move mapping does, so
12
- * no fidelity is lost relative to the wire.
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, JxPath } from "@jxsuite/schema/types";
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 interface DiffOptions {
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
package/src/ops.ts CHANGED
@@ -1,128 +1,16 @@
1
1
  /**
2
- * The canonical Jx document-op vocabulary and its pure applier. A `JxDocOp` is a value-carrying,
3
- * replayable mutation of a plain-JSON Jx document tree, addressed by `JxPath` (alternating
4
- * "children" / index segments for structure, plus node keys). Studio's transaction pipeline records
5
- * forward/inverse pairs of these for history replay; the collab bridge mirrors them into a shared
6
- * Y.Doc.
2
+ * The Jx document-op vocabulary, re-exported from its home in `@jxsuite/schema/doc-ops`.
7
3
  *
8
- * This module is deliberately yjs-free and dependency-light: the slim canvas-iframe bundle imports
9
- * the applier for its non-reactive shadow doc, and it MUST stay byte-identical in behavior to the
10
- * parent's history replay — both sides import this one implementation.
4
+ * It moved so a host with no yjs (a Durable Object, a headless tool) can edit a document the way
5
+ * Studio does. This subpath stays so every existing `@jxsuite/collab/ops` import keeps working.
11
6
  */
12
7
 
13
- import type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
14
-
15
- /** Value-carrying document mutation, replayable in either direction. */
16
- export type JxDocOp =
17
- /** Set (or delete, when value is undefined) a key on the node at path. */
18
- | { op: "set-key"; path: JxPath; key: string; value?: unknown }
19
- /** Insert a node at parentPath.children[index]. */
20
- | { op: "insert-child"; parentPath: JxPath; index: number; node: unknown }
21
- /** Remove parentPath.children[index]. */
22
- | { op: "remove-child"; parentPath: JxPath; index: number }
23
- /** Replace parentPath.children[index] with node. */
24
- | { op: "set-child"; parentPath: JxPath; index: number; node: unknown }
25
- /** Raw two-splice move: remove fromParent.children[fromIndex], insert at toParent[toIndex]. */
26
- | {
27
- op: "move-child";
28
- fromParentPath: JxPath;
29
- fromIndex: number;
30
- toParentPath: JxPath;
31
- toIndex: number;
32
- };
33
-
34
- export interface JxDocOpPair {
35
- forward: JxDocOp;
36
- inverse: JxDocOp;
37
- }
38
-
39
- /** JSON round-trip clone — also normalizes away reactive proxies / functions / undefined. */
40
- function jsonClone<T>(value: T): T {
41
- // oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone throws on reactive proxies; JSON normalization is the point
42
- return JSON.parse(JSON.stringify(value)) as T;
43
- }
44
-
45
- /** Deep-clone a recorded value (undefined/null pass through; reactive proxies are read through). */
46
- export function cloneValue<T>(v: T): T {
47
- return v === undefined || v === null ? v : (jsonClone(v as object) as T);
48
- }
49
-
50
- /**
51
- * Walk a plain document tree by path. Paths address nodes by construction; missing segments and
52
- * non-node leaves surface as undefined (typed as node for caller ergonomics, matching Studio's
53
- * historical helper).
54
- */
55
- export function getNodeAtPath(doc: JxMutableNode, path: JxPath): JxMutableNode {
56
- let node = doc;
57
- for (const key of path) {
58
- if (node == null) {
59
- return undefined as unknown as JxMutableNode;
60
- }
61
- node = node[key] as JxMutableNode;
62
- }
63
- return node;
64
- }
65
-
66
- /** The node's children array, lazily created; throws if `node` is itself a children array or mapped. */
67
- export function childArray(node: JxMutableNode): (JxMutableNode | string)[] {
68
- // Defense-in-depth: a path that resolves to a children array (rather than a node) would
69
- // Otherwise get a bogus `.children` property tacked on here, silently storing the insert where
70
- // Nothing renders. Callers must pass a node; fail loudly if they don't.
71
- if (Array.isArray(node)) {
72
- throw new TypeError("Cannot insert into a children array; parentPath must point at a node");
73
- }
74
- if (!node.children) {
75
- node.children = [];
76
- }
77
- if (!Array.isArray(node.children)) {
78
- throw new TypeError("Cannot insert into mapped-array children; edit the map template instead");
79
- }
80
- return node.children;
81
- }
82
-
83
- /**
84
- * Apply a replayable doc op to a bare document tree. Values/nodes are cloned in, so the tree never
85
- * aliases the op object. Throws (with a machine-readable reason) when a target path is missing.
86
- */
87
- export function applyDocOpToDoc(doc: JxMutableNode, op: JxDocOp): void {
88
- switch (op.op) {
89
- case "set-key": {
90
- const node = getNodeAtPath(doc, op.path);
91
- if (!node) {
92
- throw new Error(`doc-op-node-not-found:${op.path.join("/")}`);
93
- }
94
- const target = node as Record<string, unknown>;
95
- if (op.value === undefined) {
96
- delete target[op.key];
97
- } else {
98
- target[op.key] = cloneValue(op.value);
99
- }
100
- return;
101
- }
102
- case "insert-child": {
103
- const parent = getNodeAtPath(doc, op.parentPath);
104
- childArray(parent).splice(op.index, 0, cloneValue(op.node) as JxMutableNode);
105
- return;
106
- }
107
- case "remove-child": {
108
- const parent = getNodeAtPath(doc, op.parentPath);
109
- childArray(parent).splice(op.index, 1);
110
- return;
111
- }
112
- case "set-child": {
113
- const parent = getNodeAtPath(doc, op.parentPath);
114
- childArray(parent).splice(op.index, 1, cloneValue(op.node) as JxMutableNode);
115
- return;
116
- }
117
- case "move-child": {
118
- const fromParent = getNodeAtPath(doc, op.fromParentPath);
119
- const toParent = getNodeAtPath(doc, op.toParentPath);
120
- const [node] = childArray(fromParent).splice(op.fromIndex, 1);
121
- childArray(toParent).splice(op.toIndex, 0, node!);
122
- return;
123
- }
124
- default: {
125
- throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
126
- }
127
- }
128
- }
8
+ export {
9
+ applyDocOpToDoc,
10
+ applyDocOpsWithInverse,
11
+ childArray,
12
+ cloneValue,
13
+ getNodeAtPath,
14
+ inverseOf,
15
+ } from "@jxsuite/schema/doc-ops";
16
+ export type { JxDocOp, JxDocOpPair } from "@jxsuite/schema/doc-ops";