@beyondwork/docx-react-component 1.0.46 → 1.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/public-types.ts +115 -1
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/commands/index.ts +120 -1
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/state/text-transaction.ts +17 -5
- package/src/io/chart-preview-resolver.ts +27 -0
- package/src/io/docx-session.ts +219 -2
- package/src/io/export/serialize-main-document.ts +37 -0
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
- package/src/io/ooxml/chart/parse-series.ts +570 -0
- package/src/io/ooxml/chart/resolve-color.ts +251 -0
- package/src/io/ooxml/chart/types.ts +420 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +87 -2
- package/src/io/ooxml/parse-main-document.ts +115 -1
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/workflow-payload.ts +27 -0
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +94 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +37 -5
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +404 -1
- package/src/runtime/document-runtime.ts +221 -16
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/layout/layout-engine-version.ts +27 -2
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +8 -1
- package/src/runtime/text-ack-range.ts +3 -3
- package/src/ui/WordReviewEditor.tsx +30 -0
- package/src/ui/editor-runtime-boundary.ts +6 -1
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Awareness } from "y-protocols/awareness";
|
|
2
2
|
import type { EditorStoryTarget } from "../../api/public-types";
|
|
3
|
+
import { mapPosition, type TransactionMapping } from "../../core/selection/mapping.ts";
|
|
4
|
+
import type { RuntimeCommandAppliedBridge } from "./runtime-collab-sync.ts";
|
|
3
5
|
|
|
4
6
|
export interface RemoteCursorState {
|
|
5
7
|
userId: string;
|
|
@@ -91,3 +93,168 @@ export function getRemoteCursorStates(
|
|
|
91
93
|
|
|
92
94
|
return result;
|
|
93
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Maps a remote cursor's anchor and head positions through a
|
|
99
|
+
* {@link TransactionMapping}, returning a new cursor with the positions
|
|
100
|
+
* shifted to point at the same text after the local edit has applied.
|
|
101
|
+
*
|
|
102
|
+
* Semantics:
|
|
103
|
+
* - Insertions before the cursor shift the cursor forward by the insert
|
|
104
|
+
* size.
|
|
105
|
+
* - Insertions at or after the cursor leave it in place.
|
|
106
|
+
* - Deletions before the cursor shift it backward by the deleted span.
|
|
107
|
+
* - Deletions containing the cursor collapse it to the start of the
|
|
108
|
+
* deleted range (the text the cursor was pointing at is gone).
|
|
109
|
+
*
|
|
110
|
+
* Uses the project's existing {@link mapPosition} helper with
|
|
111
|
+
* forward association (`assoc: 1`) so the cursor sticks to the right
|
|
112
|
+
* on insertions — matches Yjs / y-prosemirror conventions.
|
|
113
|
+
*
|
|
114
|
+
* Pure function — safe to call from a `commandAppliedBridge` subscriber
|
|
115
|
+
* or any caller that has captured a transaction mapping.
|
|
116
|
+
*/
|
|
117
|
+
export function mapRemoteCursorThroughMapping(
|
|
118
|
+
cursor: RemoteCursorState,
|
|
119
|
+
mapping: TransactionMapping,
|
|
120
|
+
): RemoteCursorState {
|
|
121
|
+
if (mapping.steps.length === 0) {
|
|
122
|
+
return cursor;
|
|
123
|
+
}
|
|
124
|
+
const anchor = mapPosition(cursor.anchor, 1, mapping);
|
|
125
|
+
const head = mapPosition(cursor.head, 1, mapping);
|
|
126
|
+
if (anchor.position === cursor.anchor && head.position === cursor.head) {
|
|
127
|
+
return cursor;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
...cursor,
|
|
131
|
+
anchor: anchor.position,
|
|
132
|
+
head: head.position,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RemoteCursorTrackerOptions {
|
|
137
|
+
awareness: Awareness;
|
|
138
|
+
/**
|
|
139
|
+
* The local `awareness.clientID`. Accepted as a parameter (rather
|
|
140
|
+
* than read from awareness) so hosts that gate on identity policy
|
|
141
|
+
* can reuse the value they already stamped.
|
|
142
|
+
*/
|
|
143
|
+
localClientId: number;
|
|
144
|
+
/**
|
|
145
|
+
* When provided, the tracker subscribes to every local command commit
|
|
146
|
+
* and maps each cached remote cursor through the transaction's
|
|
147
|
+
* mapping. Without this, remote cursor positions go stale on local
|
|
148
|
+
* edits until the peer republishes.
|
|
149
|
+
*/
|
|
150
|
+
commandAppliedBridge?: RuntimeCommandAppliedBridge;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface RemoteCursorTrackerHandle {
|
|
154
|
+
/**
|
|
155
|
+
* Returns the current cached view of remote cursors, with positions
|
|
156
|
+
* mapped through every local commit that has landed since the peer
|
|
157
|
+
* last published. Excludes the local client.
|
|
158
|
+
*/
|
|
159
|
+
getRemoteCursors(): RemoteCursorState[];
|
|
160
|
+
destroy(): void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Creates a stateful tracker that mirrors each peer's awareness cursor
|
|
165
|
+
* locally, maps cached positions through the current client's
|
|
166
|
+
* transaction mappings, and resets the cache from authoritative
|
|
167
|
+
* awareness state whenever a peer publishes.
|
|
168
|
+
*
|
|
169
|
+
* Without the tracker, a remote cursor in `awareness.getStates()` goes
|
|
170
|
+
* stale as soon as the local user edits — the peer's published offset
|
|
171
|
+
* still points at the pre-edit position. The tracker fixes that.
|
|
172
|
+
*/
|
|
173
|
+
export function createRemoteCursorTracker(
|
|
174
|
+
options: RemoteCursorTrackerOptions,
|
|
175
|
+
): RemoteCursorTrackerHandle {
|
|
176
|
+
const { awareness, localClientId, commandAppliedBridge } = options;
|
|
177
|
+
const cache = new Map<number, RemoteCursorState>();
|
|
178
|
+
|
|
179
|
+
function setFromAwareness(clientId: number): void {
|
|
180
|
+
if (clientId === localClientId) {
|
|
181
|
+
// Local client's own state must NEVER populate the remote view.
|
|
182
|
+
// Drop any stale entry (e.g., clientID reuse across sessions).
|
|
183
|
+
cache.delete(clientId);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const clientState = awareness.getStates().get(clientId);
|
|
187
|
+
const cursorState = clientState?.[CURSOR_STATE_KEY];
|
|
188
|
+
if (!cursorState) {
|
|
189
|
+
cache.delete(clientId);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
typeof cursorState.userId !== "string" ||
|
|
194
|
+
typeof cursorState.displayName !== "string" ||
|
|
195
|
+
typeof cursorState.anchor !== "number" ||
|
|
196
|
+
typeof cursorState.head !== "number" ||
|
|
197
|
+
!cursorState.storyTarget ||
|
|
198
|
+
typeof cursorState.storyTarget.kind !== "string"
|
|
199
|
+
) {
|
|
200
|
+
cache.delete(clientId);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const safeColor = isSafeCursorColor(cursorState.color)
|
|
204
|
+
? cursorState.color
|
|
205
|
+
: getCursorColorForUser(cursorState.userId);
|
|
206
|
+
cache.set(clientId, {
|
|
207
|
+
...(cursorState as RemoteCursorState),
|
|
208
|
+
color: safeColor,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Seed from current awareness state (covers peers that published
|
|
213
|
+
// before the tracker started).
|
|
214
|
+
for (const [clientId] of awareness.getStates()) {
|
|
215
|
+
setFromAwareness(clientId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function onAwarenessChange(changes: {
|
|
219
|
+
added: number[];
|
|
220
|
+
updated: number[];
|
|
221
|
+
removed: number[];
|
|
222
|
+
}): void {
|
|
223
|
+
// Only react to REMOTE changes. Local-only awareness updates (e.g.
|
|
224
|
+
// the local user publishing their own cursor) must not wipe the
|
|
225
|
+
// mapped remote entries.
|
|
226
|
+
for (const id of changes.removed) {
|
|
227
|
+
if (id !== localClientId) cache.delete(id);
|
|
228
|
+
}
|
|
229
|
+
for (const id of changes.added) {
|
|
230
|
+
if (id !== localClientId) setFromAwareness(id);
|
|
231
|
+
}
|
|
232
|
+
for (const id of changes.updated) {
|
|
233
|
+
if (id !== localClientId) setFromAwareness(id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
awareness.on("change", onAwarenessChange);
|
|
237
|
+
|
|
238
|
+
let unsubscribeCommandApplied: (() => void) | null = null;
|
|
239
|
+
if (commandAppliedBridge) {
|
|
240
|
+
unsubscribeCommandApplied = commandAppliedBridge.subscribe(
|
|
241
|
+
(_command, transaction) => {
|
|
242
|
+
if (transaction.mapping.steps.length === 0) return;
|
|
243
|
+
for (const [clientId, cursor] of cache) {
|
|
244
|
+
cache.set(clientId, mapRemoteCursorThroughMapping(cursor, transaction.mapping));
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
getRemoteCursors() {
|
|
252
|
+
return [...cache.values()];
|
|
253
|
+
},
|
|
254
|
+
destroy() {
|
|
255
|
+
awareness.off("change", onAwarenessChange);
|
|
256
|
+
unsubscribeCommandApplied?.();
|
|
257
|
+
cache.clear();
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -2,9 +2,12 @@ import * as Y from "yjs";
|
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
CommandExecutionContext,
|
|
5
|
+
ContentChildrenPatch,
|
|
5
6
|
EditorCommand,
|
|
6
7
|
EditorTransaction,
|
|
7
8
|
} from "../../core/commands/index.ts";
|
|
9
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
10
|
+
import type { BlockNode } from "../../model/canonical-document.ts";
|
|
8
11
|
import type {
|
|
9
12
|
CommandAppliedMeta,
|
|
10
13
|
DocumentRuntime,
|
|
@@ -14,8 +17,159 @@ import {
|
|
|
14
17
|
createCommandEvent,
|
|
15
18
|
isBroadcastCommand,
|
|
16
19
|
isLocalOnlyCommand,
|
|
20
|
+
COMMAND_EVENT_SCHEMA_VERSION,
|
|
17
21
|
type CommandEvent,
|
|
18
22
|
} from "./event-types.ts";
|
|
23
|
+
import { computeBaseDocFingerprint } from "./base-doc-fingerprint.ts";
|
|
24
|
+
import type { Checkpoint } from "./checkpoint-store.ts";
|
|
25
|
+
|
|
26
|
+
/** Shared Y.Map key — {@link SHARED_META_MAP_KEY}. */
|
|
27
|
+
const SHARED_META_MAP_KEY = "meta";
|
|
28
|
+
const META_BASE_DOC_HASH_KEY = "baseDocHash";
|
|
29
|
+
const META_SCHEMA_VERSION_KEY = "schemaVersion";
|
|
30
|
+
const META_CREATED_AT_KEY = "createdAt";
|
|
31
|
+
const CHECKPOINTS_KEY = "checkpoints";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lifecycle + correctness events surfaced by a
|
|
35
|
+
* {@link RuntimeCollabSyncHandle} subscription. Emitted in response to
|
|
36
|
+
* attach-path checks (base-doc fingerprint) and inbound replay filtering
|
|
37
|
+
* (command-event schema version). Hosts subscribe once and react to the
|
|
38
|
+
* whole stream.
|
|
39
|
+
*
|
|
40
|
+
* - `collab_sync_attached` — fires once after a successful attach with
|
|
41
|
+
* the computed `baseDocFingerprint`. Safe to ignore; surfaced so hosts
|
|
42
|
+
* can log a "session started" event or cache the fingerprint for a
|
|
43
|
+
* subsequent peer-to-peer connect.
|
|
44
|
+
* - `collab_base_doc_mismatch` — fires when the shared `meta.baseDocHash`
|
|
45
|
+
* disagrees with the local runtime's fingerprint. The sync transitions
|
|
46
|
+
* to read-only (inbound replay dropped, outbound broadcast suppressed)
|
|
47
|
+
* until the host detaches and re-attaches with a matching base doc.
|
|
48
|
+
* - `collab_event_schema_mismatch` — fires when an inbound
|
|
49
|
+
* `CommandEvent` carries a `schemaVersion` other than
|
|
50
|
+
* {@link COMMAND_EVENT_SCHEMA_VERSION}. The event is skipped; other
|
|
51
|
+
* events in the log continue to replay.
|
|
52
|
+
*/
|
|
53
|
+
export type RuntimeCollabSyncEvent =
|
|
54
|
+
| { type: "collab_sync_attached"; baseDocFingerprint: string | null }
|
|
55
|
+
| { type: "collab_base_doc_mismatch"; expected: string; actual: string }
|
|
56
|
+
| { type: "collab_event_schema_mismatch"; eventId: string; receivedSchemaVersion: unknown };
|
|
57
|
+
|
|
58
|
+
const PATCHABLE_TOP_LEVEL_KEYS = [
|
|
59
|
+
"updatedAt",
|
|
60
|
+
"metadata",
|
|
61
|
+
"styles",
|
|
62
|
+
"numbering",
|
|
63
|
+
"media",
|
|
64
|
+
"content",
|
|
65
|
+
"review",
|
|
66
|
+
"preservation",
|
|
67
|
+
"diagnostics",
|
|
68
|
+
"subParts",
|
|
69
|
+
"fieldRegistry",
|
|
70
|
+
] as const satisfies ReadonlyArray<keyof CanonicalDocumentEnvelope>;
|
|
71
|
+
|
|
72
|
+
export function compressDocumentReplaceForBroadcast(
|
|
73
|
+
command: EditorCommand,
|
|
74
|
+
priorDocument: CanonicalDocumentEnvelope,
|
|
75
|
+
): EditorCommand {
|
|
76
|
+
if (command.type !== "document.replace") {
|
|
77
|
+
return command;
|
|
78
|
+
}
|
|
79
|
+
const next = command.document;
|
|
80
|
+
if (next === priorDocument) {
|
|
81
|
+
return command;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const patch: Partial<CanonicalDocumentEnvelope> = {};
|
|
85
|
+
let anyTopLevelChange = false;
|
|
86
|
+
for (const key of PATCHABLE_TOP_LEVEL_KEYS) {
|
|
87
|
+
if (key === "content") continue;
|
|
88
|
+
if (next[key] !== priorDocument[key]) {
|
|
89
|
+
(patch as Record<string, unknown>)[key] = next[key];
|
|
90
|
+
anyTopLevelChange = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const contentChildrenPatch = diffContentChildren(
|
|
95
|
+
priorDocument.content.children,
|
|
96
|
+
next.content.children,
|
|
97
|
+
);
|
|
98
|
+
const contentMetaChanged =
|
|
99
|
+
next.content !== priorDocument.content &&
|
|
100
|
+
!contentReferenceEqualExceptChildren(priorDocument.content, next.content);
|
|
101
|
+
if (contentMetaChanged) {
|
|
102
|
+
const { children: _children, ...rest } = next.content;
|
|
103
|
+
patch.content = { ...rest, children: [] } as CanonicalDocumentEnvelope["content"];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const contentChanged =
|
|
107
|
+
contentChildrenPatch.kind !== "keep-all" || contentMetaChanged;
|
|
108
|
+
if (!contentChanged && !anyTopLevelChange) {
|
|
109
|
+
return {
|
|
110
|
+
type: "document.patch",
|
|
111
|
+
patch: {},
|
|
112
|
+
contentChildren: { kind: "keep-all" },
|
|
113
|
+
mapping: command.mapping,
|
|
114
|
+
selection: command.selection,
|
|
115
|
+
protectionSelection: command.protectionSelection,
|
|
116
|
+
origin: command.origin,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
type: "document.patch",
|
|
122
|
+
patch,
|
|
123
|
+
contentChildren: contentChildrenPatch,
|
|
124
|
+
mapping: command.mapping,
|
|
125
|
+
selection: command.selection,
|
|
126
|
+
protectionSelection: command.protectionSelection,
|
|
127
|
+
origin: command.origin,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function contentReferenceEqualExceptChildren(
|
|
132
|
+
a: CanonicalDocumentEnvelope["content"],
|
|
133
|
+
b: CanonicalDocumentEnvelope["content"],
|
|
134
|
+
): boolean {
|
|
135
|
+
const aKeys = Object.keys(a);
|
|
136
|
+
const bKeys = Object.keys(b);
|
|
137
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
138
|
+
for (const key of aKeys) {
|
|
139
|
+
if (key === "children") continue;
|
|
140
|
+
if ((a as unknown as Record<string, unknown>)[key] !== (b as unknown as Record<string, unknown>)[key]) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function diffContentChildren(
|
|
148
|
+
prior: readonly BlockNode[],
|
|
149
|
+
next: readonly BlockNode[],
|
|
150
|
+
): ContentChildrenPatch {
|
|
151
|
+
if (prior === next) {
|
|
152
|
+
return { kind: "keep-all" };
|
|
153
|
+
}
|
|
154
|
+
if (prior.length === next.length) {
|
|
155
|
+
const entries: Array<{ index: number; block: BlockNode }> = [];
|
|
156
|
+
for (let i = 0; i < next.length; i += 1) {
|
|
157
|
+
const priorBlock = prior[i];
|
|
158
|
+
const nextBlock = next[i];
|
|
159
|
+
if (priorBlock === undefined || nextBlock === undefined) {
|
|
160
|
+
return { kind: "replace-all", children: next.slice() };
|
|
161
|
+
}
|
|
162
|
+
if (priorBlock !== nextBlock) {
|
|
163
|
+
entries.push({ index: i, block: nextBlock });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (entries.length === 0) {
|
|
167
|
+
return { kind: "keep-all" };
|
|
168
|
+
}
|
|
169
|
+
return { kind: "index-map", baseLength: prior.length, entries };
|
|
170
|
+
}
|
|
171
|
+
return { kind: "replace-all", children: next.slice() };
|
|
172
|
+
}
|
|
19
173
|
|
|
20
174
|
export type RuntimeCommandAppliedListener = (
|
|
21
175
|
command: EditorCommand,
|
|
@@ -38,6 +192,31 @@ export interface RuntimeCollabSyncOptions {
|
|
|
38
192
|
|
|
39
193
|
export interface RuntimeCollabSyncHandle {
|
|
40
194
|
destroy(): void;
|
|
195
|
+
/**
|
|
196
|
+
* Subscribe to lifecycle + correctness events. Returns an
|
|
197
|
+
* unsubscribe function.
|
|
198
|
+
*/
|
|
199
|
+
subscribe(listener: (event: RuntimeCollabSyncEvent) => void): () => void;
|
|
200
|
+
/**
|
|
201
|
+
* Returns the base-doc fingerprint this sync either seeded (first peer
|
|
202
|
+
* to attach) or verified against (subsequent peers). `null` before the
|
|
203
|
+
* attach-path fingerprint check wires up in commit 2 of the P11
|
|
204
|
+
* ship, or when the fingerprint check has not yet run.
|
|
205
|
+
*/
|
|
206
|
+
getBaseDocFingerprint(): string | null;
|
|
207
|
+
/**
|
|
208
|
+
* `true` when the sync has transitioned to read-only (either a
|
|
209
|
+
* base-doc mismatch or a future shutdown condition). Read-only syncs
|
|
210
|
+
* neither broadcast outbound commands nor replay inbound events.
|
|
211
|
+
*/
|
|
212
|
+
isReadOnly(): boolean;
|
|
213
|
+
/**
|
|
214
|
+
* Current size of the internal `appliedEventIds` dedup set. Exposed
|
|
215
|
+
* for observability + tests — hosts can watch it to spot runaway
|
|
216
|
+
* growth (the checkpoint pruning path shrinks it when a new
|
|
217
|
+
* checkpoint covers previously-seen events).
|
|
218
|
+
*/
|
|
219
|
+
getAppliedEventCount(): number;
|
|
41
220
|
}
|
|
42
221
|
|
|
43
222
|
export function createRuntimeCommandAppliedBridge(): RuntimeCommandAppliedBridge {
|
|
@@ -65,7 +244,98 @@ export function createRuntimeCollabSync(
|
|
|
65
244
|
const yEvents = ydoc.getArray<CommandEvent>("commandEvents");
|
|
66
245
|
const appliedEventIds = new Set<string>();
|
|
67
246
|
|
|
247
|
+
const listeners = new Set<(event: RuntimeCollabSyncEvent) => void>();
|
|
248
|
+
let readOnly = false;
|
|
249
|
+
let baseDocFingerprint: string | null = null;
|
|
250
|
+
|
|
251
|
+
// Events emitted before any subscriber exists are buffered and flushed
|
|
252
|
+
// to the first subscriber. This lets hosts react to the attach-path
|
|
253
|
+
// base-doc mismatch and schema-version mismatch for pre-existing
|
|
254
|
+
// events — both fire synchronously during `createRuntimeCollabSync`,
|
|
255
|
+
// before the caller has had a chance to call `sync.subscribe(...)`.
|
|
256
|
+
// Buffering is one-shot: subsequent subscribers see only live events.
|
|
257
|
+
const bufferedEvents: RuntimeCollabSyncEvent[] = [];
|
|
258
|
+
let bufferFlushed = false;
|
|
259
|
+
|
|
260
|
+
function emit(event: RuntimeCollabSyncEvent): void {
|
|
261
|
+
if (!bufferFlushed && listeners.size === 0) {
|
|
262
|
+
bufferedEvents.push(event);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Snapshot to avoid re-entrant mutation during dispatch.
|
|
266
|
+
for (const listener of [...listeners]) {
|
|
267
|
+
try {
|
|
268
|
+
listener(event);
|
|
269
|
+
} catch {
|
|
270
|
+
// Listener exceptions are isolated; the sync continues.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Compute the local base-doc fingerprint once at attach time and
|
|
276
|
+
// seed-or-verify the shared `meta` Y.Map. First peer to attach to a
|
|
277
|
+
// fresh Y.Doc seeds `{ baseDocHash, schemaVersion, createdAt }`;
|
|
278
|
+
// subsequent peers verify their own fingerprint matches and transition
|
|
279
|
+
// to read-only on divergence.
|
|
280
|
+
const yMeta = ydoc.getMap<unknown>(SHARED_META_MAP_KEY);
|
|
281
|
+
const yCheckpoints = ydoc.getArray<Checkpoint>(CHECKPOINTS_KEY);
|
|
282
|
+
baseDocFingerprint = computeBaseDocFingerprint(runtime.getSessionState().canonicalDocument);
|
|
283
|
+
|
|
284
|
+
function latestCheckpointFingerprint(): string | null {
|
|
285
|
+
if (yCheckpoints.length === 0) return null;
|
|
286
|
+
const latest = yCheckpoints.get(yCheckpoints.length - 1) as Checkpoint | undefined;
|
|
287
|
+
if (!latest || !latest.documentAtCheckpoint) return null;
|
|
288
|
+
return computeBaseDocFingerprint(latest.documentAtCheckpoint);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function checkFingerprintAgainstMeta(): void {
|
|
292
|
+
if (readOnly) return;
|
|
293
|
+
const stored = yMeta.get(META_BASE_DOC_HASH_KEY);
|
|
294
|
+
if (typeof stored !== "string") return;
|
|
295
|
+
if (baseDocFingerprint === null) return;
|
|
296
|
+
if (stored === baseDocFingerprint) return;
|
|
297
|
+
// Joiner-with-checkpoint path: if the local runtime's fingerprint
|
|
298
|
+
// matches the latest checkpoint's fingerprint, accept the attach —
|
|
299
|
+
// the joiner legitimately booted from a mid-session snapshot.
|
|
300
|
+
// `meta.baseDocHash` still reflects the original base doc, which
|
|
301
|
+
// the joiner does not (and should not) reconstruct.
|
|
302
|
+
const latestCpFingerprint = latestCheckpointFingerprint();
|
|
303
|
+
if (latestCpFingerprint !== null && latestCpFingerprint === baseDocFingerprint) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
readOnly = true;
|
|
307
|
+
emit({
|
|
308
|
+
type: "collab_base_doc_mismatch",
|
|
309
|
+
expected: stored,
|
|
310
|
+
actual: baseDocFingerprint,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
ydoc.transact(() => {
|
|
315
|
+
if (yMeta.size === 0) {
|
|
316
|
+
yMeta.set(META_BASE_DOC_HASH_KEY, baseDocFingerprint);
|
|
317
|
+
yMeta.set(META_SCHEMA_VERSION_KEY, COMMAND_EVENT_SCHEMA_VERSION);
|
|
318
|
+
yMeta.set(META_CREATED_AT_KEY, new Date().toISOString());
|
|
319
|
+
} else {
|
|
320
|
+
checkFingerprintAgainstMeta();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Watch for late-arriving divergent writes — covers the race case
|
|
325
|
+
// where two peers both saw an empty `meta` and both seeded their own
|
|
326
|
+
// fingerprint, plus the case where an out-of-band peer rewrites the
|
|
327
|
+
// hash after attach. Own-writes that match our fingerprint are no-ops
|
|
328
|
+
// (stored === baseDocFingerprint short-circuits).
|
|
329
|
+
yMeta.observe(checkFingerprintAgainstMeta);
|
|
330
|
+
|
|
331
|
+
if (!readOnly) {
|
|
332
|
+
emit({ type: "collab_sync_attached", baseDocFingerprint });
|
|
333
|
+
}
|
|
334
|
+
|
|
68
335
|
const unsubscribeCommandApplied = commandAppliedBridge.subscribe((command, _transaction, context, meta) => {
|
|
336
|
+
if (readOnly) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
69
339
|
if (isLocalOnlyCommand(command)) {
|
|
70
340
|
return;
|
|
71
341
|
}
|
|
@@ -73,8 +343,13 @@ export function createRuntimeCollabSync(
|
|
|
73
343
|
return;
|
|
74
344
|
}
|
|
75
345
|
|
|
76
|
-
const
|
|
346
|
+
const broadcastCommand = compressDocumentReplaceForBroadcast(
|
|
77
347
|
command,
|
|
348
|
+
meta.priorDocument,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const event = createCommandEvent({
|
|
352
|
+
command: broadcastCommand,
|
|
78
353
|
originClientId: ydoc.clientID,
|
|
79
354
|
authorId,
|
|
80
355
|
timestamp: context.timestamp,
|
|
@@ -92,14 +367,119 @@ export function createRuntimeCollabSync(
|
|
|
92
367
|
|
|
93
368
|
yEvents.observe(onYEventsChange);
|
|
94
369
|
|
|
370
|
+
// Checkpoint-aware startup replay: if there's a checkpoint in the
|
|
371
|
+
// shared log, seed `appliedEventIds` with every event up to and
|
|
372
|
+
// including `latestCheckpoint.afterEventId` so the startup loop
|
|
373
|
+
// skips them. The caller is expected to have created `runtime` with
|
|
374
|
+
// `initialCanonicalDocument = latestCheckpoint.documentAtCheckpoint`
|
|
375
|
+
// so the snapshot + tail replay reproduces the author's state.
|
|
376
|
+
//
|
|
377
|
+
// If the `afterEventId` is not found in the current log (e.g. an
|
|
378
|
+
// out-of-order merge or a corrupt checkpoint), no events are
|
|
379
|
+
// seeded — the startup loop falls back to full replay, which is
|
|
380
|
+
// wasteful but safe thanks to the existing dedup.
|
|
381
|
+
if (yCheckpoints.length > 0) {
|
|
382
|
+
const latestCheckpoint = yCheckpoints.get(yCheckpoints.length - 1);
|
|
383
|
+
const targetAfterEventId = latestCheckpoint?.afterEventId;
|
|
384
|
+
if (typeof targetAfterEventId === "string") {
|
|
385
|
+
const events = yEvents.toArray();
|
|
386
|
+
let found = false;
|
|
387
|
+
for (const value of events) {
|
|
388
|
+
const eventId = (value as { eventId?: unknown })?.eventId;
|
|
389
|
+
if (typeof eventId !== "string") continue;
|
|
390
|
+
appliedEventIds.add(eventId);
|
|
391
|
+
if (eventId === targetAfterEventId) {
|
|
392
|
+
found = true;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (!found) {
|
|
397
|
+
// Fallback: clear the seeded ids so startup replay can proceed
|
|
398
|
+
// across the full log. Safe because the runtime was created
|
|
399
|
+
// from some canonical base (either a matching snapshot or the
|
|
400
|
+
// original doc) and the commit path is idempotent via
|
|
401
|
+
// `appliedEventIds` dedup for the live channel anyway.
|
|
402
|
+
appliedEventIds.clear();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
95
407
|
for (const value of yEvents.toArray()) {
|
|
96
408
|
applyStartupEvent(value);
|
|
97
409
|
}
|
|
98
410
|
|
|
411
|
+
// When a new checkpoint lands, prune `appliedEventIds` of every
|
|
412
|
+
// event up to and including the checkpoint's `afterEventId`. Those
|
|
413
|
+
// ids are now permanently captured in the snapshot, so the dedup
|
|
414
|
+
// set no longer needs them — Yjs doesn't re-deliver events to
|
|
415
|
+
// existing observers, so pruning is safe. Stale checkpoints whose
|
|
416
|
+
// `afterEventId` is not found in the current log leave the set
|
|
417
|
+
// intact.
|
|
418
|
+
function pruneAppliedEventIdsForCheckpoint(cursorEventId: string): void {
|
|
419
|
+
const events = yEvents.toArray();
|
|
420
|
+
const idsCovered = new Set<string>();
|
|
421
|
+
let foundCursor = false;
|
|
422
|
+
for (const value of events) {
|
|
423
|
+
const evt = value as { eventId?: unknown } | undefined;
|
|
424
|
+
const eventId = evt?.eventId;
|
|
425
|
+
if (typeof eventId !== "string") continue;
|
|
426
|
+
idsCovered.add(eventId);
|
|
427
|
+
if (eventId === cursorEventId) {
|
|
428
|
+
foundCursor = true;
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (!foundCursor) return;
|
|
433
|
+
for (const id of idsCovered) {
|
|
434
|
+
appliedEventIds.delete(id);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function onCheckpointsChange(event: Y.YArrayEvent<Checkpoint>): void {
|
|
439
|
+
for (const delta of event.changes.delta) {
|
|
440
|
+
if (!Array.isArray(delta.insert)) continue;
|
|
441
|
+
for (const value of delta.insert) {
|
|
442
|
+
const cursor = (value as { afterEventId?: unknown } | undefined)?.afterEventId;
|
|
443
|
+
if (typeof cursor !== "string") continue;
|
|
444
|
+
pruneAppliedEventIdsForCheckpoint(cursor);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
yCheckpoints.observe(onCheckpointsChange);
|
|
449
|
+
|
|
99
450
|
return {
|
|
100
451
|
destroy() {
|
|
101
452
|
unsubscribeCommandApplied();
|
|
102
453
|
yEvents.unobserve(onYEventsChange);
|
|
454
|
+
yMeta.unobserve(checkFingerprintAgainstMeta);
|
|
455
|
+
yCheckpoints.unobserve(onCheckpointsChange);
|
|
456
|
+
listeners.clear();
|
|
457
|
+
},
|
|
458
|
+
subscribe(listener) {
|
|
459
|
+
listeners.add(listener);
|
|
460
|
+
if (!bufferFlushed) {
|
|
461
|
+
bufferFlushed = true;
|
|
462
|
+
const pending = bufferedEvents.splice(0);
|
|
463
|
+
for (const event of pending) {
|
|
464
|
+
try {
|
|
465
|
+
listener(event);
|
|
466
|
+
} catch {
|
|
467
|
+
// Listener exceptions are isolated.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return () => {
|
|
472
|
+
listeners.delete(listener);
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
getBaseDocFingerprint() {
|
|
476
|
+
return baseDocFingerprint;
|
|
477
|
+
},
|
|
478
|
+
isReadOnly() {
|
|
479
|
+
return readOnly;
|
|
480
|
+
},
|
|
481
|
+
getAppliedEventCount() {
|
|
482
|
+
return appliedEventIds.size;
|
|
103
483
|
},
|
|
104
484
|
};
|
|
105
485
|
|
|
@@ -116,6 +496,9 @@ export function createRuntimeCollabSync(
|
|
|
116
496
|
}
|
|
117
497
|
|
|
118
498
|
function applyStartupEvent(value: unknown): void {
|
|
499
|
+
if (readOnly) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
119
502
|
const event = asCommandEvent(value);
|
|
120
503
|
if (!event) {
|
|
121
504
|
return;
|
|
@@ -133,6 +516,9 @@ export function createRuntimeCollabSync(
|
|
|
133
516
|
}
|
|
134
517
|
|
|
135
518
|
function applyObservedEvent(value: unknown): void {
|
|
519
|
+
if (readOnly) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
136
522
|
const event = asCommandEvent(value);
|
|
137
523
|
if (!event) {
|
|
138
524
|
return;
|
|
@@ -153,6 +539,14 @@ export function createRuntimeCollabSync(
|
|
|
153
539
|
}
|
|
154
540
|
|
|
155
541
|
function isReplayableEvent(event: CommandEvent): boolean {
|
|
542
|
+
if ((event.schemaVersion as number) !== COMMAND_EVENT_SCHEMA_VERSION) {
|
|
543
|
+
emit({
|
|
544
|
+
type: "collab_event_schema_mismatch",
|
|
545
|
+
eventId: event.eventId,
|
|
546
|
+
receivedSchemaVersion: event.schemaVersion,
|
|
547
|
+
});
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
156
550
|
if (isLocalOnlyCommand(event.command)) {
|
|
157
551
|
return false;
|
|
158
552
|
}
|
|
@@ -202,8 +596,17 @@ function asCommandEvent(value: unknown): CommandEvent | null {
|
|
|
202
596
|
return null;
|
|
203
597
|
}
|
|
204
598
|
|
|
599
|
+
// Preserve the received `schemaVersion` verbatim when present so
|
|
600
|
+
// `isReplayableEvent` can report the offending value on mismatch.
|
|
601
|
+
// Missing fields decode to `COMMAND_EVENT_SCHEMA_VERSION` — legacy
|
|
602
|
+
// peers that pre-date the field continue to replay cleanly.
|
|
603
|
+
const schemaVersion = typeof value.schemaVersion === "number"
|
|
604
|
+
? value.schemaVersion
|
|
605
|
+
: COMMAND_EVENT_SCHEMA_VERSION;
|
|
606
|
+
|
|
205
607
|
return {
|
|
206
608
|
eventId: value.eventId,
|
|
609
|
+
schemaVersion: schemaVersion as CommandEvent["schemaVersion"],
|
|
207
610
|
originClientId: value.originClientId,
|
|
208
611
|
authorId: value.authorId,
|
|
209
612
|
timestamp,
|