@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
2
|
+
import { sha256Hex } from "../../io/source-package-provenance.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Computes a deterministic fingerprint of the base-doc portion of a
|
|
6
|
+
* {@link CanonicalDocumentEnvelope}. The fingerprint is used by
|
|
7
|
+
* `createRuntimeCollabSync` to verify that every peer attaching to a
|
|
8
|
+
* shared `Y.Doc` started from the same canonical content.
|
|
9
|
+
*
|
|
10
|
+
* The fingerprint intentionally **excludes** session-local identity
|
|
11
|
+
* fields so two peers loading the same source `.docx` under different
|
|
12
|
+
* local session IDs still converge on the same hash:
|
|
13
|
+
*
|
|
14
|
+
* | Field | Included | Rationale |
|
|
15
|
+
* |---|---|---|
|
|
16
|
+
* | `schemaVersion` | yes | CDS contract version; mismatch is a different base |
|
|
17
|
+
* | `metadata` | yes | customProperties are part of the document |
|
|
18
|
+
* | `styles` | yes | style cascade is base content |
|
|
19
|
+
* | `numbering` | yes | numbering definitions are base content |
|
|
20
|
+
* | `media` | yes | embedded media are base content |
|
|
21
|
+
* | `content` | yes | the editable body tree |
|
|
22
|
+
* | `preservation` | yes | opaque fragments round-trip identically from the source |
|
|
23
|
+
* | `docId` | **no** | session-local |
|
|
24
|
+
* | `createdAt` / `updatedAt` | **no** | session-local timestamps |
|
|
25
|
+
* | `review` | **no** | tracked changes are session state |
|
|
26
|
+
* | `diagnostics` | **no** | validator output is session state |
|
|
27
|
+
* | `subParts` / `fieldRegistry` | **no** | may be partially session-computed |
|
|
28
|
+
*
|
|
29
|
+
* Hash pipeline: the subset is serialized via {@link stableStringify}
|
|
30
|
+
* (sorts object keys recursively, preserves array order, follows
|
|
31
|
+
* `JSON.stringify` semantics for `undefined`), encoded UTF-8, and fed
|
|
32
|
+
* to `sha256Hex` from `src/io/source-package-provenance.ts`. Returns a
|
|
33
|
+
* 64-char lowercase hex string.
|
|
34
|
+
*
|
|
35
|
+
* This function is pure and synchronous — safe to call at attach time
|
|
36
|
+
* without breaking the `createRuntimeCollabSync` disposer contract.
|
|
37
|
+
*/
|
|
38
|
+
export function computeBaseDocFingerprint(envelope: CanonicalDocumentEnvelope): string {
|
|
39
|
+
const subset = {
|
|
40
|
+
schemaVersion: envelope.schemaVersion,
|
|
41
|
+
metadata: envelope.metadata,
|
|
42
|
+
styles: envelope.styles,
|
|
43
|
+
numbering: envelope.numbering,
|
|
44
|
+
media: envelope.media,
|
|
45
|
+
content: envelope.content,
|
|
46
|
+
preservation: envelope.preservation,
|
|
47
|
+
};
|
|
48
|
+
const serialized = stableStringify(subset);
|
|
49
|
+
const bytes = new TextEncoder().encode(serialized);
|
|
50
|
+
return sha256Hex(bytes);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Deterministic JSON-like serializer. Object keys are sorted
|
|
55
|
+
* alphabetically at every depth; arrays preserve order; `undefined`
|
|
56
|
+
* values in objects are skipped, `undefined` values in arrays become
|
|
57
|
+
* `null` — matching `JSON.stringify` semantics. Primitives emit
|
|
58
|
+
* identically to `JSON.stringify`.
|
|
59
|
+
*
|
|
60
|
+
* Exported for unit testing and for callers that want to hash a
|
|
61
|
+
* narrower slice of the envelope.
|
|
62
|
+
*/
|
|
63
|
+
export function stableStringify(value: unknown): string {
|
|
64
|
+
if (value === null) return "null";
|
|
65
|
+
if (value === undefined) return "null";
|
|
66
|
+
|
|
67
|
+
const t = typeof value;
|
|
68
|
+
if (t === "string" || t === "number" || t === "boolean") {
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
for (const item of value) {
|
|
75
|
+
parts.push(item === undefined ? "null" : stableStringify(item));
|
|
76
|
+
}
|
|
77
|
+
return "[" + parts.join(",") + "]";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (t === "object") {
|
|
81
|
+
const obj = value as Record<string, unknown>;
|
|
82
|
+
const keys: string[] = [];
|
|
83
|
+
for (const key of Object.keys(obj)) {
|
|
84
|
+
if (obj[key] !== undefined) {
|
|
85
|
+
keys.push(key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
keys.sort();
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
for (const key of keys) {
|
|
91
|
+
parts.push(JSON.stringify(key) + ":" + stableStringify(obj[key]));
|
|
92
|
+
}
|
|
93
|
+
return "{" + parts.join(",") + "}";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Functions, symbols, bigints — should not appear in a canonical
|
|
97
|
+
// envelope. Fall back to `null` to keep the output valid JSON-ish.
|
|
98
|
+
return "null";
|
|
99
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Awareness } from "y-protocols/awareness";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Election policy for "which peer should write the next checkpoint":
|
|
5
|
+
* the peer with the lowest `clientID` currently visible in
|
|
6
|
+
* `awareness.getStates()` wins. Deterministic, zero-coordination, and
|
|
7
|
+
* stable across the network (every peer independently computes the
|
|
8
|
+
* same winner).
|
|
9
|
+
*
|
|
10
|
+
* Properties:
|
|
11
|
+
* - `clientID` is stable per Y.Doc lifetime, so a peer does not drift
|
|
12
|
+
* between elected / not-elected while connected.
|
|
13
|
+
* - If the elected peer disconnects, `awareness` emits `change` with
|
|
14
|
+
* `removed`, and the next-lowest peer observes the change + becomes
|
|
15
|
+
* elected. No consensus handshake needed.
|
|
16
|
+
* - If awareness is empty (no peers have published state yet), no peer
|
|
17
|
+
* is elected — wait for `setLocalStateField` / `setLocalState` calls
|
|
18
|
+
* to land.
|
|
19
|
+
*
|
|
20
|
+
* NOT a leader-election primitive with fencing / lease semantics. Two
|
|
21
|
+
* peers racing at exactly the same split-second could both believe
|
|
22
|
+
* they're elected and both write a checkpoint. That is benign — both
|
|
23
|
+
* writes flow into the same `Y.Array<Checkpoint>`; joiners simply see
|
|
24
|
+
* two consecutive checkpoints and apply the last one. Bandwidth cost
|
|
25
|
+
* is one extra snapshot per race, no correctness impact.
|
|
26
|
+
*/
|
|
27
|
+
export function isElectedCheckpointAuthor(
|
|
28
|
+
awareness: Awareness,
|
|
29
|
+
localClientId: number,
|
|
30
|
+
): boolean {
|
|
31
|
+
const states = awareness.getStates();
|
|
32
|
+
if (states.size === 0) return false;
|
|
33
|
+
let minClientId: number | null = null;
|
|
34
|
+
for (const [clientId] of states) {
|
|
35
|
+
if (minClientId === null || clientId < minClientId) {
|
|
36
|
+
minClientId = clientId;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return minClientId === localClientId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Subscribes to election-status changes for the local client. Fires
|
|
44
|
+
* immediately on subscribe with the current status, then on every
|
|
45
|
+
* awareness `change` where the elected status flips. Never fires
|
|
46
|
+
* consecutively with the same value.
|
|
47
|
+
*
|
|
48
|
+
* Returns an unsubscribe function.
|
|
49
|
+
*/
|
|
50
|
+
export function subscribeElectedCheckpointAuthor(
|
|
51
|
+
awareness: Awareness,
|
|
52
|
+
localClientId: number,
|
|
53
|
+
listener: (isElected: boolean) => void,
|
|
54
|
+
): () => void {
|
|
55
|
+
let lastValue: boolean | null = null;
|
|
56
|
+
|
|
57
|
+
function check(): void {
|
|
58
|
+
const elected = isElectedCheckpointAuthor(awareness, localClientId);
|
|
59
|
+
if (elected !== lastValue) {
|
|
60
|
+
lastValue = elected;
|
|
61
|
+
try {
|
|
62
|
+
listener(elected);
|
|
63
|
+
} catch {
|
|
64
|
+
// Listener errors are isolated.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
awareness.on("change", check);
|
|
70
|
+
check();
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
awareness.off("change", check);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type { CommandEvent } from "./event-types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context passed to `onTrigger` when the scheduler decides a checkpoint
|
|
7
|
+
* should be produced.
|
|
8
|
+
*
|
|
9
|
+
* - `reason: "event-count"` — the count of broadcast events since the
|
|
10
|
+
* last `markCheckpointed()` crossed `eventCountThreshold`.
|
|
11
|
+
* - `reason: "inactivity"` — no new broadcast events arrived for
|
|
12
|
+
* `inactivityMs` after the most recent one, so the document has
|
|
13
|
+
* stabilized and it is a good moment to snapshot.
|
|
14
|
+
*
|
|
15
|
+
* `afterEventId` is the `eventId` of the most recent broadcast event
|
|
16
|
+
* included in the trigger. The caller (the elected author) should
|
|
17
|
+
* pass this verbatim into the `Checkpoint.afterEventId` field so
|
|
18
|
+
* joiners know which tail events still need replay.
|
|
19
|
+
*/
|
|
20
|
+
export interface CheckpointTriggerContext {
|
|
21
|
+
reason: "event-count" | "inactivity";
|
|
22
|
+
afterEventId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CreateCheckpointSchedulerOptions {
|
|
26
|
+
ydoc: Y.Doc;
|
|
27
|
+
/**
|
|
28
|
+
* Fire a trigger once the count of broadcast events since the last
|
|
29
|
+
* `markCheckpointed()` reaches this number. Omit (or set to
|
|
30
|
+
* `undefined`) to disable count-based triggers.
|
|
31
|
+
*/
|
|
32
|
+
eventCountThreshold?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Fire a trigger once no new broadcast events have arrived for this
|
|
35
|
+
* many milliseconds (measured from the most recent event). Omit (or
|
|
36
|
+
* set to `undefined`) to disable inactivity triggers.
|
|
37
|
+
*/
|
|
38
|
+
inactivityMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Called when a threshold or inactivity window fires. The scheduler
|
|
41
|
+
* suppresses further triggers until the caller acknowledges by
|
|
42
|
+
* calling `markCheckpointed()` — preventing duplicate checkpoints
|
|
43
|
+
* while the author is still finishing its write.
|
|
44
|
+
*/
|
|
45
|
+
onTrigger: (ctx: CheckpointTriggerContext) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Override `Date.now` — used by tests to avoid real clocks. Defaults
|
|
48
|
+
* to `Date.now`.
|
|
49
|
+
*/
|
|
50
|
+
now?: () => number;
|
|
51
|
+
/**
|
|
52
|
+
* Override `setTimeout` — used by tests to inject a fake timer.
|
|
53
|
+
* Defaults to the global `setTimeout`.
|
|
54
|
+
*/
|
|
55
|
+
setTimeout?: typeof setTimeout;
|
|
56
|
+
/** Override `clearTimeout` — matches `setTimeout`. */
|
|
57
|
+
clearTimeout?: typeof clearTimeout;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CheckpointSchedulerHandle {
|
|
61
|
+
/**
|
|
62
|
+
* Resets the counter + idle state after the caller has written the
|
|
63
|
+
* corresponding checkpoint. Clears the internal "fired" flag so
|
|
64
|
+
* future thresholds can trigger again.
|
|
65
|
+
*/
|
|
66
|
+
markCheckpointed(): void;
|
|
67
|
+
destroy(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Watches a shared `Y.Array<CommandEvent>("commandEvents")` and fires
|
|
72
|
+
* `onTrigger` when a count or inactivity threshold suggests a
|
|
73
|
+
* checkpoint should be written. Stateless with respect to the actual
|
|
74
|
+
* checkpoint store — callers compose the two explicitly (election
|
|
75
|
+
* decides who writes; the writer calls `markCheckpointed()` after
|
|
76
|
+
* appending).
|
|
77
|
+
*/
|
|
78
|
+
export function createCheckpointScheduler(
|
|
79
|
+
options: CreateCheckpointSchedulerOptions,
|
|
80
|
+
): CheckpointSchedulerHandle {
|
|
81
|
+
const {
|
|
82
|
+
ydoc,
|
|
83
|
+
eventCountThreshold,
|
|
84
|
+
inactivityMs,
|
|
85
|
+
onTrigger,
|
|
86
|
+
now = Date.now,
|
|
87
|
+
setTimeout: setTimeoutFn = setTimeout,
|
|
88
|
+
clearTimeout: clearTimeoutFn = clearTimeout,
|
|
89
|
+
} = options;
|
|
90
|
+
|
|
91
|
+
const yEvents = ydoc.getArray<CommandEvent>("commandEvents");
|
|
92
|
+
const disabled = eventCountThreshold === undefined && inactivityMs === undefined;
|
|
93
|
+
|
|
94
|
+
let eventsSinceLastCheckpoint = 0;
|
|
95
|
+
let lastEventId: string | null = null;
|
|
96
|
+
let fired = false;
|
|
97
|
+
let idleTimerHandle: ReturnType<typeof setTimeout> | null = null;
|
|
98
|
+
let destroyed = false;
|
|
99
|
+
|
|
100
|
+
function clearIdleTimer(): void {
|
|
101
|
+
if (idleTimerHandle !== null) {
|
|
102
|
+
clearTimeoutFn(idleTimerHandle);
|
|
103
|
+
idleTimerHandle = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function armIdleTimer(): void {
|
|
108
|
+
if (destroyed) return;
|
|
109
|
+
if (inactivityMs === undefined) return;
|
|
110
|
+
clearIdleTimer();
|
|
111
|
+
idleTimerHandle = setTimeoutFn(() => {
|
|
112
|
+
idleTimerHandle = null;
|
|
113
|
+
if (destroyed || fired || lastEventId === null) return;
|
|
114
|
+
fired = true;
|
|
115
|
+
try {
|
|
116
|
+
onTrigger({ reason: "inactivity", afterEventId: lastEventId });
|
|
117
|
+
} catch {
|
|
118
|
+
// Caller errors are isolated; the scheduler keeps running.
|
|
119
|
+
}
|
|
120
|
+
}, inactivityMs);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Avoid pretending there was no history: on init, check what's
|
|
124
|
+
// already in the array so scheduler state reflects the current doc.
|
|
125
|
+
// We don't fire for pre-existing events — we only count new ones
|
|
126
|
+
// post-subscribe. `lastEventId` seeds from the tail so an
|
|
127
|
+
// `inactivityMs` trigger has something to report if the caller
|
|
128
|
+
// wires `now` to a later time.
|
|
129
|
+
const initial = yEvents.toArray();
|
|
130
|
+
if (initial.length > 0) {
|
|
131
|
+
const tail = initial[initial.length - 1];
|
|
132
|
+
if (tail && typeof tail.eventId === "string") {
|
|
133
|
+
lastEventId = tail.eventId;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onYEventsChange(event: Y.YArrayEvent<CommandEvent>): void {
|
|
138
|
+
if (destroyed) return;
|
|
139
|
+
if (disabled) return;
|
|
140
|
+
let inserted = 0;
|
|
141
|
+
let tailEventId: string | null = null;
|
|
142
|
+
for (const delta of event.changes.delta) {
|
|
143
|
+
if (!Array.isArray(delta.insert)) continue;
|
|
144
|
+
for (const value of delta.insert) {
|
|
145
|
+
const evt = value as CommandEvent | undefined;
|
|
146
|
+
if (evt && typeof evt.eventId === "string") {
|
|
147
|
+
inserted += 1;
|
|
148
|
+
tailEventId = evt.eventId;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (inserted === 0) return;
|
|
153
|
+
|
|
154
|
+
eventsSinceLastCheckpoint += inserted;
|
|
155
|
+
if (tailEventId !== null) lastEventId = tailEventId;
|
|
156
|
+
|
|
157
|
+
if (fired) {
|
|
158
|
+
// Suppress until caller acknowledges with markCheckpointed().
|
|
159
|
+
// Keep counter growing for accuracy on next cycle though.
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
eventCountThreshold !== undefined &&
|
|
165
|
+
eventsSinceLastCheckpoint >= eventCountThreshold &&
|
|
166
|
+
lastEventId !== null
|
|
167
|
+
) {
|
|
168
|
+
fired = true;
|
|
169
|
+
clearIdleTimer();
|
|
170
|
+
try {
|
|
171
|
+
onTrigger({ reason: "event-count", afterEventId: lastEventId });
|
|
172
|
+
} catch {
|
|
173
|
+
// Caller errors isolated.
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// New activity: reset the idle window.
|
|
179
|
+
armIdleTimer();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!disabled) {
|
|
183
|
+
yEvents.observe(onYEventsChange);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// `now` is accepted for test determinism; the scheduler doesn't read
|
|
187
|
+
// it today but keeping the option lets future slices (e.g., per-peer
|
|
188
|
+
// backoff) use a shared clock without a signature change.
|
|
189
|
+
void now;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
markCheckpointed() {
|
|
193
|
+
eventsSinceLastCheckpoint = 0;
|
|
194
|
+
fired = false;
|
|
195
|
+
clearIdleTimer();
|
|
196
|
+
},
|
|
197
|
+
destroy() {
|
|
198
|
+
if (destroyed) return;
|
|
199
|
+
destroyed = true;
|
|
200
|
+
if (!disabled) yEvents.unobserve(onYEventsChange);
|
|
201
|
+
clearIdleTimer();
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single snapshot of the canonical document at a specific point in the
|
|
7
|
+
* broadcast event log. Stored in `Y.Array<Checkpoint>("checkpoints")` so
|
|
8
|
+
* late joiners can apply the latest checkpoint instead of replaying the
|
|
9
|
+
* full command history.
|
|
10
|
+
*
|
|
11
|
+
* Contract:
|
|
12
|
+
* - `afterEventId` is the `eventId` of the last `CommandEvent` included
|
|
13
|
+
* in the snapshot. Joiners replay broadcast events whose position in
|
|
14
|
+
* the shared `Y.Array<CommandEvent>` comes AFTER the event with this
|
|
15
|
+
* id. If the id is not found (e.g. out-of-order Yjs merge), joiners
|
|
16
|
+
* fall back to replaying from the start — the runtime's existing
|
|
17
|
+
* `appliedEventIds` dedup makes that safe but wasteful.
|
|
18
|
+
* - `documentAtCheckpoint` is a full `CanonicalDocumentEnvelope`.
|
|
19
|
+
* Passing it to `createDocumentRuntime({ initialCanonicalDocument })`
|
|
20
|
+
* produces a runtime that matches the author's state at that event.
|
|
21
|
+
* - `authorClientId` identifies which peer produced the checkpoint
|
|
22
|
+
* (single author is elected deterministically — see
|
|
23
|
+
* `checkpoint-scheduler.ts` in a later slice).
|
|
24
|
+
*/
|
|
25
|
+
export interface Checkpoint {
|
|
26
|
+
checkpointId: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
afterEventId: string;
|
|
29
|
+
documentAtCheckpoint: CanonicalDocumentEnvelope;
|
|
30
|
+
authorClientId: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CHECKPOINTS_KEY = "checkpoints";
|
|
34
|
+
|
|
35
|
+
export interface CreateCheckpointStoreOptions {
|
|
36
|
+
ydoc: Y.Doc;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CheckpointStoreHandle {
|
|
40
|
+
/** Append a new checkpoint to the shared log. */
|
|
41
|
+
append(checkpoint: Checkpoint): void;
|
|
42
|
+
/** Most recent checkpoint, or `undefined` when the log is empty. */
|
|
43
|
+
latest(): Checkpoint | undefined;
|
|
44
|
+
/** All checkpoints in insertion order. */
|
|
45
|
+
all(): Checkpoint[];
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to post-insert notifications. The listener receives the
|
|
48
|
+
* current full list (cheap copy) on every change. Returns an
|
|
49
|
+
* unsubscribe function.
|
|
50
|
+
*/
|
|
51
|
+
subscribe(listener: (checkpoints: Checkpoint[]) => void): () => void;
|
|
52
|
+
/** Detach the underlying observer. Safe to call multiple times. */
|
|
53
|
+
destroy(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a thin adapter over `ydoc.getArray<Checkpoint>("checkpoints")`.
|
|
58
|
+
* The array is the canonical list — multiple stores on the same Y.Doc
|
|
59
|
+
* share state via Yjs, and peers on the network see every append.
|
|
60
|
+
*
|
|
61
|
+
* Writers should elect a single author (see
|
|
62
|
+
* `createCheckpointScheduler` + election slice) to avoid duplicate
|
|
63
|
+
* snapshots. Readers can always open a store and call `latest()`; the
|
|
64
|
+
* adapter is cheap (no internal cache beyond the Y.Array).
|
|
65
|
+
*/
|
|
66
|
+
export function createCheckpointStore(
|
|
67
|
+
options: CreateCheckpointStoreOptions,
|
|
68
|
+
): CheckpointStoreHandle {
|
|
69
|
+
const { ydoc } = options;
|
|
70
|
+
const yCheckpoints = ydoc.getArray<Checkpoint>(CHECKPOINTS_KEY);
|
|
71
|
+
|
|
72
|
+
const listeners = new Set<(checkpoints: Checkpoint[]) => void>();
|
|
73
|
+
let destroyed = false;
|
|
74
|
+
|
|
75
|
+
function onChange(): void {
|
|
76
|
+
if (destroyed) return;
|
|
77
|
+
if (listeners.size === 0) return;
|
|
78
|
+
const snapshot = yCheckpoints.toArray() as Checkpoint[];
|
|
79
|
+
for (const listener of [...listeners]) {
|
|
80
|
+
try {
|
|
81
|
+
listener(snapshot);
|
|
82
|
+
} catch {
|
|
83
|
+
// Listener exceptions are isolated; the store continues.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
yCheckpoints.observe(onChange);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
append(checkpoint) {
|
|
91
|
+
if (destroyed) return;
|
|
92
|
+
yCheckpoints.push([checkpoint]);
|
|
93
|
+
},
|
|
94
|
+
latest() {
|
|
95
|
+
const length = yCheckpoints.length;
|
|
96
|
+
if (length === 0) return undefined;
|
|
97
|
+
return yCheckpoints.get(length - 1) as Checkpoint;
|
|
98
|
+
},
|
|
99
|
+
all() {
|
|
100
|
+
return yCheckpoints.toArray() as Checkpoint[];
|
|
101
|
+
},
|
|
102
|
+
subscribe(listener) {
|
|
103
|
+
listeners.add(listener);
|
|
104
|
+
return () => {
|
|
105
|
+
listeners.delete(listener);
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
destroy() {
|
|
109
|
+
if (destroyed) return;
|
|
110
|
+
destroyed = true;
|
|
111
|
+
yCheckpoints.unobserve(onChange);
|
|
112
|
+
listeners.clear();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -28,9 +28,35 @@ import type { EditorStoryTarget } from "../../api/public-types.ts";
|
|
|
28
28
|
* current canonical state — see `applyDocumentPatch` in
|
|
29
29
|
* `core/commands/index.ts`.
|
|
30
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* Wire version of a {@link CommandEvent}. Bump to `2` on any breaking
|
|
33
|
+
* change to the envelope shape (renames, reorderings, or removed fields).
|
|
34
|
+
*
|
|
35
|
+
* **Versioning policy** — strict equality, not semver. Peers that see an
|
|
36
|
+
* event with a `schemaVersion !== COMMAND_EVENT_SCHEMA_VERSION` skip the
|
|
37
|
+
* event and emit `collab_event_schema_mismatch` on the collab-sync handle
|
|
38
|
+
* so the host can decide whether to prompt a reload. Additive changes
|
|
39
|
+
* (new optional `context` fields, new broadcast command types) do NOT
|
|
40
|
+
* bump this number; peers preserve unknown fields on decode and ignore
|
|
41
|
+
* unknown command types via the existing `BROADCAST_COMMAND_TYPES`
|
|
42
|
+
* classifier.
|
|
43
|
+
*
|
|
44
|
+
* Legacy events written before this field existed decode as `1` via the
|
|
45
|
+
* decoder fallback in `runtime-collab-sync.ts:asCommandEvent` — they
|
|
46
|
+
* continue to replay cleanly on upgraded clients.
|
|
47
|
+
*/
|
|
48
|
+
export const COMMAND_EVENT_SCHEMA_VERSION = 1 as const;
|
|
49
|
+
|
|
31
50
|
export interface CommandEvent {
|
|
32
51
|
/** UUID — dedup key for defensive idempotency. */
|
|
33
52
|
eventId: string;
|
|
53
|
+
/**
|
|
54
|
+
* Wire version of this envelope. See {@link COMMAND_EVENT_SCHEMA_VERSION}
|
|
55
|
+
* for the policy. Every event minted by `createCommandEvent` carries
|
|
56
|
+
* the current version; legacy events without this field are defaulted
|
|
57
|
+
* to `1` at decode time.
|
|
58
|
+
*/
|
|
59
|
+
schemaVersion: 1;
|
|
34
60
|
/** `Y.Doc.clientID` of the originating client. */
|
|
35
61
|
originClientId: number;
|
|
36
62
|
/** User identity for attribution (e.g. `currentUser.userId`). */
|
|
@@ -169,6 +195,7 @@ export interface CreateCommandEventInput {
|
|
|
169
195
|
export function createCommandEvent(input: CreateCommandEventInput): CommandEvent {
|
|
170
196
|
return {
|
|
171
197
|
eventId: generateEventId(),
|
|
198
|
+
schemaVersion: COMMAND_EVENT_SCHEMA_VERSION,
|
|
172
199
|
originClientId: input.originClientId,
|
|
173
200
|
authorId: input.authorId,
|
|
174
201
|
timestamp: input.timestamp,
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
export {
|
|
2
2
|
createRuntimeCollabSync,
|
|
3
3
|
createRuntimeCommandAppliedBridge,
|
|
4
|
+
type RuntimeCollabSyncEvent,
|
|
4
5
|
type RuntimeCollabSyncHandle,
|
|
5
6
|
type RuntimeCollabSyncOptions,
|
|
6
7
|
type RuntimeCommandAppliedBridge,
|
|
7
8
|
type RuntimeCommandAppliedListener,
|
|
8
9
|
} from "./runtime-collab-sync.ts";
|
|
9
10
|
export {
|
|
11
|
+
createCheckpointStore,
|
|
12
|
+
type Checkpoint,
|
|
13
|
+
type CheckpointStoreHandle,
|
|
14
|
+
type CreateCheckpointStoreOptions,
|
|
15
|
+
} from "./checkpoint-store.ts";
|
|
16
|
+
export {
|
|
17
|
+
createCheckpointScheduler,
|
|
18
|
+
type CheckpointSchedulerHandle,
|
|
19
|
+
type CheckpointTriggerContext,
|
|
20
|
+
type CreateCheckpointSchedulerOptions,
|
|
21
|
+
} from "./checkpoint-scheduler.ts";
|
|
22
|
+
export {
|
|
23
|
+
isElectedCheckpointAuthor,
|
|
24
|
+
subscribeElectedCheckpointAuthor,
|
|
25
|
+
} from "./checkpoint-election.ts";
|
|
26
|
+
export {
|
|
27
|
+
COMMAND_EVENT_SCHEMA_VERSION,
|
|
10
28
|
createCommandEvent,
|
|
11
29
|
isBroadcastCommand,
|
|
12
30
|
isLocalOnlyCommand,
|
|
@@ -15,8 +33,12 @@ export {
|
|
|
15
33
|
} from "./event-types.ts";
|
|
16
34
|
export {
|
|
17
35
|
clearLocalCursorState,
|
|
36
|
+
createRemoteCursorTracker,
|
|
18
37
|
getCursorColorForUser,
|
|
19
38
|
getRemoteCursorStates,
|
|
39
|
+
mapRemoteCursorThroughMapping,
|
|
20
40
|
setLocalCursorState,
|
|
21
41
|
type RemoteCursorState,
|
|
42
|
+
type RemoteCursorTrackerHandle,
|
|
43
|
+
type RemoteCursorTrackerOptions,
|
|
22
44
|
} from "./remote-cursor-awareness.ts";
|