@beyondwork/docx-react-component 1.0.47 → 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/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 +226 -38
- 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/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 +27 -0
- 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 +279 -0
- package/src/runtime/document-runtime.ts +214 -16
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- 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/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
|
+
}
|
|
@@ -17,8 +17,43 @@ import {
|
|
|
17
17
|
createCommandEvent,
|
|
18
18
|
isBroadcastCommand,
|
|
19
19
|
isLocalOnlyCommand,
|
|
20
|
+
COMMAND_EVENT_SCHEMA_VERSION,
|
|
20
21
|
type CommandEvent,
|
|
21
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 };
|
|
22
57
|
|
|
23
58
|
const PATCHABLE_TOP_LEVEL_KEYS = [
|
|
24
59
|
"updatedAt",
|
|
@@ -157,6 +192,31 @@ export interface RuntimeCollabSyncOptions {
|
|
|
157
192
|
|
|
158
193
|
export interface RuntimeCollabSyncHandle {
|
|
159
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;
|
|
160
220
|
}
|
|
161
221
|
|
|
162
222
|
export function createRuntimeCommandAppliedBridge(): RuntimeCommandAppliedBridge {
|
|
@@ -184,7 +244,98 @@ export function createRuntimeCollabSync(
|
|
|
184
244
|
const yEvents = ydoc.getArray<CommandEvent>("commandEvents");
|
|
185
245
|
const appliedEventIds = new Set<string>();
|
|
186
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
|
+
|
|
187
335
|
const unsubscribeCommandApplied = commandAppliedBridge.subscribe((command, _transaction, context, meta) => {
|
|
336
|
+
if (readOnly) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
188
339
|
if (isLocalOnlyCommand(command)) {
|
|
189
340
|
return;
|
|
190
341
|
}
|
|
@@ -216,14 +367,119 @@ export function createRuntimeCollabSync(
|
|
|
216
367
|
|
|
217
368
|
yEvents.observe(onYEventsChange);
|
|
218
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
|
+
|
|
219
407
|
for (const value of yEvents.toArray()) {
|
|
220
408
|
applyStartupEvent(value);
|
|
221
409
|
}
|
|
222
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
|
+
|
|
223
450
|
return {
|
|
224
451
|
destroy() {
|
|
225
452
|
unsubscribeCommandApplied();
|
|
226
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;
|
|
227
483
|
},
|
|
228
484
|
};
|
|
229
485
|
|
|
@@ -240,6 +496,9 @@ export function createRuntimeCollabSync(
|
|
|
240
496
|
}
|
|
241
497
|
|
|
242
498
|
function applyStartupEvent(value: unknown): void {
|
|
499
|
+
if (readOnly) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
243
502
|
const event = asCommandEvent(value);
|
|
244
503
|
if (!event) {
|
|
245
504
|
return;
|
|
@@ -257,6 +516,9 @@ export function createRuntimeCollabSync(
|
|
|
257
516
|
}
|
|
258
517
|
|
|
259
518
|
function applyObservedEvent(value: unknown): void {
|
|
519
|
+
if (readOnly) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
260
522
|
const event = asCommandEvent(value);
|
|
261
523
|
if (!event) {
|
|
262
524
|
return;
|
|
@@ -277,6 +539,14 @@ export function createRuntimeCollabSync(
|
|
|
277
539
|
}
|
|
278
540
|
|
|
279
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
|
+
}
|
|
280
550
|
if (isLocalOnlyCommand(event.command)) {
|
|
281
551
|
return false;
|
|
282
552
|
}
|
|
@@ -326,8 +596,17 @@ function asCommandEvent(value: unknown): CommandEvent | null {
|
|
|
326
596
|
return null;
|
|
327
597
|
}
|
|
328
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
|
+
|
|
329
607
|
return {
|
|
330
608
|
eventId: value.eventId,
|
|
609
|
+
schemaVersion: schemaVersion as CommandEvent["schemaVersion"],
|
|
331
610
|
originClientId: value.originClientId,
|
|
332
611
|
authorId: value.authorId,
|
|
333
612
|
timestamp,
|