@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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +115 -1
  3. package/src/compare/diff-engine.ts +4 -0
  4. package/src/core/commands/add-scope.ts +257 -0
  5. package/src/core/commands/formatting-commands.ts +2 -0
  6. package/src/core/schema/text-schema.ts +95 -1
  7. package/src/core/state/text-transaction.ts +17 -5
  8. package/src/io/chart-preview-resolver.ts +27 -0
  9. package/src/io/docx-session.ts +226 -38
  10. package/src/io/export/serialize-main-document.ts +37 -0
  11. package/src/io/export/serialize-settings.ts +421 -0
  12. package/src/io/export/serialize-styles.ts +10 -0
  13. package/src/io/normalize/normalize-text.ts +1 -0
  14. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  15. package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
  16. package/src/io/ooxml/chart/parse-series.ts +570 -0
  17. package/src/io/ooxml/chart/resolve-color.ts +251 -0
  18. package/src/io/ooxml/chart/types.ts +420 -0
  19. package/src/io/ooxml/parse-block-structure.ts +99 -0
  20. package/src/io/ooxml/parse-complex-content.ts +87 -2
  21. package/src/io/ooxml/parse-main-document.ts +115 -1
  22. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  23. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  24. package/src/io/ooxml/parse-settings.ts +97 -1
  25. package/src/io/ooxml/parse-styles.ts +65 -0
  26. package/src/io/ooxml/parse-theme.ts +2 -127
  27. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  28. package/src/io/ooxml/xml-parser.ts +142 -0
  29. package/src/model/canonical-document.ts +94 -0
  30. package/src/model/scope-markers.ts +144 -0
  31. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  32. package/src/runtime/collab/checkpoint-election.ts +75 -0
  33. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  34. package/src/runtime/collab/checkpoint-store.ts +115 -0
  35. package/src/runtime/collab/event-types.ts +27 -0
  36. package/src/runtime/collab/index.ts +22 -0
  37. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  38. package/src/runtime/collab/runtime-collab-sync.ts +279 -0
  39. package/src/runtime/document-runtime.ts +214 -16
  40. package/src/runtime/editor-surface/capabilities.ts +63 -50
  41. package/src/runtime/layout/layout-engine-version.ts +8 -1
  42. package/src/runtime/prerender/cache-envelope.ts +19 -7
  43. package/src/runtime/prerender/cache-key.ts +25 -14
  44. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  45. package/src/runtime/prerender/customxml-cache.ts +211 -0
  46. package/src/runtime/prerender/customxml-probe.ts +78 -0
  47. package/src/runtime/prerender/prerender-document.ts +74 -7
  48. package/src/runtime/scope-resolver.ts +148 -0
  49. package/src/runtime/scope-tag-registry.ts +10 -0
  50. package/src/runtime/surface-projection.ts +8 -1
  51. package/src/ui/WordReviewEditor.tsx +30 -0
  52. package/src/ui/editor-runtime-boundary.ts +6 -1
  53. 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,