texlite 0.6.0 → 0.6.5

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 (44) hide show
  1. package/README.md +1 -0
  2. package/dist/client/assets/{BinaryModule-Aj1vLnwf-C2TCdTPg.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
  3. package/dist/client/assets/{GitDialog-9mNxx8oX.js → GitDialog-s54eAFX6.js} +1 -1
  4. package/dist/client/assets/{HistoryDialog-CmoD9Cvf.js → HistoryDialog-DrUnIy0a.js} +1 -1
  5. package/dist/client/assets/{LatexEditor-Dp8EoZBP.js → LatexEditor-Y5KfVqQI.js} +20 -20
  6. package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-veKVnRxc.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
  8. package/dist/client/assets/{SystemMetricsDialog-D_oiVmBC.js → SystemMetricsDialog-B97Og0La.js} +1 -1
  9. package/dist/client/assets/binary-DCBFVo4B.js +1 -0
  10. package/dist/client/assets/index-5ajAy27z.css +1 -0
  11. package/dist/client/assets/index-CCitTPyj.js +17 -0
  12. package/dist/client/assets/{index-CyEG4l54.js → index-CQG_gpuU.js} +1 -1
  13. package/dist/client/assets/{plus-Bup73Wq6.js → plus-Bp4_lUor.js} +1 -1
  14. package/dist/client/assets/{spellCheck-BHW5bumN.js → spellCheck-0f5ZG5IV.js} +3 -3
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/app.js +888 -339
  17. package/dist/server/archive.js +20 -3
  18. package/dist/server/auth.js +13 -2
  19. package/dist/server/collaboration.js +815 -124
  20. package/dist/server/compileArtifacts.js +31 -13
  21. package/dist/server/compiler.js +69 -20
  22. package/dist/server/db.js +6 -0
  23. package/dist/server/environment.js +4 -2
  24. package/dist/server/files.js +172 -25
  25. package/dist/server/git.js +152 -124
  26. package/dist/server/history.js +86 -18
  27. package/dist/server/http.js +26 -0
  28. package/dist/server/index.js +39 -26
  29. package/dist/server/instanceLock.js +137 -0
  30. package/dist/server/latexCompletion.js +23 -1
  31. package/dist/server/latexFormatter.js +4 -2
  32. package/dist/server/processTree.js +31 -0
  33. package/dist/server/projectMutations.js +255 -0
  34. package/dist/server/projectOutline.js +113 -1
  35. package/dist/server/projectSearch.js +6 -0
  36. package/dist/server/routes/compile.js +505 -128
  37. package/dist/server/security.js +61 -1
  38. package/dist/server/synctex.js +11 -2
  39. package/dist/server/zip.js +2 -1
  40. package/package.json +1 -1
  41. package/dist/client/assets/PdfPreview-IfkmLMSU.js +0 -1
  42. package/dist/client/assets/binary-DTriWQzf.js +0 -1
  43. package/dist/client/assets/index-CI87C5Wy.js +0 -17
  44. package/dist/client/assets/index-DB6GC33g.css +0 -1
@@ -8,7 +8,7 @@ import * as encoding from "lib0/encoding";
8
8
  import * as decoding from "lib0/decoding";
9
9
  import * as syncProtocol from "y-protocols/sync";
10
10
  import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, modifyAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
11
- import { listProjectFiles, outputRoot, resolveSourcePath, safeRelativePath } from "./files.js";
11
+ import { listProjectFilesAsync, outputRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
12
12
  import { accessibleProject, canEdit } from "./projects.js";
13
13
  import { reanchorFileComments } from "./anchors.js";
14
14
  const MESSAGE_SYNC = 0;
@@ -16,6 +16,19 @@ const MESSAGE_AWARENESS = 1;
16
16
  const MESSAGE_QUERY_AWARENESS = 3;
17
17
  const MESSAGE_FLUSH = 4;
18
18
  const MESSAGE_PROTOCOL = 5;
19
+ const MESSAGE_MAINTENANCE = 6;
20
+ const MESSAGE_PERMISSION = 7;
21
+ // A small, non-Yjs handshake message used for ephemeral compile metadata.
22
+ // Source text still uses the normal Yjs sync protocol; this message prevents
23
+ // an old IndexedDB metadata entry from being treated as an active compile.
24
+ const MESSAGE_COMPILE_STATES = 8;
25
+ /**
26
+ * Increment this when a wire-level collaboration change cannot be decoded by
27
+ * an older browser. The epoch marker is rotated at the same time, which makes
28
+ * already-open older pages discard their local draft and reload safely.
29
+ */
30
+ export const COLLABORATION_PROTOCOL_VERSION = 2;
31
+ const VERSIONED_EPOCH_PREFIX = `${COLLABORATION_PROTOCOL_VERSION}:`;
19
32
  const SOURCE_PREFIX = "source:";
20
33
  const MAX_PROJECT_SESSIONS = 10;
21
34
  const MAX_COLLABORATIVE_FILE_BYTES = 5 * 1024 * 1024;
@@ -25,6 +38,7 @@ const META_ORIGIN = Symbol("meta");
25
38
  const SAVE_DELAY_MS = 750;
26
39
  const STATE_SAVE_DELAY_MS = 750;
27
40
  const ROOM_IDLE_MS = 30_000;
41
+ const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision"];
28
42
  const COLORS = [
29
43
  ["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
30
44
  ["#9a58b5", "#9a58b533"], ["#d27b18", "#d27b1833"], ["#3f7d20", "#3f7d2033"],
@@ -36,51 +50,149 @@ export class CollaborationService {
36
50
  db;
37
51
  onPersist;
38
52
  rooms = new Map();
53
+ maintenanceProjects = new Map();
54
+ roomInitializations = new Map();
55
+ projectGenerations = new Map();
56
+ snapshotBarriers = new Map();
57
+ pendingConnections = new Map();
58
+ closed = false;
39
59
  constructor(config, db, onPersist) {
40
60
  this.config = config;
41
61
  this.db = db;
42
62
  this.onPersist = onPersist;
43
63
  }
44
- connect(socket, projectId, user) {
64
+ async connect(socket, projectId, user) {
65
+ if (this.closed) {
66
+ socket.close(1012, "Collaboration service is shutting down");
67
+ return;
68
+ }
45
69
  const project = accessibleProject(this.db, projectId, user);
46
70
  if (!project) {
47
71
  socket.close(1008, "Project access denied");
48
72
  return;
49
73
  }
50
- const room = this.getRoom(projectId);
51
- if (room.connections.size >= MAX_PROJECT_SESSIONS) {
52
- socket.close(1013, "Project collaboration room is full");
74
+ const maintenanceReason = this.maintenanceProjects.get(projectId);
75
+ if (maintenanceReason) {
76
+ socket.close(1013, `Project is temporarily unavailable: ${maintenanceReason}`);
53
77
  return;
54
78
  }
55
- if (room.cleanupTimer) {
56
- clearTimeout(room.cleanupTimer);
57
- room.cleanupTimer = null;
79
+ const existing = this.rooms.get(projectId);
80
+ if (existing) {
81
+ this.attachConnection(existing, socket, user);
82
+ return;
58
83
  }
59
- const connection = {
60
- socket, user, awarenessClientId: null, protocolVerified: false, protocolTimer: null
61
- };
62
- room.connections.add(connection);
63
- socket.binaryType = "arraybuffer";
64
- socket.on("message", (data) => {
65
- try {
66
- this.handleMessage(room, connection, rawData(data));
84
+ const generation = this.projectGeneration(projectId);
85
+ const pendingKey = this.pendingConnectionKey(projectId, generation);
86
+ const pending = this.pendingConnections.get(pendingKey) ?? 0;
87
+ if (pending >= MAX_PROJECT_SESSIONS) {
88
+ socket.close(1013, "Project collaboration room is full");
89
+ return;
90
+ }
91
+ this.pendingConnections.set(pendingKey, pending + 1);
92
+ const initialization = this.roomInitializations.get(projectId) ?? this.initializeRoom(projectId, generation);
93
+ try {
94
+ const room = await initialization;
95
+ const currentMaintenance = this.maintenanceProjects.get(projectId);
96
+ if (currentMaintenance) {
97
+ this.disposeRoom(room);
98
+ socket.close(1013, `Project is temporarily unavailable: ${currentMaintenance}`);
67
99
  }
68
- catch {
69
- socket.close(1003, "Invalid collaboration message");
100
+ else if (this.projectGeneration(projectId) !== generation) {
101
+ socket.close(1013, "Collaboration state changed; retry required");
70
102
  }
71
- });
72
- socket.on("close", () => this.disconnect(room, connection));
73
- socket.on("error", () => this.disconnect(room, connection));
74
- connection.protocolTimer = setTimeout(() => {
75
- if (!connection.protocolVerified)
76
- socket.close(4001, "Reload required");
77
- }, 10_000);
78
- send(socket, protocolMessage(room.epoch));
103
+ else if (socket.readyState === WebSocket.OPEN) {
104
+ this.attachConnection(room, socket, user);
105
+ }
106
+ else {
107
+ socket.close(1000, "Collaboration connection closed during initialization");
108
+ }
109
+ }
110
+ catch {
111
+ socket.close(1011, "Unable to initialize collaboration room");
112
+ }
113
+ finally {
114
+ const count = (this.pendingConnections.get(pendingKey) ?? 1) - 1;
115
+ if (count > 0)
116
+ this.pendingConnections.set(pendingKey, count);
117
+ else
118
+ this.pendingConnections.delete(pendingKey);
119
+ }
120
+ }
121
+ /** Wait for an in-flight cold-room load before reading or replacing sources. */
122
+ async waitForReady(projectId) {
123
+ const initialization = this.roomInitializations.get(projectId);
124
+ if (!initialization)
125
+ return;
126
+ try {
127
+ await initialization;
128
+ }
129
+ catch { /* Source files remain authoritative if recovery fails. */ }
79
130
  }
80
131
  flushProject(projectId) {
81
132
  const room = this.rooms.get(projectId);
82
- if (room)
83
- this.flushRoom(room);
133
+ return room ? this.flushRoom(room) : null;
134
+ }
135
+ /**
136
+ * Prevent collaboration autosaves from changing the source tree while a
137
+ * compiler is copying an immutable snapshot. Yjs updates remain accepted in
138
+ * memory and are flushed when the outermost barrier is released.
139
+ */
140
+ beginSnapshotBarrier(projectId) {
141
+ const depth = (this.snapshotBarriers.get(projectId) ?? 0) + 1;
142
+ this.snapshotBarriers.set(projectId, depth);
143
+ const room = this.rooms.get(projectId);
144
+ if (!room)
145
+ return;
146
+ room.snapshotBarrierDepth = depth;
147
+ if (depth === 1 && room.cleanupTimer) {
148
+ clearTimeout(room.cleanupTimer);
149
+ room.cleanupTimer = null;
150
+ }
151
+ }
152
+ /**
153
+ * Release a snapshot barrier and persist edits that arrived while the
154
+ * source tree was protected. The returned receipt describes the post-barrier
155
+ * disk revision, allowing callers to label a snapshot that predates edits
156
+ * without treating that consistent snapshot as invalid.
157
+ */
158
+ endSnapshotBarrier(projectId) {
159
+ const depth = this.snapshotBarriers.get(projectId) ?? 0;
160
+ if (depth <= 0)
161
+ return this.rooms.get(projectId) ? this.currentReceipt(this.rooms.get(projectId)) : null;
162
+ const nextDepth = depth - 1;
163
+ if (nextDepth > 0) {
164
+ this.snapshotBarriers.set(projectId, nextDepth);
165
+ const nestedRoom = this.rooms.get(projectId);
166
+ if (nestedRoom)
167
+ nestedRoom.snapshotBarrierDepth = nextDepth;
168
+ return nestedRoom ? this.currentReceipt(nestedRoom) : null;
169
+ }
170
+ this.snapshotBarriers.delete(projectId);
171
+ const room = this.rooms.get(projectId);
172
+ if (!room)
173
+ return null;
174
+ room.snapshotBarrierDepth = 0;
175
+ const shouldFlush = room.snapshotFlushPending || room.dirtyPaths.size > 0;
176
+ room.snapshotFlushPending = false;
177
+ let receipt;
178
+ try {
179
+ receipt = shouldFlush ? this.flushRoom(room) : this.currentReceipt(room);
180
+ }
181
+ catch (error) {
182
+ // A state-file or source-file I/O failure must still complete any
183
+ // browser flush requests waiting behind the barrier. The caller should
184
+ // receive the original server error, while editors receive a durable
185
+ // negative receipt instead of timing out.
186
+ receipt = this.currentReceipt(room, false, [...room.dirtyPaths]);
187
+ if (room.dirtyPaths.size > 0)
188
+ this.scheduleSave(room);
189
+ this.resolvePendingFlushes(room, receipt);
190
+ this.scheduleRoomCleanup(room);
191
+ throw error;
192
+ }
193
+ this.resolvePendingFlushes(room, receipt);
194
+ this.scheduleRoomCleanup(room);
195
+ return receipt;
84
196
  }
85
197
  stats() {
86
198
  let sessions = 0;
@@ -89,23 +201,132 @@ export class CollaborationService {
89
201
  sessions += room.connections.size;
90
202
  dirtyFiles += room.dirtyPaths.size;
91
203
  }
92
- return { rooms: this.rooms.size, sessions, dirtyFiles };
204
+ return { rooms: this.rooms.size, sessions, dirtyFiles, initializing: this.roomInitializations.size };
93
205
  }
94
206
  updateFile(projectId, filePathInput, content, userId) {
95
207
  const room = this.rooms.get(projectId);
96
- if (!room || !isCollaborativeTextFile(filePathInput))
208
+ if (!room) {
209
+ this.invalidateProject(projectId);
210
+ return;
211
+ }
212
+ if (!isCollaborativeTextFile(filePathInput)) {
213
+ this.invalidateProject(projectId);
97
214
  return;
215
+ }
98
216
  const filePath = safeRelativePath(filePathInput);
99
217
  room.allowedPaths.add(filePath);
100
218
  room.persistedContent.set(filePath, content);
101
219
  room.doc.transact(() => replaceText(this.trackedText(room, filePath), content), HTTP_ORIGIN);
102
220
  room.lastModifiedUserId = userId;
221
+ room.persistedRevision += 1;
222
+ room.persistedAt = new Date().toISOString();
103
223
  this.bumpFiles(room, { kind: "update", path: filePath });
104
224
  }
105
- movePath(projectId, sourceInput, destinationInput, userId) {
225
+ /**
226
+ * Mark a project as undergoing an exclusive source-tree operation. Existing
227
+ * clients are told to stop editing and are disconnected when the operation
228
+ * completes by endMaintenance(), which forces a fresh Yjs epoch.
229
+ */
230
+ beginMaintenance(projectId, reason) {
231
+ const normalizedReason = reason.slice(0, 200);
232
+ const existingMaintenance = this.maintenanceProjects.get(projectId);
233
+ if (existingMaintenance)
234
+ throw new Error("Project is already undergoing a maintenance operation");
235
+ const room = this.rooms.get(projectId);
236
+ if (room?.maintenanceReason)
237
+ throw new Error("Project is already undergoing a maintenance operation");
238
+ this.maintenanceProjects.set(projectId, normalizedReason);
239
+ if (!room)
240
+ return;
241
+ room.maintenanceReason = normalizedReason;
242
+ const message = maintenanceMessage(room.maintenanceReason);
243
+ for (const connection of room.connections) {
244
+ send(connection.socket, message);
245
+ // Close immediately after the notice. This prevents local edits from
246
+ // accumulating while a source-tree replacement is in flight; the epoch
247
+ // reset in endMaintenance will make the client reload authoritative data.
248
+ connection.socket.close(4002, `Project maintenance: ${normalizedReason}`);
249
+ }
250
+ }
251
+ endMaintenance(projectId) {
252
+ this.maintenanceProjects.delete(projectId);
253
+ const room = this.rooms.get(projectId);
254
+ if (room)
255
+ room.maintenanceReason = null;
256
+ // A project-level replacement cannot safely merge edits made while the
257
+ // operation was in flight. Resetting the epoch makes every client reload
258
+ // the authoritative source tree instead of replaying stale local drafts.
259
+ this.resetProject(projectId);
260
+ }
261
+ notifyPermissionChanged(projectId, userId, permission) {
262
+ const room = this.rooms.get(projectId);
263
+ if (!room)
264
+ return;
265
+ const encoder = encoding.createEncoder();
266
+ encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
267
+ encoding.writeVarString(encoder, userId);
268
+ encoding.writeVarString(encoder, permission);
269
+ const message = encoding.toUint8Array(encoder);
270
+ for (const connection of room.connections) {
271
+ if (connection.user.id === userId)
272
+ send(connection.socket, message);
273
+ }
274
+ }
275
+ disconnectUser(userId, reason = "User access revoked") {
276
+ const encoder = encoding.createEncoder();
277
+ encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
278
+ encoding.writeVarString(encoder, userId);
279
+ encoding.writeVarString(encoder, "revoked");
280
+ const message = encoding.toUint8Array(encoder);
281
+ for (const room of this.rooms.values()) {
282
+ for (const connection of [...room.connections]) {
283
+ if (connection.user.id === userId) {
284
+ send(connection.socket, message);
285
+ connection.socket.close(1008, reason);
286
+ this.disconnect(room, connection);
287
+ }
288
+ }
289
+ }
290
+ }
291
+ currentRevision(projectId) {
292
+ return this.rooms.get(projectId)?.persistedRevision ?? null;
293
+ }
294
+ /** Return the durable collaborative text currently held by a live room. */
295
+ fileContent(projectId, filePathInput) {
106
296
  const room = this.rooms.get(projectId);
107
297
  if (!room)
298
+ return null;
299
+ const filePath = safeRelativePath(filePathInput);
300
+ if (!room.allowedPaths.has(filePath))
301
+ return null;
302
+ return this.trackedText(room, filePath).toString();
303
+ }
304
+ /**
305
+ * Returns the current source-tree epoch. Exclusive filesystem operations
306
+ * increment this value, allowing callers to cheaply distinguish a request
307
+ * admitted before and after a project replacement without reading files.
308
+ */
309
+ currentGeneration(projectId) {
310
+ return this.projectGeneration(projectId);
311
+ }
312
+ hasPendingChanges(projectId) {
313
+ return Boolean(this.rooms.get(projectId)?.dirtyPaths.size);
314
+ }
315
+ isMaintaining(projectId) {
316
+ return this.maintenanceProjects.has(projectId);
317
+ }
318
+ isStable(projectId, revision) {
319
+ const room = this.rooms.get(projectId);
320
+ return room
321
+ ? room.maintenanceReason === null && room.persistedRevision === revision && room.dirtyPaths.size === 0
322
+ : revision === null;
323
+ }
324
+ movePath(projectId, sourceInput, destinationInput, userId) {
325
+ const room = this.rooms.get(projectId);
326
+ if (!room) {
327
+ this.invalidateProject(projectId);
108
328
  return;
329
+ }
109
330
  this.flushRoom(room);
110
331
  const source = safeRelativePath(sourceInput);
111
332
  const destination = safeRelativePath(destinationInput);
@@ -123,12 +344,16 @@ export class CollaborationService {
123
344
  }
124
345
  }, HTTP_ORIGIN);
125
346
  room.lastModifiedUserId = userId;
347
+ room.persistedRevision += 1;
348
+ room.persistedAt = new Date().toISOString();
126
349
  this.bumpFiles(room, { kind: "move", source, destination });
127
350
  }
128
351
  removePath(projectId, filePathInput) {
129
352
  const room = this.rooms.get(projectId);
130
- if (!room)
353
+ if (!room) {
354
+ this.invalidateProject(projectId);
131
355
  return;
356
+ }
132
357
  const filePath = safeRelativePath(filePathInput);
133
358
  const removed = [...room.allowedPaths].filter((candidate) => candidate === filePath || candidate.startsWith(`${filePath}/`));
134
359
  room.doc.transact(() => {
@@ -139,8 +364,18 @@ export class CollaborationService {
139
364
  room.dirtyPaths.delete(candidate);
140
365
  }
141
366
  }, HTTP_ORIGIN);
367
+ room.persistedRevision += 1;
368
+ room.persistedAt = new Date().toISOString();
142
369
  this.bumpFiles(room, { kind: "delete", path: filePath });
143
370
  }
371
+ invalidateSourceTree(projectId, filePathInput) {
372
+ this.invalidateProject(projectId);
373
+ const room = this.rooms.get(projectId);
374
+ if (!room)
375
+ return;
376
+ const filePath = filePathInput ? safeRelativePath(filePathInput) : "";
377
+ this.bumpFiles(room, { kind: "update", path: filePath });
378
+ }
144
379
  signalComments(projectId) {
145
380
  const room = this.rooms.get(projectId);
146
381
  if (!room)
@@ -167,7 +402,102 @@ export class CollaborationService {
167
402
  .slice(0, 20));
168
403
  room.doc.transact(() => room.meta.set("compileStates", retained), META_ORIGIN);
169
404
  }
405
+ /**
406
+ * Drop compile states that no longer describe an active database run.
407
+ *
408
+ * `compileStates` is intentionally only a live UI signal. A browser can
409
+ * nevertheless replay an old IndexedDB update while reconnecting, so the
410
+ * server must not let an old `queued`/`running` value become authoritative
411
+ * again after a restart. Completed states are retained for the current room
412
+ * because they are useful to the UI and cannot disable a new compile.
413
+ */
414
+ sanitizeCompileStates(room) {
415
+ const current = room.meta.get("compileStates");
416
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
417
+ if (current !== undefined)
418
+ room.doc.transact(() => room.meta.delete("compileStates"), META_ORIGIN);
419
+ return;
420
+ }
421
+ const retained = {};
422
+ let changed = false;
423
+ for (const [mainFile, value] of Object.entries(current)) {
424
+ if (!isSharedCompileState(mainFile, value)) {
425
+ changed = true;
426
+ continue;
427
+ }
428
+ if (value.status === "queued" || value.status === "running") {
429
+ const run = this.db.prepare(`SELECT status, main_file FROM compile_runs
430
+ WHERE id = ? AND project_id = ?`).get(value.runId, room.projectId);
431
+ if (!run || run.main_file !== mainFile || (run.status !== "queued" && run.status !== "running")) {
432
+ changed = true;
433
+ continue;
434
+ }
435
+ }
436
+ retained[mainFile] = value;
437
+ }
438
+ if (!changed && Object.keys(retained).length === Object.keys(current).length)
439
+ return;
440
+ room.doc.transact(() => {
441
+ if (Object.keys(retained).length)
442
+ room.meta.set("compileStates", retained);
443
+ else
444
+ room.meta.delete("compileStates");
445
+ }, META_ORIGIN);
446
+ }
447
+ sendCompileStates(room, socket) {
448
+ const payload = this.compileStatesForClient(room);
449
+ const encoder = encoding.createEncoder();
450
+ encoding.writeVarUint(encoder, MESSAGE_COMPILE_STATES);
451
+ encoding.writeVarString(encoder, JSON.stringify(payload));
452
+ send(socket, encoding.toUint8Array(encoder));
453
+ }
454
+ /**
455
+ * Include active database runs even when no live room existed when the run
456
+ * was queued. This keeps the handshake authoritative without making every
457
+ * compile request persist a duplicate Yjs metadata update.
458
+ */
459
+ compileStatesForClient(room) {
460
+ const current = room.meta.get("compileStates");
461
+ const states = isCompileStateMap(current) ? { ...current } : {};
462
+ const activeRuns = this.db.prepare(`SELECT run.id, run.main_file, run.status, run.requested_by,
463
+ run.created_at, user.username AS requested_by_username, user.display_name AS requested_by_name
464
+ FROM compile_runs run LEFT JOIN users user ON user.id = run.requested_by
465
+ WHERE run.project_id = ? AND run.status IN ('queued', 'running')
466
+ ORDER BY CASE run.status WHEN 'running' THEN 0 ELSE 1 END, run.created_at DESC`).all(room.projectId);
467
+ for (const run of activeRuns) {
468
+ if (states[run.main_file]?.status === "running" && run.status === "queued")
469
+ continue;
470
+ states[run.main_file] = {
471
+ mainFile: run.main_file,
472
+ runId: run.id,
473
+ status: run.status,
474
+ requestedBy: {
475
+ id: run.requested_by ?? "deleted-user",
476
+ username: run.requested_by_username ?? "deleted-user",
477
+ name: run.requested_by_name ?? "Deleted User"
478
+ },
479
+ updatedAt: run.created_at
480
+ };
481
+ }
482
+ return Object.fromEntries(Object.entries(states)
483
+ .sort((left, right) => Date.parse(right[1].updatedAt) - Date.parse(left[1].updatedAt))
484
+ .slice(0, 20));
485
+ }
486
+ clearRecoveredMetadata(room) {
487
+ let changed = false;
488
+ room.doc.transact(() => {
489
+ for (const key of EPHEMERAL_META_KEYS) {
490
+ if (!room.meta.has(key))
491
+ continue;
492
+ room.meta.delete(key);
493
+ changed = true;
494
+ }
495
+ }, META_ORIGIN);
496
+ return changed;
497
+ }
170
498
  closeProject(projectId) {
499
+ this.invalidateProject(projectId);
500
+ this.snapshotBarriers.delete(projectId);
171
501
  const room = this.rooms.get(projectId);
172
502
  if (!room)
173
503
  return;
@@ -176,6 +506,8 @@ export class CollaborationService {
176
506
  this.destroyRoom(room);
177
507
  }
178
508
  resetProject(projectId) {
509
+ this.invalidateProject(projectId);
510
+ this.snapshotBarriers.delete(projectId);
179
511
  const room = this.rooms.get(projectId);
180
512
  if (room) {
181
513
  for (const connection of room.connections)
@@ -186,19 +518,127 @@ export class CollaborationService {
186
518
  fs.rmSync(collaborationEpochPath(this.config, projectId), { force: true });
187
519
  }
188
520
  destroy() {
521
+ this.closed = true;
189
522
  for (const room of [...this.rooms.values()])
190
523
  this.destroyRoom(room);
524
+ this.roomInitializations.clear();
525
+ this.snapshotBarriers.clear();
526
+ this.pendingConnections.clear();
191
527
  }
192
- getRoom(projectId) {
528
+ initializeRoom(projectId, generation) {
193
529
  const existing = this.rooms.get(projectId);
194
530
  if (existing)
531
+ return Promise.resolve(existing);
532
+ const pending = this.roomInitializations.get(projectId);
533
+ if (pending)
534
+ return pending;
535
+ const request = this.loadRoomBootstrap(projectId)
536
+ .then((bootstrap) => {
537
+ if (this.closed || this.projectGeneration(projectId) !== generation) {
538
+ bootstrap.doc.destroy();
539
+ throw new Error("Collaboration room initialization was invalidated");
540
+ }
541
+ return this.createRoom(projectId, bootstrap);
542
+ })
543
+ .finally(() => {
544
+ if (this.roomInitializations.get(projectId) === request)
545
+ this.roomInitializations.delete(projectId);
546
+ });
547
+ this.roomInitializations.set(projectId, request);
548
+ return request;
549
+ }
550
+ /** Read the potentially large source tree without blocking the event loop. */
551
+ async loadRoomBootstrap(projectId) {
552
+ let doc = new Y.Doc();
553
+ try {
554
+ let recoveredState = false;
555
+ let persistedState = null;
556
+ try {
557
+ persistedState = await fs.promises.readFile(collaborationStatePath(this.config, projectId));
558
+ }
559
+ catch (error) {
560
+ if (error.code !== "ENOENT")
561
+ throw error;
562
+ }
563
+ if (persistedState) {
564
+ try {
565
+ Y.applyUpdate(doc, persistedState, DISK_ORIGIN);
566
+ recoveredState = true;
567
+ }
568
+ catch {
569
+ // Malformed Yjs data is recoverable because source files remain the
570
+ // authority. Start with a pristine document in case applyUpdate()
571
+ // partially mutated the original before rejecting the payload.
572
+ doc.destroy();
573
+ doc = new Y.Doc();
574
+ }
575
+ }
576
+ const epoch = await collaborationEpochAsync(this.config, projectId, recoveredState);
577
+ const files = await this.readCollaborativeFiles(projectId);
578
+ return { doc, recoveredState, epoch, files };
579
+ }
580
+ catch (error) {
581
+ doc.destroy();
582
+ throw error;
583
+ }
584
+ }
585
+ /**
586
+ * Recheck file metadata after reading. This closes the small race where an
587
+ * upload or autosave changes a file while the cold room is being hydrated.
588
+ */
589
+ async readCollaborativeFiles(projectId) {
590
+ const assertSourceRoot = async () => {
591
+ const stats = await fs.promises.lstat(sourceRoot(this.config, projectId));
592
+ if (stats.isSymbolicLink())
593
+ throw new Error("Collaborative source root cannot be a symbolic link");
594
+ if (!stats.isDirectory())
595
+ throw new Error("Collaborative source root is not a directory");
596
+ };
597
+ let lastFailure = "Unable to obtain a stable collaborative source snapshot";
598
+ for (let attempt = 0; attempt < 3; attempt += 1) {
599
+ await assertSourceRoot();
600
+ const entries = (await listProjectFilesAsync(this.config, projectId))
601
+ .filter((entry) => entry.type === "file" && isCollaborativeTextFile(entry.path)
602
+ && (entry.size ?? 0) <= maxCollaborativeFileBytes(this.config));
603
+ const files = [];
604
+ const failedPaths = [];
605
+ for (let offset = 0; offset < entries.length; offset += 8) {
606
+ const batch = entries.slice(offset, offset + 8);
607
+ const loaded = await Promise.all(batch.map(async (entry) => {
608
+ try {
609
+ return { path: entry.path, content: await fs.promises.readFile(resolveSourcePath(this.config, projectId, entry.path), "utf8") };
610
+ }
611
+ catch {
612
+ failedPaths.push(entry.path);
613
+ return null;
614
+ }
615
+ }));
616
+ files.push(...loaded.filter((file) => file !== null));
617
+ }
618
+ const currentEntries = (await listProjectFilesAsync(this.config, projectId))
619
+ .filter((entry) => entry.type === "file" && isCollaborativeTextFile(entry.path)
620
+ && (entry.size ?? 0) <= maxCollaborativeFileBytes(this.config));
621
+ await assertSourceRoot();
622
+ const before = entries.map(collaborativeEntrySignature).sort().join("\n");
623
+ const after = currentEntries.map(collaborativeEntrySignature).sort().join("\n");
624
+ if (before === after && failedPaths.length === 0)
625
+ return files;
626
+ if (failedPaths.length)
627
+ lastFailure = `Unable to read collaborative source files: ${failedPaths.join(", ")}`;
628
+ }
629
+ throw new Error(lastFailure);
630
+ }
631
+ createRoom(projectId, bootstrap) {
632
+ const existing = this.rooms.get(projectId);
633
+ if (existing) {
634
+ bootstrap.doc.destroy();
195
635
  return existing;
196
- const doc = new Y.Doc();
636
+ }
197
637
  const room = {
198
638
  projectId,
199
- doc,
200
- awareness: new Awareness(doc),
201
- meta: doc.getMap("texlite:meta"),
639
+ doc: bootstrap.doc,
640
+ awareness: new Awareness(bootstrap.doc),
641
+ meta: bootstrap.doc.getMap("texlite:meta"),
202
642
  connections: new Set(),
203
643
  awarenessOwners: new Map(),
204
644
  allowedPaths: new Set(),
@@ -209,58 +649,68 @@ export class CollaborationService {
209
649
  stateSaveTimer: null,
210
650
  cleanupTimer: null,
211
651
  lastModifiedUserId: null,
212
- epoch: "",
652
+ epoch: bootstrap.epoch,
213
653
  persistedRevision: 0,
214
- persistedAt: new Date().toISOString()
654
+ persistedAt: new Date().toISOString(),
655
+ maintenanceReason: this.maintenanceProjects.get(projectId) ?? null,
656
+ snapshotBarrierDepth: this.snapshotBarriers.get(projectId) ?? 0,
657
+ snapshotFlushPending: false,
658
+ pendingFlushes: [],
659
+ rejectedPaths: new Set(),
660
+ compileMetaValidationPending: false
215
661
  };
216
662
  room.awareness.setLocalState(null);
217
- const persistedState = collaborationStatePath(this.config, projectId);
218
- let recoveredState = false;
663
+ // A recovered Yjs document may contain metadata from a process that no
664
+ // longer exists. The database/source tree are authoritative after a
665
+ // restart; clear the markers before any browser can sync them back.
666
+ const recoveredMetadataChanged = bootstrap.recoveredState && this.clearRecoveredMetadata(room);
667
+ this.sanitizeCompileStates(room);
219
668
  let recoveredDirty = false;
220
- if (fs.existsSync(persistedState)) {
221
- try {
222
- Y.applyUpdate(doc, fs.readFileSync(persistedState), DISK_ORIGIN);
223
- recoveredState = true;
224
- }
225
- catch {
226
- // A corrupt state file is ignored; the source files remain authoritative.
227
- }
228
- }
229
- room.epoch = collaborationEpoch(this.config, projectId, recoveredState);
230
669
  const diskPaths = new Set();
231
- doc.transact(() => {
232
- for (const entry of listProjectFiles(this.config, projectId)) {
233
- if (entry.type !== "file" || !isCollaborativeTextFile(entry.path) || (entry.size ?? 0) > MAX_COLLABORATIVE_FILE_BYTES)
234
- continue;
235
- const content = fs.readFileSync(resolveSourcePath(this.config, projectId, entry.path), "utf8");
236
- diskPaths.add(entry.path);
237
- room.allowedPaths.add(entry.path);
238
- room.persistedContent.set(entry.path, content);
239
- const name = typeName(entry.path);
240
- // Yjs decodes top-level shared types lazily as AbstractType instances;
241
- // the stable source: namespace identifies text more reliably than
242
- // instanceof before getText() materializes the public type.
243
- const hasRecoveredText = recoveredState && doc.share.has(name);
244
- const text = this.trackedText(room, entry.path);
245
- if (hasRecoveredText && text.toString() !== content) {
246
- room.dirtyPaths.add(entry.path);
670
+ room.doc.transact(() => {
671
+ for (const file of bootstrap.files) {
672
+ diskPaths.add(file.path);
673
+ room.allowedPaths.add(file.path);
674
+ room.persistedContent.set(file.path, file.content);
675
+ const name = typeName(file.path);
676
+ // Yjs decodes top-level shared types lazily; the source: namespace is
677
+ // the stable identifier before getText() materializes the public type.
678
+ const hasRecoveredText = bootstrap.recoveredState && room.doc.share.has(name);
679
+ const text = this.trackedText(room, file.path);
680
+ if (hasRecoveredText && text.toString() !== file.content) {
681
+ room.dirtyPaths.add(file.path);
247
682
  recoveredDirty = true;
248
683
  }
249
684
  else {
250
- replaceText(text, content);
685
+ replaceText(text, file.content);
251
686
  }
252
687
  }
253
- for (const name of doc.share.keys()) {
688
+ for (const name of room.doc.share.keys()) {
254
689
  if (!name.startsWith(SOURCE_PREFIX))
255
690
  continue;
256
691
  const filePath = name.slice(SOURCE_PREFIX.length);
257
692
  if (!diskPaths.has(filePath))
258
- replaceText(doc.getText(name), "");
693
+ replaceText(room.doc.getText(name), "");
259
694
  }
260
695
  }, DISK_ORIGIN);
261
- doc.on("update", (update, origin) => {
696
+ const rejectedRecoveredPaths = this.rejectOversizedTexts(room);
697
+ recoveredDirty = room.dirtyPaths.size > 0;
698
+ room.meta.observe((_event, transaction) => {
699
+ if (isConnectionOrigin(transaction.origin))
700
+ room.compileMetaValidationPending = true;
701
+ });
702
+ room.doc.on("update", (update, origin) => {
262
703
  this.broadcast(room, syncUpdateMessage(update), origin instanceof Object && "socket" in origin ? origin : null);
263
- this.scheduleStateSave(room);
704
+ // Compile state, file-list revisions, and comment/dictionary revision
705
+ // markers are ephemeral metadata. They are reconstructed from SQLite
706
+ // and the source tree after a restart, so do not encode and synchronously
707
+ // rewrite the complete Yjs document for every metadata-only event.
708
+ if (origin !== META_ORIGIN)
709
+ this.scheduleStateSave(room);
710
+ if (room.compileMetaValidationPending) {
711
+ room.compileMetaValidationPending = false;
712
+ this.sanitizeCompileStates(room);
713
+ }
264
714
  if (origin !== DISK_ORIGIN && origin !== HTTP_ORIGIN && origin !== META_ORIGIN) {
265
715
  if (origin && typeof origin === "object" && "user" in origin) {
266
716
  room.lastModifiedUserId = origin.user.id;
@@ -283,13 +733,65 @@ export class CollaborationService {
283
733
  }
284
734
  }
285
735
  });
736
+ try {
737
+ // Persist a corrected state even when an oversized recovered document was
738
+ // reverted to the source file and no source write remains dirty.
739
+ if (recoveredDirty || rejectedRecoveredPaths.length > 0)
740
+ this.flushRoom(room);
741
+ else if (recoveredMetadataChanged)
742
+ this.scheduleStateSave(room);
743
+ }
744
+ catch (error) {
745
+ this.disposeRoom(room);
746
+ throw error;
747
+ }
286
748
  this.rooms.set(projectId, room);
287
- if (recoveredDirty)
288
- this.flushRoom(room);
289
- else
290
- this.persistRoomState(room);
749
+ // A fresh room has no unsaved Yjs state yet. Delaying state persistence
750
+ // avoids a large synchronous write on the first connection; subsequent
751
+ // updates schedule the normal atomic save.
291
752
  return room;
292
753
  }
754
+ attachConnection(room, socket, user) {
755
+ if (socket.readyState !== WebSocket.OPEN)
756
+ return;
757
+ if (!accessibleProject(this.db, room.projectId, user)) {
758
+ socket.close(1008, "Project access denied");
759
+ return;
760
+ }
761
+ const maintenanceReason = this.maintenanceProjects.get(room.projectId);
762
+ if (maintenanceReason) {
763
+ socket.close(1013, `Project is temporarily unavailable: ${maintenanceReason}`);
764
+ return;
765
+ }
766
+ if (room.connections.size >= MAX_PROJECT_SESSIONS) {
767
+ socket.close(1013, "Project collaboration room is full");
768
+ return;
769
+ }
770
+ if (room.cleanupTimer) {
771
+ clearTimeout(room.cleanupTimer);
772
+ room.cleanupTimer = null;
773
+ }
774
+ const connection = {
775
+ socket, user, awarenessClientId: null, protocolVerified: false, protocolTimer: null
776
+ };
777
+ room.connections.add(connection);
778
+ socket.binaryType = "arraybuffer";
779
+ socket.on("message", (data) => {
780
+ try {
781
+ this.handleMessage(room, connection, rawData(data));
782
+ }
783
+ catch {
784
+ socket.close(1003, "Invalid collaboration message");
785
+ }
786
+ });
787
+ socket.on("close", () => this.disconnect(room, connection));
788
+ socket.on("error", () => this.disconnect(room, connection));
789
+ connection.protocolTimer = setTimeout(() => {
790
+ if (!connection.protocolVerified)
791
+ socket.close(4001, "Reload required");
792
+ }, 10_000);
793
+ send(socket, protocolMessage(room.epoch));
794
+ }
293
795
  trackedText(room, filePath) {
294
796
  const text = room.doc.getText(typeName(filePath));
295
797
  if (room.textObservers.has(filePath))
@@ -312,25 +814,50 @@ export class CollaborationService {
312
814
  return text;
313
815
  }
314
816
  handleMessage(room, connection, bytes) {
315
- const current = accessibleProject(this.db, room.projectId, connection.user);
817
+ const refreshedUser = this.db.prepare("SELECT * FROM users WHERE id = ?").get(connection.user.id);
818
+ if (!refreshedUser || refreshedUser.disabled) {
819
+ connection.socket.close(1008, "Project access revoked");
820
+ this.disconnect(room, connection);
821
+ return;
822
+ }
823
+ connection.user = refreshedUser;
824
+ const current = accessibleProject(this.db, room.projectId, refreshedUser);
316
825
  if (!current) {
317
826
  connection.socket.close(1008, "Project access revoked");
318
827
  return;
319
828
  }
320
829
  const decoder = decoding.createDecoder(bytes);
321
830
  const messageType = decoding.readVarUint(decoder);
831
+ if (room.maintenanceReason && messageType !== MESSAGE_PROTOCOL && messageType !== MESSAGE_QUERY_AWARENESS) {
832
+ return;
833
+ }
322
834
  if (messageType === MESSAGE_PROTOCOL) {
323
835
  const epoch = decoding.readVarString(decoder);
836
+ const clientProtocolVersion = decoding.hasContent(decoder)
837
+ ? decoding.readVarUint(decoder)
838
+ : null;
324
839
  if (epoch !== room.epoch) {
325
840
  connection.socket.close(4001, "Collaboration state changed; reload required");
326
841
  return;
327
842
  }
843
+ // A pre-versioning client cannot safely decode the durable FLUSH
844
+ // response introduced in the previous release. Send a protocol marker
845
+ // that deliberately differs from the room epoch; the existing client
846
+ // handler treats it as a state change and reloads before it can edit.
847
+ // New clients append their version to the otherwise-compatible handshake.
848
+ if (clientProtocolVersion !== COLLABORATION_PROTOCOL_VERSION) {
849
+ send(connection.socket, protocolMessage(`${room.epoch}:reload`));
850
+ connection.socket.close(4001, "Collaboration protocol upgrade required");
851
+ return;
852
+ }
328
853
  if (!connection.protocolVerified) {
329
854
  connection.protocolVerified = true;
330
855
  if (connection.protocolTimer)
331
856
  clearTimeout(connection.protocolTimer);
332
857
  connection.protocolTimer = null;
858
+ this.sanitizeCompileStates(room);
333
859
  this.sendSyncStep1(room, connection.socket);
860
+ this.sendCompileStates(room, connection.socket);
334
861
  this.sendAwareness(room, connection.socket);
335
862
  }
336
863
  return;
@@ -344,10 +871,17 @@ export class CollaborationService {
344
871
  if (syncType === syncProtocol.messageYjsSyncStep1) {
345
872
  syncProtocol.readSyncStep1(decoder, encoder, room.doc);
346
873
  }
347
- else if (canEdit(current)) {
348
- if (syncType === syncProtocol.messageYjsSyncStep2)
874
+ else if (syncType === syncProtocol.messageYjsSyncStep2) {
875
+ // Read-only browsers may still carry a stale IndexedDB document. Do
876
+ // not apply their source updates, but always send the authoritative
877
+ // ephemeral compile state back to them.
878
+ if (canEdit(current))
349
879
  syncProtocol.readSyncStep2(decoder, room.doc, connection);
350
- else if (syncType === syncProtocol.messageYjsUpdate)
880
+ this.sanitizeCompileStates(room);
881
+ this.sendCompileStates(room, connection.socket);
882
+ }
883
+ else if (canEdit(current)) {
884
+ if (syncType === syncProtocol.messageYjsUpdate)
351
885
  syncProtocol.readUpdate(decoder, room.doc, connection);
352
886
  }
353
887
  if (encoding.length(encoder) > 1)
@@ -362,13 +896,24 @@ export class CollaborationService {
362
896
  const requestId = decoding.readVarString(decoder).slice(0, 128);
363
897
  if (!canEdit(current))
364
898
  return;
365
- const receipt = this.flushRoom(room);
366
- const encoder = encoding.createEncoder();
367
- encoding.writeVarUint(encoder, MESSAGE_FLUSH);
368
- encoding.writeVarString(encoder, requestId);
369
- encoding.writeVarUint(encoder, receipt.revision);
370
- encoding.writeVarString(encoder, receipt.persistedAt);
371
- send(connection.socket, encoding.toUint8Array(encoder));
899
+ if (room.snapshotBarrierDepth > 0 || (this.snapshotBarriers.get(room.projectId) ?? 0) > 0) {
900
+ // Do not report the intentionally deferred flush as a failure. The
901
+ // request is completed by endSnapshotBarrier() after the source tree
902
+ // is released and the pending edits have been persisted.
903
+ room.pendingFlushes.push({ connection, requestId });
904
+ room.snapshotFlushPending = room.dirtyPaths.size > 0 || room.snapshotFlushPending;
905
+ return;
906
+ }
907
+ let receipt;
908
+ try {
909
+ receipt = this.flushRoom(room);
910
+ }
911
+ catch {
912
+ receipt = this.currentReceipt(room, false, [...room.dirtyPaths]);
913
+ if (room.dirtyPaths.size > 0)
914
+ this.scheduleSave(room);
915
+ }
916
+ this.sendFlushReceipt(connection, requestId, receipt);
372
917
  return;
373
918
  }
374
919
  if (messageType !== MESSAGE_AWARENESS)
@@ -401,6 +946,7 @@ export class CollaborationService {
401
946
  disconnect(room, connection) {
402
947
  if (!room.connections.delete(connection))
403
948
  return;
949
+ room.pendingFlushes = room.pendingFlushes.filter((pending) => pending.connection !== connection);
404
950
  if (connection.protocolTimer)
405
951
  clearTimeout(connection.protocolTimer);
406
952
  connection.protocolTimer = null;
@@ -408,9 +954,7 @@ export class CollaborationService {
408
954
  removeAwarenessStates(room.awareness, [connection.awarenessClientId], connection);
409
955
  room.awarenessOwners.delete(connection.awarenessClientId);
410
956
  }
411
- if (room.connections.size === 0 && !room.cleanupTimer) {
412
- room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
413
- }
957
+ this.scheduleRoomCleanup(room);
414
958
  }
415
959
  sendSyncStep1(room, socket) {
416
960
  const encoder = encoding.createEncoder();
@@ -428,6 +972,11 @@ export class CollaborationService {
428
972
  if (connection !== except)
429
973
  send(connection.socket, message);
430
974
  }
975
+ scheduleRoomCleanup(room) {
976
+ if (room.connections.size === 0 && !room.cleanupTimer) {
977
+ room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
978
+ }
979
+ }
431
980
  scheduleSave(room) {
432
981
  if (room.saveTimer)
433
982
  clearTimeout(room.saveTimer);
@@ -456,6 +1005,7 @@ export class CollaborationService {
456
1005
  if (room.stateSaveTimer)
457
1006
  clearTimeout(room.stateSaveTimer);
458
1007
  room.stateSaveTimer = null;
1008
+ this.rejectOversizedTexts(room);
459
1009
  const target = collaborationStatePath(this.config, room.projectId);
460
1010
  const temporary = `${target}.tmp`;
461
1011
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
@@ -463,13 +1013,23 @@ export class CollaborationService {
463
1013
  fs.renameSync(temporary, target);
464
1014
  }
465
1015
  flushRoom(room) {
1016
+ if (room.snapshotBarrierDepth > 0 || (this.snapshotBarriers.get(room.projectId) ?? 0) > 0) {
1017
+ if (room.saveTimer)
1018
+ clearTimeout(room.saveTimer);
1019
+ room.saveTimer = null;
1020
+ if (room.dirtyPaths.size > 0)
1021
+ room.snapshotFlushPending = true;
1022
+ return this.currentReceipt(room, room.dirtyPaths.size === 0, []);
1023
+ }
466
1024
  const startedAt = performance.now();
467
1025
  if (room.saveTimer)
468
1026
  clearTimeout(room.saveTimer);
469
1027
  room.saveTimer = null;
1028
+ this.rejectOversizedTexts(room);
470
1029
  this.persistRoomState(room);
471
1030
  let changed = false;
472
1031
  const changedPaths = [];
1032
+ const failedPaths = [...room.rejectedPaths];
473
1033
  const dirtyPaths = [...room.dirtyPaths];
474
1034
  for (const filePath of dirtyPaths) {
475
1035
  if (!room.allowedPaths.has(filePath)) {
@@ -482,11 +1042,18 @@ export class CollaborationService {
482
1042
  room.dirtyPaths.delete(filePath);
483
1043
  continue;
484
1044
  }
485
- if (Buffer.byteLength(next, "utf8") > this.config.maxUploadBytes)
486
- throw new Error(`Collaborative file exceeds upload limit: ${filePath}`);
1045
+ if (Buffer.byteLength(next, "utf8") > maxCollaborativeFileBytes(this.config)) {
1046
+ failedPaths.push(filePath);
1047
+ continue;
1048
+ }
487
1049
  const absolute = resolveSourcePath(this.config, room.projectId, filePath);
488
- if (!fs.existsSync(absolute))
489
- throw new Error(`Collaborative source path disappeared: ${filePath}`);
1050
+ try {
1051
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1052
+ }
1053
+ catch {
1054
+ failedPaths.push(filePath);
1055
+ continue;
1056
+ }
490
1057
  const temporary = `${absolute}.collaboration-${process.pid}-${randomUUID()}.tmp`;
491
1058
  try {
492
1059
  fs.writeFileSync(temporary, next, { encoding: "utf8", mode: 0o600 });
@@ -494,7 +1061,8 @@ export class CollaborationService {
494
1061
  }
495
1062
  catch (error) {
496
1063
  fs.rmSync(temporary, { force: true });
497
- throw error;
1064
+ failedPaths.push(filePath);
1065
+ continue;
498
1066
  }
499
1067
  room.persistedContent.set(filePath, next);
500
1068
  room.dirtyPaths.delete(filePath);
@@ -517,9 +1085,34 @@ export class CollaborationService {
517
1085
  }
518
1086
  catch { /* Source durability must not depend on optional history bookkeeping. */ }
519
1087
  }
520
- room.persistedRevision += 1;
521
- room.persistedAt = new Date().toISOString();
522
- return { revision: room.persistedRevision, persistedAt: room.persistedAt };
1088
+ const ok = failedPaths.length === 0 && room.dirtyPaths.size === 0;
1089
+ if (changed)
1090
+ room.persistedRevision += 1;
1091
+ if (ok) {
1092
+ room.persistedAt = new Date().toISOString();
1093
+ }
1094
+ else if (!room.saveTimer && room.dirtyPaths.size > 0) {
1095
+ // Retry with backoff if dirty files remain unpersisted
1096
+ room.saveTimer = setTimeout(() => {
1097
+ try {
1098
+ this.flushRoom(room);
1099
+ }
1100
+ catch {
1101
+ room.saveTimer = null;
1102
+ }
1103
+ }, 2000);
1104
+ }
1105
+ const receipt = this.currentReceipt(room, ok, [...new Set(failedPaths)]);
1106
+ room.rejectedPaths.clear();
1107
+ return receipt;
1108
+ }
1109
+ currentReceipt(room, ok = true, failedPaths = []) {
1110
+ return {
1111
+ revision: room.persistedRevision,
1112
+ persistedAt: room.persistedAt,
1113
+ ok: ok && room.dirtyPaths.size === 0,
1114
+ failedPaths
1115
+ };
523
1116
  }
524
1117
  bumpFiles(room, event) {
525
1118
  room.doc.transact(() => room.meta.set("filesEvent", { ...event, revision: randomUUID() }), META_ORIGIN);
@@ -527,13 +1120,6 @@ export class CollaborationService {
527
1120
  destroyRoom(room, persist = true) {
528
1121
  if (this.rooms.get(room.projectId) !== room)
529
1122
  return;
530
- if (room.cleanupTimer)
531
- clearTimeout(room.cleanupTimer);
532
- for (const connection of room.connections) {
533
- if (connection.protocolTimer)
534
- clearTimeout(connection.protocolTimer);
535
- connection.protocolTimer = null;
536
- }
537
1123
  if (persist) {
538
1124
  try {
539
1125
  this.flushRoom(room);
@@ -545,18 +1131,74 @@ export class CollaborationService {
545
1131
  catch { /* Keep shutdown best-effort. */ }
546
1132
  }
547
1133
  }
548
- else {
549
- if (room.saveTimer)
550
- clearTimeout(room.saveTimer);
551
- if (room.stateSaveTimer)
552
- clearTimeout(room.stateSaveTimer);
1134
+ this.disposeRoom(room);
1135
+ this.rooms.delete(room.projectId);
1136
+ }
1137
+ disposeRoom(room) {
1138
+ if (room.cleanupTimer)
1139
+ clearTimeout(room.cleanupTimer);
1140
+ if (room.saveTimer)
1141
+ clearTimeout(room.saveTimer);
1142
+ if (room.stateSaveTimer)
1143
+ clearTimeout(room.stateSaveTimer);
1144
+ for (const connection of room.connections) {
1145
+ if (connection.protocolTimer)
1146
+ clearTimeout(connection.protocolTimer);
1147
+ connection.protocolTimer = null;
553
1148
  }
1149
+ room.connections.clear();
1150
+ room.pendingFlushes.splice(0);
1151
+ room.rejectedPaths.clear();
1152
+ room.awarenessOwners.clear();
554
1153
  for (const [filePath, observer] of room.textObservers) {
555
1154
  room.doc.getText(typeName(filePath)).unobserve(observer);
556
1155
  }
557
1156
  room.awareness.destroy();
558
1157
  room.doc.destroy();
559
- this.rooms.delete(room.projectId);
1158
+ }
1159
+ rejectOversizedTexts(room) {
1160
+ const rejected = [];
1161
+ const limit = maxCollaborativeFileBytes(this.config);
1162
+ for (const filePath of [...room.dirtyPaths]) {
1163
+ const text = this.trackedText(room, filePath);
1164
+ if (Buffer.byteLength(text.toString(), "utf8") <= limit)
1165
+ continue;
1166
+ const previous = room.persistedContent.get(filePath) ?? "";
1167
+ room.doc.transact(() => replaceText(text, previous), DISK_ORIGIN);
1168
+ room.dirtyPaths.delete(filePath);
1169
+ room.rejectedPaths.add(filePath);
1170
+ rejected.push(filePath);
1171
+ }
1172
+ return rejected;
1173
+ }
1174
+ sendFlushReceipt(connection, requestId, receipt) {
1175
+ const encoder = encoding.createEncoder();
1176
+ encoding.writeVarUint(encoder, MESSAGE_FLUSH);
1177
+ encoding.writeVarString(encoder, requestId);
1178
+ encoding.writeVarUint(encoder, receipt.ok ? 1 : 0);
1179
+ encoding.writeVarUint(encoder, receipt.revision);
1180
+ encoding.writeVarString(encoder, receipt.persistedAt);
1181
+ encoding.writeVarUint(encoder, receipt.failedPaths?.length ?? 0);
1182
+ for (const failed of receipt.failedPaths ?? [])
1183
+ encoding.writeVarString(encoder, failed);
1184
+ send(connection.socket, encoding.toUint8Array(encoder));
1185
+ }
1186
+ resolvePendingFlushes(room, receipt) {
1187
+ const pending = room.pendingFlushes.splice(0);
1188
+ for (const request of pending) {
1189
+ if (room.connections.has(request.connection))
1190
+ this.sendFlushReceipt(request.connection, request.requestId, receipt);
1191
+ }
1192
+ }
1193
+ projectGeneration(projectId) {
1194
+ return this.projectGenerations.get(projectId) ?? 0;
1195
+ }
1196
+ pendingConnectionKey(projectId, generation) {
1197
+ return `${projectId}\0${generation}`;
1198
+ }
1199
+ invalidateProject(projectId) {
1200
+ this.projectGenerations.set(projectId, this.projectGeneration(projectId) + 1);
1201
+ this.roomInitializations.delete(projectId);
560
1202
  }
561
1203
  }
562
1204
  export function collaborationStatePath(config, projectId) {
@@ -565,23 +1207,42 @@ export function collaborationStatePath(config, projectId) {
565
1207
  export function collaborationEpochPath(config, projectId) {
566
1208
  return path.join(outputRoot(config, projectId), ".texlite", "collaboration.epoch");
567
1209
  }
568
- function collaborationEpoch(config, projectId, recoveredState) {
1210
+ export function maxCollaborativeFileBytes(config) {
1211
+ return Math.min(config.maxUploadBytes, MAX_COLLABORATIVE_FILE_BYTES);
1212
+ }
1213
+ export function isCollaborativeTextFile(filePath) {
1214
+ return /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
1215
+ }
1216
+ function collaborativeEntrySignature(entry) {
1217
+ return `${entry.path}\0${entry.size ?? 0}\0${entry.mtimeMs ?? 0}`;
1218
+ }
1219
+ async function collaborationEpochAsync(config, projectId, recoveredState) {
569
1220
  const target = collaborationEpochPath(config, projectId);
570
- if (recoveredState && fs.existsSync(target)) {
571
- const existing = fs.readFileSync(target, "utf8").trim();
572
- if (/^[a-f0-9-]{36}$/i.test(existing))
573
- return existing;
1221
+ if (recoveredState) {
1222
+ try {
1223
+ const existing = (await fs.promises.readFile(target, "utf8")).trim();
1224
+ if (new RegExp(`^${COLLABORATION_PROTOCOL_VERSION}:[a-f0-9-]{36}$`, "i").test(existing))
1225
+ return existing;
1226
+ }
1227
+ catch (error) {
1228
+ if (error.code !== "ENOENT")
1229
+ throw error;
1230
+ // Generate a fresh epoch when the previous marker is missing.
1231
+ }
1232
+ }
1233
+ const epoch = `${VERSIONED_EPOCH_PREFIX}${randomUUID()}`;
1234
+ const temporary = `${target}.${process.pid}-${randomUUID()}.tmp`;
1235
+ await fs.promises.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
1236
+ try {
1237
+ await fs.promises.writeFile(temporary, epoch, { encoding: "utf8", mode: 0o600 });
1238
+ await fs.promises.rename(temporary, target);
1239
+ }
1240
+ catch (error) {
1241
+ await fs.promises.rm(temporary, { force: true });
1242
+ throw error;
574
1243
  }
575
- const epoch = randomUUID();
576
- const temporary = `${target}.tmp`;
577
- fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
578
- fs.writeFileSync(temporary, epoch, { encoding: "utf8", mode: 0o600 });
579
- fs.renameSync(temporary, target);
580
1244
  return epoch;
581
1245
  }
582
- function isCollaborativeTextFile(filePath) {
583
- return /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
584
- }
585
1246
  function typeName(filePath) {
586
1247
  return `${SOURCE_PREFIX}${filePath}`;
587
1248
  }
@@ -611,6 +1272,36 @@ function protocolMessage(epoch) {
611
1272
  encoding.writeVarString(encoder, epoch);
612
1273
  return encoding.toUint8Array(encoder);
613
1274
  }
1275
+ function isConnectionOrigin(origin) {
1276
+ return Boolean(origin && typeof origin === "object" && "socket" in origin);
1277
+ }
1278
+ function isSharedCompileState(mainFile, value) {
1279
+ if (!value || typeof value !== "object" || Array.isArray(value))
1280
+ return false;
1281
+ const state = value;
1282
+ const requestedBy = state.requestedBy;
1283
+ return state.mainFile === mainFile
1284
+ && typeof state.runId === "string" && state.runId.length > 0
1285
+ && (state.status === "queued" || state.status === "running" || state.status === "succeeded"
1286
+ || state.status === "failed" || state.status === "cleaned")
1287
+ && (state.status !== "cleaned" || state.cleanMode === "cache" || state.cleanMode === "artifacts")
1288
+ && (state.stale === undefined || typeof state.stale === "boolean")
1289
+ && typeof state.updatedAt === "string"
1290
+ && Boolean(requestedBy && typeof requestedBy === "object"
1291
+ && typeof requestedBy.id === "string" && typeof requestedBy.username === "string"
1292
+ && typeof requestedBy.name === "string");
1293
+ }
1294
+ function isCompileStateMap(value) {
1295
+ if (!value || typeof value !== "object" || Array.isArray(value))
1296
+ return false;
1297
+ return Object.entries(value).every(([mainFile, state]) => isSharedCompileState(mainFile, state));
1298
+ }
1299
+ function maintenanceMessage(reason) {
1300
+ const encoder = encoding.createEncoder();
1301
+ encoding.writeVarUint(encoder, MESSAGE_MAINTENANCE);
1302
+ encoding.writeVarString(encoder, reason);
1303
+ return encoding.toUint8Array(encoder);
1304
+ }
614
1305
  function awarenessClientIds(update) {
615
1306
  const decoder = decoding.createDecoder(update);
616
1307
  const count = decoding.readVarUint(decoder);