@ai-matrx/office 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ Docs co-editing on the ordered log (COLLAB-DESIGN unit C3), in `./univer/collab` (loaded on demand):
6
+
7
+ - `transformDocsOp(x, y, side)` → `{ op, lost }`: ot-json1 `transformNoConflict` with our text-x transform. Rules R1–R5 from the C0b spike plus R6 (a merge retain whose style lost every key becomes a plain retain; REPLACE retains untouched). `composeDocsOps`, `invertDocsOp` (inverts a copy: `invertWithDoc` mutates its op), `normalizeDocsOp`, `applyDocsOp`. New dependency `ot-json1` (already in the graph through `@univerjs/core`).
8
+ - `docsStateHash(snapshot)` (Web Crypto SHA-256 of `canonicalDocsSnapshot`) and `stampDeterministicIds` for raw snapshots.
9
+ - `openDocsOtRoom({ port, transport, clientId, presence, replace, onNotice })`: capture of applied rich-text mutations with their pre-state inverse and command label, remote apply with `fromCollab` (unit re-addressed, `textRanges: null`, `noNeedSetTextRange`), an IME composition queue, undo/redo items rebased over every remote op (undo reverts only your own change), saves only from the chosen quiet seat with a matching hash, restore through `collab_replace`. Page setup is a barrier (§18).
10
+ - `DocumentModel.startOtRoom(factory)`, `collabMode()`, and the view port's optional `undoRedo`. Command depth per view is closed in a try/finally around `executeCommand`. `write`, `replaceContent` and `rest` route to the room when one is open.
11
+ - `documentOtFactory({ documentId, uid, clientId, onNotice, … })` and `documentUsesOrderedRoom(documentId, knob, isNew)`: the room for a cloud document (store `udt_document_snapshots`, doors over the docs host's client, Yjs awareness for presence and `quiet_hash` only).
12
+ - Evidence: the §6.4 property over the three C0 corpora, the eight owner-ruled pairs, 20,000 seeds each: 0 divergences, 0 throws; Univer's own transform diverges on the same draws (red). `DOCS_OT_FUZZ_SEEDS=20000 pnpm vitest run src/univer/collab/docs-ot/concurrent-docs-edits-converge.test.ts` (default run: 2,000 seeds).
13
+
14
+ ### Consumer action
15
+
16
+ Wire `model.startOtRoom(documentOtFactory(...))` for documents where `documentUsesOrderedRoom` is true; the 0.6 relay stays the path for every other document.
17
+
3
18
  ## 0.7.0
4
19
 
5
20
  New `./collab/sheets-transform` entry: the co-editing transformer for workbook edits (COLLAB-DESIGN unit C5a). Pure functions, no runtime wiring: nothing calls it until the Sheets flip (C6).
package/dist/docs.cjs CHANGED
@@ -45,6 +45,9 @@ function takeCovered(snapshot) {
45
45
  }
46
46
 
47
47
  // src/docs/document-model.ts
48
+ var RICH_TEXT_EDITING_ID = "doc.mutation.rich-text-editing";
49
+ var COMMAND_TYPE = 0;
50
+ var DEPTH_GUARDED = /* @__PURE__ */ Symbol.for("ai-matrx.office.docs.command-depth-guard");
48
51
  var DOCUMENT_SAVE_DELAY_MS = 2500;
49
52
  var viewIds = 0;
50
53
  var DocumentModel = (_class = class {
@@ -61,17 +64,21 @@ var DocumentModel = (_class = class {
61
64
  __init6() {this.knownSnapshots = /* @__PURE__ */ new Set()}
62
65
  __init7() {this.collabListeners = /* @__PURE__ */ new Set()}
63
66
  __init8() {this.collab = null}
67
+ /** The ordered-log room (C3); exclusive with `collab` (the 0.6 relay room). */
68
+ __init9() {this.ot = null}
69
+ __init10() {this.otStarting = null}
70
+ __init11() {this.otListeners = /* @__PURE__ */ new Set()}
64
71
  /** The saved snapshot the body started from, and the room entries it already holds. */
65
- __init9() {this.baseId = null}
66
- __init10() {this.baseCovered = /* @__PURE__ */ new Set()}
67
- __init11() {this.collabStarting = null}
68
- __init12() {this.awareness = null}
69
- __init13() {this.awarenessListeners = /* @__PURE__ */ new Set()}
70
- __init14() {this.realtimeClose = null}
71
- __init15() {this.pageClose = null}
72
+ __init12() {this.baseId = null}
73
+ __init13() {this.baseCovered = /* @__PURE__ */ new Set()}
74
+ __init14() {this.collabStarting = null}
75
+ __init15() {this.awareness = null}
76
+ __init16() {this.awarenessListeners = /* @__PURE__ */ new Set()}
77
+ __init17() {this.realtimeClose = null}
78
+ __init18() {this.pageClose = null}
72
79
  /** The last view's editor, alive and off the page until a view takes it back. */
73
- __init16() {this.parked = null}
74
- constructor(handle, deps) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);_class.prototype.__init7.call(this);_class.prototype.__init8.call(this);_class.prototype.__init9.call(this);_class.prototype.__init10.call(this);_class.prototype.__init11.call(this);_class.prototype.__init12.call(this);_class.prototype.__init13.call(this);_class.prototype.__init14.call(this);_class.prototype.__init15.call(this);_class.prototype.__init16.call(this);_class.prototype.__init17.call(this);_class.prototype.__init18.call(this);_class.prototype.__init19.call(this);_class.prototype.__init20.call(this);
80
+ __init19() {this.parked = null}
81
+ constructor(handle, deps) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);_class.prototype.__init7.call(this);_class.prototype.__init8.call(this);_class.prototype.__init9.call(this);_class.prototype.__init10.call(this);_class.prototype.__init11.call(this);_class.prototype.__init12.call(this);_class.prototype.__init13.call(this);_class.prototype.__init14.call(this);_class.prototype.__init15.call(this);_class.prototype.__init16.call(this);_class.prototype.__init17.call(this);_class.prototype.__init18.call(this);_class.prototype.__init19.call(this);_class.prototype.__init20.call(this);_class.prototype.__init21.call(this);_class.prototype.__init22.call(this);_class.prototype.__init23.call(this);_class.prototype.__init24.call(this);
75
82
  this.documentId = handle.id;
76
83
  this.handle = handle;
77
84
  this.deps = deps;
@@ -124,7 +131,14 @@ var DocumentModel = (_class = class {
124
131
  const subscription = port.commandService.onMutationExecutedForCollab(
125
132
  (info, options) => this.onViewMutation(id, info, options)
126
133
  );
127
- this.views.set(id, { id, port, dispose: () => subscription.dispose() });
134
+ const view = { id, port, dispose: () => {
135
+ }, commands: [], pre: null };
136
+ const hooks = this.trackCommands(view);
137
+ view.dispose = () => {
138
+ subscription.dispose();
139
+ hooks();
140
+ };
141
+ this.views.set(id, view);
128
142
  if (!this.handle.hasPending()) {
129
143
  try {
130
144
  const mounted = port.snapshot();
@@ -137,8 +151,8 @@ var DocumentModel = (_class = class {
137
151
  return () => {
138
152
  if (detached) return;
139
153
  detached = true;
140
- const view = this.views.get(id);
141
- if (!view) return;
154
+ const view2 = this.views.get(id);
155
+ if (!view2) return;
142
156
  if (this.views.size === 1) {
143
157
  try {
144
158
  const snapshot = port.snapshot();
@@ -147,7 +161,7 @@ var DocumentModel = (_class = class {
147
161
  console.warn(`[document-model] could not keep ${this.documentId}'s last view's document`, error);
148
162
  }
149
163
  }
150
- view.dispose();
164
+ view2.dispose();
151
165
  this.views.delete(id);
152
166
  if (this.lastEditedView === id) this.lastEditedView = null;
153
167
  };
@@ -171,19 +185,74 @@ var DocumentModel = (_class = class {
171
185
  return editor;
172
186
  }
173
187
  onViewMutation(viewId, info, options) {
174
- if (this.relaying || _optionalChain([options, 'optionalAccess', _ => _.fromCollab]) || _optionalChain([options, 'optionalAccess', _2 => _2.fromChangeset])) return;
188
+ if (this.relaying || _optionalChain([options, 'optionalAccess', _7 => _7.fromCollab]) || _optionalChain([options, 'optionalAccess', _8 => _8.fromChangeset])) return;
175
189
  const view = this.views.get(viewId);
176
190
  if (!view || !view.port.editable()) return;
177
191
  this.lastEditedView = viewId;
178
192
  this.relay(info, viewId);
179
193
  for (const listener of this.collabListeners) listener(info, options);
194
+ if (this.otListeners.size && !_optionalChain([options, 'optionalAccess', _9 => _9.onlyLocal])) {
195
+ const m = {
196
+ id: info.id,
197
+ params: info.params,
198
+ pre: info.id === RICH_TEXT_EDITING_ID ? view.pre : null,
199
+ cmd: _nullishCoalesce(view.commands[0], () => ( null)),
200
+ content: isContentMutation(info)
201
+ };
202
+ view.pre = null;
203
+ for (const listener of Array.from(this.otListeners)) listener(m);
204
+ }
180
205
  if (isContentMutation(info)) this.markDirty();
181
206
  }
207
+ /**
208
+ * Per view: the outermost command running (a mutation's group label) and, while a room listens, the document
209
+ * before each rich-text mutation (its inverse is computed from the APPLIED actions against this copy). Univer's
210
+ * `executeCommand` has no `finally`: a command that mutates then throws never fires `onCommandExecuted`, so
211
+ * the depth is restored in a try/finally around the instance's `executeCommand` (C0 surprise 7).
212
+ */
213
+ trackCommands(view) {
214
+ const cs = view.port.commandService;
215
+ if (typeof cs.beforeCommandExecuted !== "function" || typeof cs.onCommandExecuted !== "function") return () => {
216
+ };
217
+ const before = cs.beforeCommandExecuted((info) => {
218
+ if (info.type === COMMAND_TYPE) view.commands.push(info.id);
219
+ else if (info.id === RICH_TEXT_EDITING_ID && this.otListeners.size && !this.relaying) {
220
+ try {
221
+ const snap = view.port.snapshot();
222
+ view.pre = snap ? structuredClone(snap) : null;
223
+ } catch (e3) {
224
+ view.pre = null;
225
+ }
226
+ }
227
+ });
228
+ const after = cs.onCommandExecuted((info) => {
229
+ if (info.type !== COMMAND_TYPE) return;
230
+ const at = view.commands.lastIndexOf(info.id);
231
+ if (at >= 0) view.commands.splice(at, 1);
232
+ });
233
+ const guarded = cs;
234
+ if (typeof cs.executeCommand === "function" && !guarded[DEPTH_GUARDED]) {
235
+ const original = cs.executeCommand.bind(cs);
236
+ guarded[DEPTH_GUARDED] = true;
237
+ cs.executeCommand = async (id, params, options) => {
238
+ const depth = view.commands.length;
239
+ try {
240
+ return await original(id, params, options);
241
+ } finally {
242
+ if (view.commands.length > depth) view.commands.length = depth;
243
+ }
244
+ };
245
+ }
246
+ return () => {
247
+ before.dispose();
248
+ after.dispose();
249
+ };
250
+ }
182
251
  /** The unit id a view's document has (what a replayed mutation must be addressed to). */
183
252
  unitIdOf(view) {
184
253
  try {
185
- return _nullishCoalesce(_nullishCoalesce(_optionalChain([view, 'access', _3 => _3.port, 'access', _4 => _4.unitId, 'optionalCall', _5 => _5()]), () => ( _optionalChain([view, 'access', _6 => _6.port, 'access', _7 => _7.snapshot, 'call', _8 => _8(), 'optionalAccess', _9 => _9.id]))), () => ( null));
186
- } catch (e3) {
254
+ return _nullishCoalesce(_nullishCoalesce(_optionalChain([view, 'access', _10 => _10.port, 'access', _11 => _11.unitId, 'optionalCall', _12 => _12()]), () => ( _optionalChain([view, 'access', _13 => _13.port, 'access', _14 => _14.snapshot, 'call', _15 => _15(), 'optionalAccess', _16 => _16.id]))), () => ( null));
255
+ } catch (e4) {
187
256
  return null;
188
257
  }
189
258
  }
@@ -216,7 +285,7 @@ var DocumentModel = (_class = class {
216
285
  /** A view could not follow an edit: say so, then show the saved version (unless this tab has unsaved edits of its own). */
217
286
  applyFailed(mutationId, error) {
218
287
  try {
219
- _optionalChain([this, 'access', _10 => _10.deps, 'access', _11 => _11.onApplyFailed, 'optionalCall', _12 => _12(this.documentId, mutationId, error)]);
288
+ _optionalChain([this, 'access', _17 => _17.deps, 'access', _18 => _18.onApplyFailed, 'optionalCall', _19 => _19(this.documentId, mutationId, error)]);
220
289
  } catch (notifyError) {
221
290
  console.warn(`[document-model] the notice for ${this.documentId}'s failed apply also failed`, notifyError);
222
291
  }
@@ -229,19 +298,24 @@ var DocumentModel = (_class = class {
229
298
  markDirty() {
230
299
  this.handle.touch();
231
300
  }
232
- __init17() {this.saving = false}
301
+ __init20() {this.saving = false}
233
302
  /**
234
303
  * The content of the version the server holds (loaded or last written). A
235
304
  * save of the same content is no save: attaching, detaching, waking or a
236
305
  * flush of an unchanged document never writes a snapshot row.
237
306
  */
238
- __init18() {this.savedFingerprint = null}
307
+ __init21() {this.savedFingerprint = null}
239
308
  /**
240
309
  * Write the body as it is now. Returns false when this tab is a collab peer
241
310
  * that does not write (the elected host writes the canonical snapshot; a
242
311
  * peer's edits reach it through the room). An explicit Save always writes.
243
312
  */
244
313
  async write(reason) {
314
+ if (this.ot) {
315
+ const wrote = await this.ot.write(reason);
316
+ if (wrote) _optionalChain([this, 'access', _20 => _20.deps, 'access', _21 => _21.onSaved, 'optionalCall', _22 => _22(this.documentId, reason === "manual" ? "manual" : "autosave")]);
317
+ return wrote;
318
+ }
245
319
  const snapshot = this.latest();
246
320
  if (!snapshot) throw new Error("There is no open document to save.");
247
321
  if (this.collab && !this.collab.isHost() && reason !== "manual") return false;
@@ -264,7 +338,7 @@ var DocumentModel = (_class = class {
264
338
  this.saving = false;
265
339
  }
266
340
  if (this.views.size === 0) this.stored = snapshot;
267
- _optionalChain([this, 'access', _13 => _13.deps, 'access', _14 => _14.onSaved, 'optionalCall', _15 => _15(this.documentId, origin)]);
341
+ _optionalChain([this, 'access', _23 => _23.deps, 'access', _24 => _24.onSaved, 'optionalCall', _25 => _25(this.documentId, origin)]);
268
342
  return true;
269
343
  }
270
344
  hasUnsaved() {
@@ -286,7 +360,7 @@ var DocumentModel = (_class = class {
286
360
  * person chooses; `overUnsaved` is "Take theirs" (`./documentModels.ts`).
287
361
  */
288
362
  async onRemoteSnapshot(snapshotId, { overUnsaved = false } = {}) {
289
- if (this.collab) return;
363
+ if (this.collab || this.ot) return;
290
364
  if (this.knownSnapshots.has(snapshotId)) return;
291
365
  if (this.saving) return;
292
366
  if (this.hasUnsaved() && !overUnsaved) {
@@ -305,8 +379,9 @@ var DocumentModel = (_class = class {
305
379
  * room the peers reload it and the shared log is emptied.
306
380
  */
307
381
  replaceContent(row) {
382
+ if (this.ot) return this.ot.replace(row.snapshot);
308
383
  this.show(row);
309
- _optionalChain([this, 'access', _16 => _16.collab, 'optionalAccess', _17 => _17.announceReplaced, 'call', _18 => _18(row.id)]);
384
+ _optionalChain([this, 'access', _26 => _26.collab, 'optionalAccess', _27 => _27.announceReplaced, 'call', _28 => _28(row.id)]);
310
385
  }
311
386
  /** A peer replaced the document: show the newest save (once). */
312
387
  async onPeerReplaced() {
@@ -329,7 +404,7 @@ var DocumentModel = (_class = class {
329
404
  this.relaying = true;
330
405
  try {
331
406
  for (const view of this.views.values()) view.port.remount(snapshot);
332
- _optionalChain([this, 'access', _19 => _19.parked, 'optionalAccess', _20 => _20.remount, 'call', _21 => _21(snapshot)]);
407
+ _optionalChain([this, 'access', _29 => _29.parked, 'optionalAccess', _30 => _30.remount, 'call', _31 => _31(snapshot)]);
333
408
  } finally {
334
409
  this.relaying = false;
335
410
  }
@@ -340,7 +415,7 @@ var DocumentModel = (_class = class {
340
415
  * binds to. Local edits from any view go out once; a peer's edit is applied
341
416
  * to every view.
342
417
  */
343
- __init19() {this.virtualCommandService = {
418
+ __init22() {this.virtualCommandService = {
344
419
  onMutationExecutedForCollab: (listener) => {
345
420
  this.collabListeners.add(listener);
346
421
  return { dispose: () => this.collabListeners.delete(listener) };
@@ -388,7 +463,79 @@ var DocumentModel = (_class = class {
388
463
  }
389
464
  return this.collabStarting;
390
465
  }
466
+ /** The model as the ordered-log room sees it (COLLAB-DESIGN §6.7: the model is the client, not the view). */
467
+ __init23() {this.otPort = {
468
+ onLocal: (listener) => {
469
+ this.otListeners.add(listener);
470
+ return () => void this.otListeners.delete(listener);
471
+ },
472
+ applyRemote: (id, params) => {
473
+ if (this.views.size === 0) throw new Error("no view of the document is mounted");
474
+ this.relaying = true;
475
+ try {
476
+ for (const view of this.views.values()) {
477
+ const unitId = this.unitIdOf(view);
478
+ const input = Array.isArray(params.actions) ? params.actions.length : 0;
479
+ const p = { ...params, ...unitId ? { unitId } : {}, textRanges: null, noNeedSetTextRange: true };
480
+ const ok = view.port.commandService.syncExecuteCommand(id, p, { onlyLocal: true, fromCollab: true });
481
+ if (ok === false) throw new Error(`"${id}" was refused by the editor`);
482
+ if (input > 0 && Array.isArray(p.actions) && p.actions.length === 0) throw new Error(`"${id}" was emptied by the editor`);
483
+ }
484
+ } finally {
485
+ this.relaying = false;
486
+ }
487
+ if (isContentMutation({ id, type: 2, params })) this.markDirty();
488
+ },
489
+ snapshot: () => this.latest(),
490
+ load: (snapshot) => {
491
+ const s2 = snapshot;
492
+ this.stored = s2;
493
+ this.savedFingerprint = contentFingerprint(s2);
494
+ this.relaying = true;
495
+ try {
496
+ for (const view of this.views.values()) view.port.remount(s2);
497
+ _optionalChain([this, 'access', _32 => _32.parked, 'optionalAccess', _33 => _33.remount, 'call', _34 => _34(s2)]);
498
+ } finally {
499
+ this.relaying = false;
500
+ }
501
+ },
502
+ undoTargets: () => {
503
+ const out = [];
504
+ for (const view of this.views.values()) {
505
+ const unitId = this.unitIdOf(view);
506
+ const service = _optionalChain([view, 'access', _35 => _35.port, 'access', _36 => _36.undoRedo, 'optionalCall', _37 => _37()]);
507
+ if (unitId && service) out.push({ service, unitId });
508
+ }
509
+ return out;
510
+ },
511
+ markDirty: () => this.markDirty()
512
+ }}
513
+ /** Join the document's ordered-log room once (C3). Exclusive with `startCollab`. */
514
+ startOtRoom(factory) {
515
+ if (this.ot || this.collab) return Promise.resolve();
516
+ if (!this.otStarting) {
517
+ this.otStarting = factory(this.otPort, (awareness) => {
518
+ this.awareness = awareness;
519
+ for (const listener of Array.from(this.awarenessListeners)) listener();
520
+ }).then((handle) => {
521
+ if (!handle) return;
522
+ if (this.views.size === 0) {
523
+ handle.stop();
524
+ return;
525
+ }
526
+ this.ot = handle;
527
+ }).finally(() => {
528
+ this.otStarting = null;
529
+ });
530
+ }
531
+ return this.otStarting;
532
+ }
533
+ /** Which co-editing room this document is in right now. */
534
+ collabMode() {
535
+ return this.ot ? "ordered" : this.collab ? "relay" : null;
536
+ }
391
537
  isCollabHost() {
538
+ if (this.ot) return this.ot.isSaver();
392
539
  return this.collab ? this.collab.isHost() : true;
393
540
  }
394
541
  getAwareness() {
@@ -414,11 +561,13 @@ var DocumentModel = (_class = class {
414
561
  * so a collaborator's save still reaches the body a returning view boots from.
415
562
  */
416
563
  rest() {
417
- _optionalChain([this, 'access', _22 => _22.collab, 'optionalAccess', _23 => _23.stop, 'call', _24 => _24()]);
564
+ _optionalChain([this, 'access', _38 => _38.collab, 'optionalAccess', _39 => _39.stop, 'call', _40 => _40()]);
418
565
  this.collab = null;
566
+ _optionalChain([this, 'access', _41 => _41.ot, 'optionalAccess', _42 => _42.stop, 'call', _43 => _43()]);
567
+ this.ot = null;
419
568
  this.awareness = null;
420
569
  }
421
- __init20() {this.closed = false}
570
+ __init24() {this.closed = false}
422
571
  close() {
423
572
  this.closed = true;
424
573
  const parked = this.parked;
@@ -430,9 +579,9 @@ var DocumentModel = (_class = class {
430
579
  console.warn(`[document-model] could not dispose ${this.documentId}'s kept editor`, error);
431
580
  }
432
581
  }
433
- _optionalChain([this, 'access', _25 => _25.realtimeClose, 'optionalCall', _26 => _26()]);
582
+ _optionalChain([this, 'access', _44 => _44.realtimeClose, 'optionalCall', _45 => _45()]);
434
583
  this.realtimeClose = null;
435
- _optionalChain([this, 'access', _27 => _27.pageClose, 'optionalCall', _28 => _28()]);
584
+ _optionalChain([this, 'access', _46 => _46.pageClose, 'optionalCall', _47 => _47()]);
436
585
  this.pageClose = null;
437
586
  }
438
587
  }, _class);