@jxsuite/collab 0.1.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 +79 -0
- package/package.json +40 -0
- package/src/awareness-types.ts +54 -0
- package/src/diff.ts +277 -0
- package/src/envelope.ts +137 -0
- package/src/index.ts +45 -0
- package/src/op-bridge.ts +218 -0
- package/src/ops.ts +128 -0
- package/src/provider.ts +45 -0
- package/src/schema.ts +205 -0
- package/src/ws-client.ts +436 -0
- package/src/ws-room.ts +424 -0
package/src/op-bridge.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-way bridge between Studio's JxDocOp op-log and the shared Y.Doc structure tree.
|
|
3
|
+
*
|
|
4
|
+
* Outbound ({@link applyDocOpsToY}): forward ops recorded by a local transaction replay onto the Y
|
|
5
|
+
* tree inside one Y transaction tagged with the caller's origin, so observers can tell local
|
|
6
|
+
* mirroring from remote edits.
|
|
7
|
+
*
|
|
8
|
+
* Inbound ({@link yEventsToDocOps}): a remote transaction's Y events convert back into JxDocOps for
|
|
9
|
+
* `applyExternalDocOps`. Only provably safe event shapes take this fast path (it keeps canvas
|
|
10
|
+
* patches surgical); anything gnarlier returns null and the caller reconciles by diffing — the
|
|
11
|
+
* correctness anchor.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as Y from "yjs";
|
|
15
|
+
import type { JxPath } from "@jxsuite/schema/types";
|
|
16
|
+
import type { JxDocOp } from "./ops.ts";
|
|
17
|
+
import { resolveYPath, toYChildren, toYNode, yValueToJson } from "./schema.ts";
|
|
18
|
+
|
|
19
|
+
/** Origin of Y transactions produced by mirroring the local tab's ops. */
|
|
20
|
+
export const LOCAL_ORIGIN = "jx-local";
|
|
21
|
+
/** Origin of derived-representation writes (the elected reconciler's structure↔source mirrors). */
|
|
22
|
+
export const MIRROR_ORIGIN = "jx-mirror";
|
|
23
|
+
/** Origin of seeding/bootstrap writes. */
|
|
24
|
+
export const SEED_ORIGIN = "jx-seed";
|
|
25
|
+
|
|
26
|
+
/** Thrown when an op's path does not resolve in the Y tree; callers fall back to diffing. */
|
|
27
|
+
export class CollabPathError extends Error {
|
|
28
|
+
constructor(path: JxPath) {
|
|
29
|
+
super(`collab-path-unresolved:${path.join("/")}`);
|
|
30
|
+
this.name = "CollabPathError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requireNode(doc: Y.Doc, path: JxPath): Y.Map<unknown> {
|
|
35
|
+
const target = resolveYPath(doc, path);
|
|
36
|
+
if (!(target instanceof Y.Map)) {
|
|
37
|
+
throw new CollabPathError(path);
|
|
38
|
+
}
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function childrenOf(doc: Y.Doc, parentPath: JxPath, create: boolean): Y.Array<unknown> {
|
|
43
|
+
const parent = requireNode(doc, parentPath);
|
|
44
|
+
const children = parent.get("children");
|
|
45
|
+
if (children instanceof Y.Array) {
|
|
46
|
+
return children;
|
|
47
|
+
}
|
|
48
|
+
if (create && children === undefined) {
|
|
49
|
+
const fresh = new Y.Array<unknown>();
|
|
50
|
+
parent.set("children", fresh);
|
|
51
|
+
return fresh;
|
|
52
|
+
}
|
|
53
|
+
throw new CollabPathError([...parentPath, "children"]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** JSON round-trip clone (matches the ops applier's normalization). */
|
|
57
|
+
function cloneJson<T>(value: T): T {
|
|
58
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone -- JSON normalization is the point
|
|
59
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toYChild(node: unknown): unknown {
|
|
63
|
+
return typeof node === "string" ? node : toYNode(node);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applyOneOp(doc: Y.Doc, op: JxDocOp): void {
|
|
67
|
+
switch (op.op) {
|
|
68
|
+
case "set-key": {
|
|
69
|
+
const node = requireNode(doc, op.path);
|
|
70
|
+
if (op.value === undefined) {
|
|
71
|
+
node.delete(op.key);
|
|
72
|
+
} else if (op.key === "children" && Array.isArray(op.value)) {
|
|
73
|
+
node.set(op.key, toYChildren(op.value));
|
|
74
|
+
} else {
|
|
75
|
+
node.set(op.key, cloneJson(op.value));
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
case "insert-child": {
|
|
80
|
+
const children = childrenOf(doc, op.parentPath, true);
|
|
81
|
+
if (op.index < 0 || op.index > children.length) {
|
|
82
|
+
throw new CollabPathError([...op.parentPath, "children", op.index]);
|
|
83
|
+
}
|
|
84
|
+
children.insert(op.index, [toYChild(op.node)]);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case "remove-child": {
|
|
88
|
+
const children = childrenOf(doc, op.parentPath, false);
|
|
89
|
+
if (op.index < 0 || op.index >= children.length) {
|
|
90
|
+
throw new CollabPathError([...op.parentPath, "children", op.index]);
|
|
91
|
+
}
|
|
92
|
+
children.delete(op.index, 1);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
case "set-child": {
|
|
96
|
+
const children = childrenOf(doc, op.parentPath, false);
|
|
97
|
+
if (op.index < 0 || op.index >= children.length) {
|
|
98
|
+
throw new CollabPathError([...op.parentPath, "children", op.index]);
|
|
99
|
+
}
|
|
100
|
+
children.delete(op.index, 1);
|
|
101
|
+
children.insert(op.index, [toYChild(op.node)]);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
case "move-child": {
|
|
105
|
+
// Y.Array has no native move: re-insert the node's current JSON at the target. A remote
|
|
106
|
+
// Edit landing inside the moved subtree during the same round-trip is lost (documented v1
|
|
107
|
+
// Limitation); convergence itself is unaffected. Both parent paths resolve BEFORE the
|
|
108
|
+
// Removal splice — matching the plain applier's semantics — while toIndex is expressed in
|
|
109
|
+
// Post-removal coordinates (the mutators pre-adjust same-parent moves).
|
|
110
|
+
const from = childrenOf(doc, op.fromParentPath, false);
|
|
111
|
+
const to = childrenOf(doc, op.toParentPath, true);
|
|
112
|
+
if (op.fromIndex < 0 || op.fromIndex >= from.length) {
|
|
113
|
+
throw new CollabPathError([...op.fromParentPath, "children", op.fromIndex]);
|
|
114
|
+
}
|
|
115
|
+
const json = yValueToJson(from.get(op.fromIndex));
|
|
116
|
+
from.delete(op.fromIndex, 1);
|
|
117
|
+
if (op.toIndex < 0 || op.toIndex > to.length) {
|
|
118
|
+
throw new CollabPathError([...op.toParentPath, "children", op.toIndex]);
|
|
119
|
+
}
|
|
120
|
+
to.insert(op.toIndex, [toYChild(json)]);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
default: {
|
|
124
|
+
throw new Error(`unknown-doc-op:${(op as JxDocOp).op}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Replay forward ops onto the Y structure tree in one transaction. Throws {@link CollabPathError}
|
|
131
|
+
* when any op's path does not resolve — the transaction still commits the ops applied before the
|
|
132
|
+
* failure, so callers catching it must reconcile by diff (which corrects any partial state).
|
|
133
|
+
*/
|
|
134
|
+
export function applyDocOpsToY(doc: Y.Doc, ops: readonly JxDocOp[], origin: unknown): void {
|
|
135
|
+
doc.transact(() => {
|
|
136
|
+
for (const op of ops) {
|
|
137
|
+
applyOneOp(doc, op);
|
|
138
|
+
}
|
|
139
|
+
}, origin);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isStructurePath(segments: readonly (string | number)[]): segments is JxPath {
|
|
143
|
+
return segments.every((seg) => typeof seg === "string" || typeof seg === "number");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Convert a remote transaction's deep events on the structure tree into JxDocOps (positions valid
|
|
148
|
+
* at sequential apply time). Returns null when the shape is not provably safe to convert — multiple
|
|
149
|
+
* overlapping events, more than one array event, or non-contiguous delete runs — and the caller
|
|
150
|
+
* must reconcile by diffing instead.
|
|
151
|
+
*/
|
|
152
|
+
export function yEventsToDocOps(events: readonly Y.YEvent<never>[]): JxDocOp[] | null {
|
|
153
|
+
if (events.length === 0) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const paths = events.map((event) => event.path);
|
|
157
|
+
if (!paths.every((p) => isStructurePath(p))) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// Overlap guard: one event's target must not sit inside another's subtree — later paths would
|
|
161
|
+
// Describe positions the earlier splice already shifted.
|
|
162
|
+
for (let i = 0; i < paths.length; i++) {
|
|
163
|
+
for (let j = 0; j < paths.length; j++) {
|
|
164
|
+
if (i === j) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const a = paths[i]!;
|
|
168
|
+
const b = paths[j]!;
|
|
169
|
+
if (a.length <= b.length && a.every((seg, k) => seg === b[k])) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const arrayEvents = events.filter((event) => event instanceof Y.YArrayEvent).length;
|
|
175
|
+
if (arrayEvents > 1) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ops: JxDocOp[] = [];
|
|
180
|
+
for (const event of events) {
|
|
181
|
+
const path = event.path as JxPath;
|
|
182
|
+
if (event instanceof Y.YMapEvent) {
|
|
183
|
+
const target = event.target as Y.Map<unknown>;
|
|
184
|
+
for (const [key, change] of event.changes.keys) {
|
|
185
|
+
if (change.action === "delete") {
|
|
186
|
+
ops.push({ key, op: "set-key", path });
|
|
187
|
+
} else {
|
|
188
|
+
ops.push({ key, op: "set-key", path, value: yValueToJson(target.get(key)) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else if (event instanceof Y.YArrayEvent) {
|
|
192
|
+
// The array event's path points at the children array; ops address its parent node.
|
|
193
|
+
if (path.at(-1) !== "children") {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const parentPath = path.slice(0, -1);
|
|
197
|
+
let index = 0;
|
|
198
|
+
for (const delta of event.changes.delta) {
|
|
199
|
+
if (delta.retain !== undefined) {
|
|
200
|
+
index += delta.retain;
|
|
201
|
+
} else if (delta.delete !== undefined) {
|
|
202
|
+
for (let i = 0; i < delta.delete; i++) {
|
|
203
|
+
ops.push({ index, op: "remove-child", parentPath });
|
|
204
|
+
}
|
|
205
|
+
} else if (delta.insert !== undefined) {
|
|
206
|
+
const items = Array.isArray(delta.insert) ? delta.insert : [delta.insert];
|
|
207
|
+
for (const item of items) {
|
|
208
|
+
ops.push({ index, node: yValueToJson(item), op: "insert-child", parentPath });
|
|
209
|
+
index += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return ops;
|
|
218
|
+
}
|
package/src/ops.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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.
|
|
7
|
+
*
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
|
|
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
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider contract types — what `StudioPlatform.collab?.(docPath)` resolves to. Type-only on
|
|
3
|
+
* purpose: Studio's base bundle imports this without pulling yjs in; the runtime lives behind a
|
|
4
|
+
* dynamic import of the concrete provider.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
8
|
+
import type * as Y from "yjs";
|
|
9
|
+
import type { CollabPermission } from "./envelope.ts";
|
|
10
|
+
|
|
11
|
+
export type CollabStatus = "connecting" | "connected" | "offline";
|
|
12
|
+
|
|
13
|
+
export interface CollabIdentity {
|
|
14
|
+
login: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
avatarUrl?: string;
|
|
17
|
+
color: string;
|
|
18
|
+
permission: CollabPermission;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A live co-editing session for one document. The Y.Doc starts EMPTY and fills from the provider
|
|
23
|
+
* (server-seeded source; client-derived structure) — never seed it locally before whenSynced.
|
|
24
|
+
*/
|
|
25
|
+
export interface CollabHandle {
|
|
26
|
+
doc: Y.Doc;
|
|
27
|
+
/** The connection's shared project-level awareness (one instance across all open docs). */
|
|
28
|
+
awareness: Awareness;
|
|
29
|
+
/** Resolves after the initial sync handshake completes for this doc. */
|
|
30
|
+
whenSynced: Promise<void>;
|
|
31
|
+
/** Identity issued by the server's hello; null until the connection greeted. */
|
|
32
|
+
identity: () => CollabIdentity | null;
|
|
33
|
+
/** Ask the provider to persist this doc to durable storage now (Cmd+S). */
|
|
34
|
+
flush: () => Promise<void>;
|
|
35
|
+
/** Connection status changes (shared per connection). Returns an unsubscribe. */
|
|
36
|
+
onStatus: (cb: (status: CollabStatus) => void) => () => void;
|
|
37
|
+
/**
|
|
38
|
+
* The server reset this doc's history (epoch bump after discard/pull/external write). The handle
|
|
39
|
+
* is dead: destroy it and re-acquire a fresh one via platform.collab.
|
|
40
|
+
*/
|
|
41
|
+
onReset: (cb: () => void) => () => void;
|
|
42
|
+
destroy: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CollabCapability = (docPath: string) => Promise<CollabHandle | null>;
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared Y.Doc schema for a Jx document, and the JSON ↔ Y mapping.
|
|
3
|
+
*
|
|
4
|
+
* Layout (all top-level types, created on demand): - `meta: Y.Map` — schemaVersion,
|
|
5
|
+
* structureSeeded, canonical ("structure" | "source"), canonicalRev (representation-flip counter —
|
|
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
|
|
13
|
+
* `getText("source").toString()` and never parse or serialize Jx documents themselves; the
|
|
14
|
+
* structure tree is a Studio-side projection kept in sync by the bridge.
|
|
15
|
+
*
|
|
16
|
+
* Seeding contract: providers seed only `source` (from file bytes). Clients derive `structure` on
|
|
17
|
+
* first sync via {@link seedStructure}, which is convergence-safe under concurrent seeders because
|
|
18
|
+
* it only performs whole-key Y.Map sets (per-key LWW picks one seeder's subtrees wholesale) — never
|
|
19
|
+
* array inserts, which would duplicate.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as Y from "yjs";
|
|
23
|
+
import type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
|
|
24
|
+
|
|
25
|
+
export const META_KEY = "meta";
|
|
26
|
+
export const FRONTMATTER_KEY = "frontmatter";
|
|
27
|
+
export const STRUCTURE_KEY = "structure";
|
|
28
|
+
export const SOURCE_KEY = "source";
|
|
29
|
+
|
|
30
|
+
export const COLLAB_SCHEMA_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
export function metaMap(doc: Y.Doc): Y.Map<unknown> {
|
|
33
|
+
return doc.getMap(META_KEY);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function frontmatterMap(doc: Y.Doc): Y.Map<unknown> {
|
|
37
|
+
return doc.getMap(FRONTMATTER_KEY);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function structureMap(doc: Y.Doc): Y.Map<unknown> {
|
|
41
|
+
return doc.getMap(STRUCTURE_KEY);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sourceText(doc: Y.Doc): Y.Text {
|
|
45
|
+
return doc.getText(SOURCE_KEY);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** JSON round-trip clone (normalizes away proxies/undefined; matches the ops applier). */
|
|
49
|
+
function cloneJson<T>(value: T): T {
|
|
50
|
+
// oxlint-disable-next-line unicorn/prefer-structured-clone -- JSON normalization is the point
|
|
51
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A children array becomes a Y.Array of node Y.Maps and plain strings. */
|
|
59
|
+
export function toYChildren(children: readonly unknown[]): Y.Array<unknown> {
|
|
60
|
+
const arr = new Y.Array<unknown>();
|
|
61
|
+
arr.insert(
|
|
62
|
+
0,
|
|
63
|
+
children.map((child) => (typeof child === "string" ? child : toYNode(child))),
|
|
64
|
+
);
|
|
65
|
+
return arr;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A node object becomes a Y.Map; only its "children" key nests further Y types. */
|
|
69
|
+
export function toYNode(node: unknown): Y.Map<unknown> {
|
|
70
|
+
if (!isPlainObject(node)) {
|
|
71
|
+
throw new TypeError("collab-schema: a document node must be a plain object");
|
|
72
|
+
}
|
|
73
|
+
const map = new Y.Map<unknown>();
|
|
74
|
+
for (const [key, value] of Object.entries(node)) {
|
|
75
|
+
if (value === undefined) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (key === "children" && Array.isArray(value)) {
|
|
79
|
+
map.set(key, toYChildren(value));
|
|
80
|
+
} else {
|
|
81
|
+
map.set(key, cloneJson(value));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return map;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Convert any structure value (Y.Map node, Y.Array children, or plain leaf) back to JSON. */
|
|
88
|
+
export function yValueToJson(value: unknown): unknown {
|
|
89
|
+
if (value instanceof Y.Map) {
|
|
90
|
+
const out: Record<string, unknown> = {};
|
|
91
|
+
for (const [key, entry] of value.entries()) {
|
|
92
|
+
out[key] = yValueToJson(entry);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
if (value instanceof Y.Array) {
|
|
97
|
+
return value.toArray().map((entry) => yValueToJson(entry));
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The whole structure tree as a plain document (deep, detached copy). */
|
|
103
|
+
export function yDocToJson(doc: Y.Doc): JxMutableNode {
|
|
104
|
+
return yValueToJson(structureMap(doc)) as JxMutableNode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a JxPath against the structure tree. Returns the Y value at the path (a node Y.Map, a
|
|
109
|
+
* children Y.Array, a string child, or a plain leaf) or undefined when any segment is missing.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveYPath(doc: Y.Doc, path: JxPath): unknown {
|
|
112
|
+
let current: unknown = structureMap(doc);
|
|
113
|
+
for (const segment of path) {
|
|
114
|
+
if (current instanceof Y.Map) {
|
|
115
|
+
current = current.get(String(segment));
|
|
116
|
+
} else if (current instanceof Y.Array) {
|
|
117
|
+
const index = typeof segment === "number" ? segment : Number(segment);
|
|
118
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
current = current.get(index);
|
|
122
|
+
} else {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return current;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Update the source Y.Text to `next` with a minimal edit (common prefix/suffix trimmed), so mirror
|
|
131
|
+
* refreshes don't ship the whole document over the wire. Returns false when already identical.
|
|
132
|
+
*/
|
|
133
|
+
export function updateSourceText(doc: Y.Doc, next: string, origin?: unknown): boolean {
|
|
134
|
+
const text = sourceText(doc);
|
|
135
|
+
const current = text.toString();
|
|
136
|
+
if (current === next) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
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
|
+
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
|
+
}
|
|
157
|
+
}, origin);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Populate the structure tree (and frontmatter/meta) from a parsed document — the client-side
|
|
163
|
+
* first-sync step. Safe under concurrent seeders: every write is a whole-key Y.Map set, so two
|
|
164
|
+
* racing seeds converge to one seeder's subtrees via per-key LWW with no duplication. No-op when
|
|
165
|
+
* another seeder already won.
|
|
166
|
+
*/
|
|
167
|
+
export function seedStructure(
|
|
168
|
+
doc: Y.Doc,
|
|
169
|
+
document: JxMutableNode,
|
|
170
|
+
opts: {
|
|
171
|
+
frontmatter?: Record<string, unknown>;
|
|
172
|
+
sourceFormat?: string | null;
|
|
173
|
+
origin?: unknown;
|
|
174
|
+
} = {},
|
|
175
|
+
): boolean {
|
|
176
|
+
const meta = metaMap(doc);
|
|
177
|
+
if (meta.get("structureSeeded") === true) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
doc.transact(() => {
|
|
181
|
+
const structure = structureMap(doc);
|
|
182
|
+
for (const [key, value] of Object.entries(document)) {
|
|
183
|
+
if (value === undefined) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (key === "children" && Array.isArray(value)) {
|
|
187
|
+
structure.set(key, toYChildren(value));
|
|
188
|
+
} else {
|
|
189
|
+
structure.set(key, cloneJson(value));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const frontmatter = frontmatterMap(doc);
|
|
193
|
+
for (const [key, value] of Object.entries(opts.frontmatter ?? {})) {
|
|
194
|
+
if (value !== undefined) {
|
|
195
|
+
frontmatter.set(key, cloneJson(value));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
meta.set("schemaVersion", COLLAB_SCHEMA_VERSION);
|
|
199
|
+
meta.set("canonical", "structure");
|
|
200
|
+
meta.set("canonicalRev", 0);
|
|
201
|
+
meta.set("sourceFormat", opts.sourceFormat ?? null);
|
|
202
|
+
meta.set("structureSeeded", true);
|
|
203
|
+
}, opts.origin);
|
|
204
|
+
return true;
|
|
205
|
+
}
|