@adonis-agora/collaboration 0.19.0 → 0.19.2

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,23 @@
1
1
  # @adonis-agora/collaboration
2
2
 
3
+ ## 0.19.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#138](https://github.com/DavideCarvalho/adonis-agora-collaboration/pull/138) [`894997d`](https://github.com/DavideCarvalho/adonis-agora-collaboration/commit/894997d002cd20a9d9fd19107812005c77eb24a1) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Load heavy engine dependencies on first use instead of at import time. `@automerge/automerge` now loads when an automerge driver first opens a document, `@hocuspocus/transformer` (Tiptap + ProseMirror) when a document's text or a diff is first computed, and `@hocuspocus/server` when the yjs driver first attaches to the HTTP server. Importing the package root or its provider drops from ~10.5 MB to ~3.9 MB of heap (and ~100 ms of module evaluation) in every process that never opens a document — ace commands, queue workers, and web pods until the first collaboration request. No API change.
8
+
9
+ ## 0.19.1
10
+
11
+ ### Patch Changes
12
+
13
+ - [#135](https://github.com/DavideCarvalho/adonis-agora-collaboration/pull/135) [`718d1cc`](https://github.com/DavideCarvalho/adonis-agora-collaboration/commit/718d1cce9bfd30f2c1f3bc77603a9bd6d230915e) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Automerge: clients and server exchange change chunks instead of whole documents. `useAutomergeDoc` asks for them with `?sync=changes`; the server acknowledges with a text frame and from then on both sides send only the changes the other lacks. Sockets that do not ask (older clients) keep sending and receiving whole documents, and a new client against an older server keeps sending whole documents, so mixed versions keep working in the same room. The server applies both shapes with `A.loadIncremental` and never forwards a payload that adds nothing.
14
+
15
+ Automerge rooms are now saved and dropped from memory when their last client leaves, `close()` writes pending saves instead of cancelling them (and no longer hangs while sockets are open), and a document edited without pause is saved at least every 10 seconds.
16
+
17
+ A socket whose presence join fails, or that closes while the join is in flight, is now taken out of its room (which can then be dropped) and its possibly half-written roster entry is removed, unless the user still has another tab on the document.
18
+
19
+ - [#135](https://github.com/DavideCarvalho/adonis-agora-collaboration/pull/135) [`718d1cc`](https://github.com/DavideCarvalho/adonis-agora-collaboration/commit/718d1cce9bfd30f2c1f3bc77603a9bd6d230915e) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Presence: each roster entry now expires on its own 60-second TTL, and every live instance re-stamps the members it holds every 20 seconds, so the users of a crashed instance drop out even on a document other instances keep busy. The stored member format is unchanged. The Yjs driver no longer scans every open socket on each disconnect, and the Automerge driver no longer scans the room's sockets when one closes: both keep a socket count per user per document.
20
+
3
21
  ## 0.19.0
4
22
 
5
23
  ### Minor Changes
@@ -1,12 +1,16 @@
1
1
  import type { CollaborationDriver, SelfHostedDriverOptions } from '../../driver.js';
2
2
  import type { CollabDiffSummary, CollabVersion, ListPageOptions } from '../../types.js';
3
+ /** Text frame confirming that this socket exchanges change chunks. */
4
+ export declare const CHANGES_ACK = "automerge-sync:changes";
3
5
  export declare class AutomergeDriver implements CollaborationDriver {
4
6
  readonly engine = "automerge";
5
7
  private rooms;
6
8
  private wss?;
7
9
  private readonly options;
8
10
  private readonly storage;
9
- private persistTimers;
11
+ private pendingSaves;
12
+ /** Saves being written, by document name; a room is not dropped under one. */
13
+ private savesInFlight;
10
14
  /**
11
15
  * Rooms being opened, by document name.
12
16
  *
@@ -17,6 +21,14 @@ export declare class AutomergeDriver implements CollaborationDriver {
17
21
  * duplicated content, so the window has to close.
18
22
  */
19
23
  private roomLoads;
24
+ /**
25
+ * `@automerge/automerge`, loaded by the first call that opens a room or reads
26
+ * a version (see `lazy_deps.ts`). Every synchronous use runs on a room, and a
27
+ * room only exists after `openRoom` awaited the module, so it is set there.
28
+ */
29
+ private am;
30
+ /** Loads Automerge (once per process) and makes it available as `this.am`. */
31
+ private automerge;
20
32
  constructor(options: SelfHostedDriverOptions);
21
33
  private loadRoom;
22
34
  private openRoom;
@@ -32,10 +44,46 @@ export declare class AutomergeDriver implements CollaborationDriver {
32
44
  * than a document worth guessing at.
33
45
  */
34
46
  private seedDocument;
47
+ /**
48
+ * Sends everything the room gained since `since` to every client but the
49
+ * sender: the change chunk to sockets that speak chunks, a whole document
50
+ * (`wholeDoc`, else `A.save` of the room) to the rest. Each shape is
51
+ * encoded at most once per call.
52
+ */
35
53
  private broadcast;
54
+ /** Runs `fn` on the room, dropping it afterwards if nobody else uses it. */
55
+ private withRoom;
56
+ /**
57
+ * Writes the room's pending save and drops it from memory once nothing
58
+ * uses it: no client, no driver call, no save waiting or being written.
59
+ * A client that joins meanwhile finds the same room in the map.
60
+ */
61
+ private releaseRoom;
36
62
  attach(server: import('node:http').Server): Promise<void>;
63
+ /**
64
+ * Takes `ws` out of the room — and, when `userId` is given, out of that
65
+ * user's socket count, leaving the roster with their last one. Safe to call
66
+ * twice for one socket.
67
+ */
68
+ private removeSocket;
69
+ /** Removes `userId` from the roster unless another of their sockets is still in the room. */
70
+ private leaveIfLast;
71
+ /**
72
+ * Applies a client's whole document or change chunk and forwards only what
73
+ * was new to the room. A payload that adds nothing — a duplicate, or bytes
74
+ * that are not Automerge at all — is neither forwarded nor saved.
75
+ */
37
76
  private handleClientMessage;
38
- private persistDebounced;
77
+ /**
78
+ * Debounced save: each edit postpones it by `debounce`, but never past
79
+ * {@link MAX_PERSIST_WAIT_MS} (or `debounce`, if longer) after the first
80
+ * unsaved edit, so a document edited without pause is still written.
81
+ */
82
+ private schedulePersist;
83
+ /** Writes the pending save of `docName` now, if there is one. */
84
+ private persistNow;
85
+ /** Saves `state`, tracked so the room is not dropped while it is written. */
86
+ private save;
39
87
  getDocumentState(docName: string): Promise<Uint8Array>;
40
88
  getDocumentText(docName: string): Promise<string>;
41
89
  /** Versions = markers over the native history (heads hash). */
@@ -48,6 +96,7 @@ export declare class AutomergeDriver implements CollaborationDriver {
48
96
  * that leaves no entry in it is a restore nobody can undo.
49
97
  */
50
98
  restoreVersion(docName: string, versionId: string, restoredBy: string | null): Promise<void>;
99
+ private restoreRoom;
51
100
  /** Binary state stored for a version (`A.save` of the doc at that point). */
52
101
  getVersionState(docName: string, versionId: string): Promise<Uint8Array>;
53
102
  diffVersions(docName: string, aId: string, bId: string): Promise<CollabDiffSummary>;
@@ -1 +1 @@
1
- {"version":3,"file":"automerge_driver.d.ts","sourceRoot":"","sources":["../../../../src/drivers/automerge/automerge_driver.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAEpF,OAAO,KAAK,EACV,iBAAiB,EAGjB,aAAa,EACb,eAAe,EAChB,MAAM,gBAAgB,CAAC;AA+BxB,qBAAa,eAAgB,YAAW,mBAAmB;IACzD,QAAQ,CAAC,MAAM,eAAe;IAE9B,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,GAAG,CAAC,CAAkB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,aAAa,CAAoD;IACzE;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS,CAAoC;IAErD,YAAY,OAAO,EAAE,uBAAuB,EAG3C;IAED,OAAO,CAAC,QAAQ;YAYF,QAAQ;IAWtB;;;;;;;;;;OAUG;YAEW,YAAY;IA0C1B,OAAO,CAAC,SAAS;IAQX,MAAM,CAAC,MAAM,EAAE,OAAO,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA2G9D;YAGa,mBAAmB;IAkBjC,OAAO,CAAC,gBAAgB;IAwBlB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAG3D;IAEK,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAItD;IAED,+DAA+D;IACzD,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,MAAM,GAAG,IAAI,GACnB,OAAO,CAAC,aAAa,CAAC,CAcxB;IAEK,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAEpF;IAED;;;;;OAKG;IACG,cAAc,CAClB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,IAAI,GACxB,OAAO,CAAC,IAAI,CAAC,CAgCf;IAED,6EAA6E;IACvE,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAI7E;IAEK,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAYxF;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAS3B;CACF"}
1
+ {"version":3,"file":"automerge_driver.d.ts","sourceRoot":"","sources":["../../../../src/drivers/automerge/automerge_driver.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAEpF,OAAO,KAAK,EACV,iBAAiB,EAGjB,aAAa,EACb,eAAe,EAChB,MAAM,gBAAgB,CAAC;AA4BxB,sEAAsE;AACtE,eAAO,MAAM,WAAW,2BAA2B,CAAC;AAgCpD,qBAAa,eAAgB,YAAW,mBAAmB;IACzD,QAAQ,CAAC,MAAM,eAAe;IAE9B,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,GAAG,CAAC,CAAkB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuB;IAC/C,OAAO,CAAC,YAAY,CAAkC;IACtD,8EAA8E;IAC9E,OAAO,CAAC,aAAa,CAAoC;IACzD;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS,CAAoC;IAErD;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAmB;IAE7B,8EAA8E;YAChE,SAAS;IAKvB,YAAY,OAAO,EAAE,uBAAuB,EAG3C;IAED,OAAO,CAAC,QAAQ;YAYF,QAAQ;IAiBtB;;;;;;;;;;OAUG;YAEW,YAAY;IA0C1B;;;;;OAKG;IACH,OAAO,CAAC,SAAS;IAoBjB,4EAA4E;YAC9D,QAAQ;IAWtB;;;;OAIG;YACW,WAAW;IAQnB,MAAM,CAAC,MAAM,EAAE,OAAO,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAsH9D;IAED;;;;OAIG;IACH,OAAO,CAAC,YAAY;IAkBpB,6FAA6F;IAC7F,OAAO,CAAC,WAAW;IAOnB;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IAoB3B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAcvB,iEAAiE;YACnD,UAAU;IAQxB,6EAA6E;YAC/D,IAAI;IAmBZ,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAE3D;IAEK,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKtD;IAED,+DAA+D;IACzD,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,KAAK,EAAE,MAAM,GAAG,IAAI,GACnB,OAAO,CAAC,aAAa,CAAC,CAexB;IAEK,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAEpF;IAED;;;;;OAKG;IACG,cAAc,CAClB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GAAG,IAAI,GACxB,OAAO,CAAC,IAAI,CAAC,CAOf;YAEa,WAAW;IA0CzB,6EAA6E;IACvE,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAI7E;IAEK,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAaxF;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAa3B;CACF"}
@@ -1,9 +1,8 @@
1
- import * as A from '@automerge/automerge';
2
- import { next as ANext } from '@automerge/automerge';
3
1
  import { WebSocketServer } from 'ws';
4
2
  import { verifySelfHostedToken } from '../../auth/token.js';
5
3
  import { reportCollaborationError, reportCollaborationWarning } from '../../observability.js';
6
4
  import { createVersionMetadata, restoredFromLabel, seqVersions } from '../../versioning.js';
5
+ import { loadAutomerge } from '../lazy_deps.js';
7
6
  import { lineDiff, resolveStorage } from '../shared.js';
8
7
  /**
9
8
  * Automerge driver.
@@ -14,22 +13,39 @@ import { lineDiff, resolveStorage } from '../shared.js';
14
13
  * between versions is already in the doc. `restoreVersion` brings the doc
15
14
  * back to the version's heads via a compensating change (non-destructive).
16
15
  *
17
- * Transport: clients speak Automerge's binary protocol
18
- * (@automerge/automerge-repo-network-websocket on the client). On the server
19
- * we use an in-memory DocHandle + simple per-room broadcast; for horizontal
20
- * scaling, updates are re-broadcast via Redis pub/sub (same philosophy as transmit).
16
+ * Transport: one in-memory doc per room and a per-room broadcast. Every
17
+ * client gets the whole `A.save(doc)` on connect. After that, a socket opened
18
+ * with `?sync=changes` is answered with the {@link CHANGES_ACK} text frame and
19
+ * from then on exchanges change chunks (`A.saveSince`) both ways; any other
20
+ * socket sends and receives whole saved documents. The server applies both
21
+ * shapes with `A.loadIncremental`, so one room can hold both kinds of client.
22
+ *
23
+ * A room lives while it has clients: when the last one leaves, its pending
24
+ * save is written and the room is dropped from memory.
21
25
  */
26
+ /** Query parameter (and value) a client sets to ask for change chunks. */
27
+ const CHANGES_PARAM = 'sync';
28
+ const CHANGES_PARAM_VALUE = 'changes';
29
+ /** Text frame confirming that this socket exchanges change chunks. */
30
+ export const CHANGES_ACK = 'automerge-sync:changes';
31
+ /** Longest a burst of edits can keep postponing its save. */
32
+ const MAX_PERSIST_WAIT_MS = 10_000;
22
33
  /** Connections whose permission denied canWrite — their updates are dropped. */
23
34
  const readOnlyClients = new WeakSet();
24
- /** userId behind each open socket — feeds the presence roster on close. */
25
- const socketUsers = new WeakMap();
35
+ /** Sockets that exchange change chunks instead of whole documents. */
36
+ const changeClients = new WeakSet();
37
+ function sameHeads(a, b) {
38
+ return a.length === b.length && a.every((hash, index) => hash === b[index]);
39
+ }
26
40
  export class AutomergeDriver {
27
41
  engine = 'automerge';
28
42
  rooms = new Map();
29
43
  wss;
30
44
  options;
31
45
  storage;
32
- persistTimers = new Map();
46
+ pendingSaves = new Map();
47
+ /** Saves being written, by document name; a room is not dropped under one. */
48
+ savesInFlight = new Map();
33
49
  /**
34
50
  * Rooms being opened, by document name.
35
51
  *
@@ -40,6 +56,17 @@ export class AutomergeDriver {
40
56
  * duplicated content, so the window has to close.
41
57
  */
42
58
  roomLoads = new Map();
59
+ /**
60
+ * `@automerge/automerge`, loaded by the first call that opens a room or reads
61
+ * a version (see `lazy_deps.ts`). Every synchronous use runs on a room, and a
62
+ * room only exists after `openRoom` awaited the module, so it is set there.
63
+ */
64
+ am;
65
+ /** Loads Automerge (once per process) and makes it available as `this.am`. */
66
+ async automerge() {
67
+ this.am ??= await loadAutomerge();
68
+ return this.am;
69
+ }
43
70
  constructor(options) {
44
71
  this.options = options;
45
72
  this.storage = resolveStorage(options.storage);
@@ -56,11 +83,17 @@ export class AutomergeDriver {
56
83
  return opening.finally(() => this.roomLoads.delete(docName));
57
84
  }
58
85
  async openRoom(docName) {
86
+ await this.automerge();
59
87
  const stored = await this.storage.loadDocument(docName);
60
88
  const doc = stored
61
- ? A.load(stored.state)
89
+ ? this.am.load(stored.state)
62
90
  : await this.seedDocument(docName);
63
- const room = { text: doc, clients: new Set() };
91
+ const room = {
92
+ text: doc,
93
+ clients: new Set(),
94
+ users: new Map(),
95
+ holds: 0,
96
+ };
64
97
  this.rooms.set(docName, room);
65
98
  return room;
66
99
  }
@@ -77,7 +110,7 @@ export class AutomergeDriver {
77
110
  */
78
111
  // biome-ignore lint/suspicious/noExplicitAny: automerge's typing is unstable between minors
79
112
  async seedDocument(docName) {
80
- const empty = () => A.from({ content: '' });
113
+ const empty = () => this.am.from({ content: '' });
81
114
  if (!this.options.seedDocument)
82
115
  return empty();
83
116
  let seed;
@@ -99,7 +132,7 @@ export class AutomergeDriver {
99
132
  }
100
133
  let doc;
101
134
  try {
102
- doc = A.load(seed);
135
+ doc = this.am.load(seed);
103
136
  }
104
137
  catch (error) {
105
138
  reportCollaborationError({ scope: 'storage', operation: 'loadDocument', docName, error });
@@ -115,12 +148,52 @@ export class AutomergeDriver {
115
148
  }
116
149
  return doc;
117
150
  }
118
- broadcast(room, payload, sender) {
151
+ /**
152
+ * Sends everything the room gained since `since` to every client but the
153
+ * sender: the change chunk to sockets that speak chunks, a whole document
154
+ * (`wholeDoc`, else `A.save` of the room) to the rest. Each shape is
155
+ * encoded at most once per call.
156
+ */
157
+ broadcast(room, since, sender, wholeDoc) {
158
+ let changes;
159
+ let whole = wholeDoc;
119
160
  for (const client of room.clients) {
120
- if (client !== sender && client.readyState === 1) {
121
- client.send(payload);
161
+ if (client === sender || client.readyState !== 1)
162
+ continue;
163
+ if (changeClients.has(client)) {
164
+ changes ??= this.am.next.saveSince(room.text, since);
165
+ client.send(changes);
122
166
  }
167
+ else {
168
+ whole ??= this.am.save(room.text);
169
+ client.send(whole);
170
+ }
171
+ }
172
+ }
173
+ /** Runs `fn` on the room, dropping it afterwards if nobody else uses it. */
174
+ async withRoom(docName, fn) {
175
+ const room = await this.loadRoom(docName);
176
+ room.holds += 1;
177
+ try {
178
+ return await fn(room);
179
+ }
180
+ finally {
181
+ room.holds -= 1;
182
+ void this.releaseRoom(docName, room);
183
+ }
184
+ }
185
+ /**
186
+ * Writes the room's pending save and drops it from memory once nothing
187
+ * uses it: no client, no driver call, no save waiting or being written.
188
+ * A client that joins meanwhile finds the same room in the map.
189
+ */
190
+ async releaseRoom(docName, room) {
191
+ const idle = () => room.clients.size === 0 && room.holds === 0;
192
+ while (idle() && (this.pendingSaves.has(docName) || this.savesInFlight.has(docName))) {
193
+ await this.persistNow(docName);
123
194
  }
195
+ if (idle() && this.rooms.get(docName) === room)
196
+ this.rooms.delete(docName);
124
197
  }
125
198
  async attach(server) {
126
199
  const path = this.options.path ?? '/collaboration';
@@ -185,102 +258,176 @@ export class AutomergeDriver {
185
258
  ws.close(4003, 'no access');
186
259
  return;
187
260
  }
261
+ let room;
188
262
  try {
189
- const room = await this.loadRoom(docName);
263
+ room = await this.loadRoom(docName);
264
+ const joinedRoom = room;
190
265
  room.clients.add(ws);
191
266
  if (!permission.canWrite)
192
267
  readOnlyClients.add(ws);
268
+ const sendsChanges = url.searchParams.get(CHANGES_PARAM) === CHANGES_PARAM_VALUE;
269
+ if (sendsChanges)
270
+ changeClients.add(ws);
271
+ // Registered before the join, so a socket that closes while the join
272
+ // is in flight still leaves the room.
273
+ let joined = false;
274
+ ws.on('close', () => {
275
+ this.removeSocket(docName, joinedRoom, ws, joined ? ctx.userId : undefined);
276
+ });
193
277
  await this.options.presence?.join(docName, {
194
278
  userId: ctx.userId,
195
279
  ...(ctx.user?.name ? { name: ctx.user.name } : {}),
196
280
  });
197
- // Sends the full state to the new client.
198
- ws.send(A.save(room.text));
281
+ if (ws.readyState !== ws.OPEN) {
282
+ // Closed during the join: the entry it wrote is owed a leave.
283
+ this.leaveIfLast(docName, joinedRoom, ctx.userId);
284
+ return;
285
+ }
286
+ joined = true;
287
+ room.users.set(ctx.userId, (room.users.get(ctx.userId) ?? 0) + 1);
288
+ // The acknowledgement goes before the state, so the client knows
289
+ // the shape of its first send before it makes it.
290
+ if (sendsChanges)
291
+ ws.send(CHANGES_ACK);
292
+ ws.send(this.am.save(room.text));
199
293
  ws.on('message', (data) => {
200
294
  // A read-only connection still receives every update; its own are
201
295
  // dropped. Enforced here because the client cannot be trusted to
202
296
  // respect a permission it was merely told about.
203
297
  if (readOnlyClients.has(ws))
204
298
  return;
205
- void this.handleClientMessage(docName, data, ws);
206
- });
207
- ws.on('close', () => {
208
- room.clients.delete(ws);
209
- readOnlyClients.delete(ws);
210
- // Only the user's LAST socket on this document leaves the roster.
211
- const stillConnected = [...room.clients].some((client) => socketUsers.get(client) === ctx.userId);
212
- socketUsers.delete(ws);
213
- if (!stillConnected) {
214
- void this.options.presence?.leave(docName, ctx.userId);
215
- }
299
+ this.handleClientMessage(docName, joinedRoom, data, ws);
216
300
  });
217
- socketUsers.set(ws, ctx.userId);
218
301
  }
219
302
  catch (error) {
303
+ if (room) {
304
+ // A join that failed may still have written the entry.
305
+ this.removeSocket(docName, room, ws, undefined);
306
+ this.leaveIfLast(docName, room, ctx.userId);
307
+ }
220
308
  reportCollaborationError({ scope: 'storage', operation: 'loadDocument', docName, error });
221
309
  ws.close(4500, 'could not open the document');
222
310
  }
223
311
  });
224
312
  });
225
313
  }
226
- // biome-ignore lint/suspicious/noExplicitAny: automerge's typing is unstable between minors
227
- async handleClientMessage(docName, data, sender) {
228
- const room = await this.loadRoom(docName);
314
+ /**
315
+ * Takes `ws` out of the room — and, when `userId` is given, out of that
316
+ * user's socket count, leaving the roster with their last one. Safe to call
317
+ * twice for one socket.
318
+ */
319
+ removeSocket(docName, room, ws, userId) {
320
+ if (!room.clients.delete(ws))
321
+ return;
322
+ readOnlyClients.delete(ws);
323
+ changeClients.delete(ws);
324
+ if (userId !== undefined) {
325
+ const count = (room.users.get(userId) ?? 1) - 1;
326
+ if (count > 0)
327
+ room.users.set(userId, count);
328
+ else
329
+ room.users.delete(userId);
330
+ this.leaveIfLast(docName, room, userId);
331
+ }
332
+ void this.releaseRoom(docName, room);
333
+ }
334
+ /** Removes `userId` from the roster unless another of their sockets is still in the room. */
335
+ leaveIfLast(docName, room, userId) {
336
+ if (room.users.has(userId))
337
+ return;
338
+ this.options.presence?.leave(docName, userId).catch((error) => {
339
+ reportCollaborationError({ scope: 'transport', operation: 'presence.leave', docName, error });
340
+ });
341
+ }
342
+ /**
343
+ * Applies a client's whole document or change chunk and forwards only what
344
+ * was new to the room. A payload that adds nothing — a duplicate, or bytes
345
+ * that are not Automerge at all — is neither forwarded nor saved.
346
+ */
347
+ handleClientMessage(docName, room, data, sender) {
229
348
  try {
230
- // Client sends an incremental binary doc; merge into the server doc.
231
- const incoming = A.load(data);
232
- const merged = A.merge(room.text, incoming);
233
- room.text = merged;
234
- this.broadcast(room, data, sender);
235
- // Persistence with simple per-room debounce.
236
- await this.persistDebounced(docName, room);
349
+ const before = this.am.next.getHeads(room.text);
350
+ room.text = this.am.loadIncremental(room.text, data);
351
+ if (sameHeads(before, this.am.next.getHeads(room.text)))
352
+ return;
353
+ // A whole document from a whole-document client is forwarded as it came
354
+ // to the other whole-document clients: it is already the payload they need.
355
+ this.broadcast(room, before, sender, changeClients.has(sender) ? undefined : data);
356
+ this.schedulePersist(docName, room);
237
357
  }
238
358
  catch {
239
359
  // Invalid payload: ignore (a badly-behaved client doesn't take down the room).
240
360
  }
241
361
  }
242
- persistDebounced(docName, room) {
243
- return new Promise((resolve) => {
244
- const existing = this.persistTimers.get(docName);
245
- if (existing)
246
- clearTimeout(existing);
247
- this.persistTimers.set(docName, setTimeout(() => {
248
- void this.storage
249
- .saveDocument(docName, A.save(room.text))
250
- .catch((error) => {
251
- reportCollaborationError({
252
- scope: 'storage',
253
- operation: 'saveDocument',
254
- docName,
255
- error,
256
- });
257
- })
258
- .finally(() => resolve());
259
- }, this.options.debounce ?? 2000));
260
- });
362
+ /**
363
+ * Debounced save: each edit postpones it by `debounce`, but never past
364
+ * {@link MAX_PERSIST_WAIT_MS} (or `debounce`, if longer) after the first
365
+ * unsaved edit, so a document edited without pause is still written.
366
+ */
367
+ schedulePersist(docName, room) {
368
+ const debounce = this.options.debounce ?? 2000;
369
+ const now = Date.now();
370
+ const pending = this.pendingSaves.get(docName);
371
+ if (pending)
372
+ clearTimeout(pending.timer);
373
+ const deadline = pending?.deadline ?? now + Math.max(MAX_PERSIST_WAIT_MS, debounce);
374
+ const timer = setTimeout(() => void this.persistNow(docName), Math.max(0, Math.min(debounce, deadline - now)));
375
+ this.pendingSaves.set(docName, { timer, deadline, room });
376
+ }
377
+ /** Writes the pending save of `docName` now, if there is one. */
378
+ async persistNow(docName) {
379
+ const pending = this.pendingSaves.get(docName);
380
+ if (!pending)
381
+ return this.savesInFlight.get(docName);
382
+ clearTimeout(pending.timer);
383
+ this.pendingSaves.delete(docName);
384
+ await this.save(docName, this.am.save(pending.room.text));
385
+ }
386
+ /** Saves `state`, tracked so the room is not dropped while it is written. */
387
+ async save(docName, state) {
388
+ const previous = this.savesInFlight.get(docName);
389
+ const write = (async () => {
390
+ // Writes of one document stay in order, so an older state never lands last.
391
+ await previous;
392
+ try {
393
+ await this.storage.saveDocument(docName, state);
394
+ }
395
+ catch (error) {
396
+ reportCollaborationError({ scope: 'storage', operation: 'saveDocument', docName, error });
397
+ }
398
+ })();
399
+ this.savesInFlight.set(docName, write);
400
+ try {
401
+ await write;
402
+ }
403
+ finally {
404
+ if (this.savesInFlight.get(docName) === write)
405
+ this.savesInFlight.delete(docName);
406
+ }
261
407
  }
262
408
  async getDocumentState(docName) {
263
- const room = await this.loadRoom(docName);
264
- return A.save(room.text);
409
+ return this.withRoom(docName, (room) => this.am.save(room.text));
265
410
  }
266
411
  async getDocumentText(docName) {
267
- const room = await this.loadRoom(docName);
268
- const content = room.text.content;
269
- return content ? content.toString() : '';
412
+ return this.withRoom(docName, (room) => {
413
+ const content = room.text.content;
414
+ return content ? content.toString() : '';
415
+ });
270
416
  }
271
417
  /** Versions = markers over the native history (heads hash). */
272
418
  async createVersion(docName, createdBy, label) {
273
- const room = await this.loadRoom(docName);
274
- const heads = ANext.getHeads(room.text);
275
- const existing = await this.storage.listVersions(docName);
276
- const version = {
277
- ...createVersionMetadata(createdBy, label, seqVersions(existing)),
278
- id: heads.join(','),
279
- };
280
- // Snapshot = binary save of the doc up to those heads (Automerge compresses
281
- // well; storing the whole doc as the "snapshot" keeps restore O(1)).
282
- await this.storage.saveVersion(docName, version, A.save(room.text));
283
- return version;
419
+ return this.withRoom(docName, async (room) => {
420
+ const heads = this.am.next.getHeads(room.text);
421
+ const existing = await this.storage.listVersions(docName);
422
+ const version = {
423
+ ...createVersionMetadata(createdBy, label, seqVersions(existing)),
424
+ id: heads.join(','),
425
+ };
426
+ // Snapshot = binary save of the doc up to those heads (Automerge compresses
427
+ // well; storing the whole doc as the "snapshot" keeps restore O(1)).
428
+ await this.storage.saveVersion(docName, version, this.am.save(room.text));
429
+ return version;
430
+ });
284
431
  }
285
432
  async listVersions(docName, page) {
286
433
  return this.storage.listVersions(docName, page);
@@ -293,25 +440,36 @@ export class AutomergeDriver {
293
440
  */
294
441
  async restoreVersion(docName, versionId, restoredBy) {
295
442
  const bytes = await this.getVersionState(docName, versionId);
296
- const snapshotDoc = A.load(bytes);
297
- const room = await this.loadRoom(docName);
443
+ await this.automerge();
444
+ const snapshotDoc = this.am.load(bytes);
445
+ await this.withRoom(docName, (room) => this.restoreRoom(docName, room, versionId, snapshotDoc, restoredBy));
446
+ }
447
+ async restoreRoom(docName, room, versionId,
448
+ // biome-ignore lint/suspicious/noExplicitAny: automerge's typing is unstable between minors
449
+ snapshotDoc, restoredBy) {
298
450
  const existing = await this.storage.listVersions(docName);
299
451
  const target = existing.find((version) => version.id === versionId);
300
452
  await this.storage.saveVersion(docName, {
301
453
  ...createVersionMetadata(restoredBy, restoredFromLabel(target, versionId), seqVersions(existing)),
302
- id: ANext.getHeads(room.text).join(','),
303
- }, A.save(room.text));
304
- const merged = A.merge(room.text, snapshotDoc);
454
+ id: this.am.next.getHeads(room.text).join(','),
455
+ }, this.am.save(room.text));
456
+ const before = this.am.next.getHeads(room.text);
457
+ const merged = this.am.merge(room.text, snapshotDoc);
305
458
  // Automerge merge converges to the most recent state; to "restore",
306
459
  // we overwrite the content field with the value from the version.
307
460
  const restoredContent = snapshotDoc
308
461
  .content;
309
- const finalDoc = A.change(merged, (doc) => {
462
+ const finalDoc = this.am.change(merged, (doc) => {
310
463
  doc.content = restoredContent?.toString() ?? '';
311
464
  });
312
465
  room.text = finalDoc;
313
- this.broadcast(room, A.save(finalDoc));
314
- await this.storage.saveDocument(docName, A.save(finalDoc));
466
+ this.broadcast(room, before);
467
+ // The restore supersedes any debounced save of the state it replaced.
468
+ const pending = this.pendingSaves.get(docName);
469
+ if (pending)
470
+ clearTimeout(pending.timer);
471
+ this.pendingSaves.delete(docName);
472
+ await this.save(docName, this.am.save(finalDoc));
315
473
  }
316
474
  /** Binary state stored for a version (`A.save` of the doc at that point). */
317
475
  async getVersionState(docName, versionId) {
@@ -325,20 +483,25 @@ export class AutomergeDriver {
325
483
  this.getVersionState(docName, aId),
326
484
  this.getVersionState(docName, bId),
327
485
  ]);
486
+ await this.automerge();
328
487
  const textOf = (bytes) => {
329
- const doc = A.load(bytes);
488
+ const doc = this.am.load(bytes);
330
489
  return doc.content?.toString() ?? '';
331
490
  };
332
491
  return lineDiff(textOf(aBytes), textOf(bBytes));
333
492
  }
334
493
  async close() {
335
- for (const timer of this.persistTimers.values())
336
- clearTimeout(timer);
494
+ // Edits still inside their debounce are written, not dropped.
495
+ await Promise.all([...this.pendingSaves.keys()].map((docName) => this.persistNow(docName)));
496
+ await Promise.all(this.savesInFlight.values());
337
497
  // `this.wss?.close(cb)` never runs cb when there is no server, so this
338
498
  // promise used to hang forever on a driver that was never attached —
339
499
  // which is every driver outside the web process.
340
500
  if (!this.wss)
341
501
  return;
502
+ // `wss.close` only calls back once every client is gone.
503
+ for (const client of this.wss.clients)
504
+ client.terminate();
342
505
  return new Promise((resolve) => {
343
506
  this.wss?.close(() => resolve());
344
507
  });