leafrelay 0.2.2 → 0.2.4

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.
@@ -18,10 +18,12 @@ import { createHash } from "crypto";
18
18
  import { posix } from "path";
19
19
  import DiffMatchPatch from "diff-match-patch";
20
20
  import { posix as posix2 } from "path";
21
+ import DiffMatchPatch2 from "diff-match-patch";
21
22
  import { createHash as createHash2 } from "crypto";
22
23
  import { diff3Merge } from "node-diff3";
23
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
24
- import { dirname as dirname3, resolve as resolve4 } from "path";
24
+ import { randomUUID } from "crypto";
25
+ import { mkdir as mkdir3, readFile as readFile4, readdir as readdir2, rename, rm as rm2, writeFile as writeFile3 } from "fs/promises";
26
+ import { dirname as dirname3, join as join2, resolve as resolve4 } from "path";
25
27
  var PACKET_TYPES = [
26
28
  "disconnect",
27
29
  "connect",
@@ -152,6 +154,7 @@ var OverleafRealtimeSocket = class {
152
154
  cookie;
153
155
  socket;
154
156
  connected = false;
157
+ projectJoined = false;
155
158
  explicitlyDisconnected = false;
156
159
  reconnectEnabled;
157
160
  reconnectAttempts = 0;
@@ -160,7 +163,6 @@ var OverleafRealtimeSocket = class {
160
163
  closeTimeoutMs = 6e4;
161
164
  acknowledgementId = 0;
162
165
  acknowledgements = /* @__PURE__ */ new Map();
163
- sendBuffer = [];
164
166
  io = {
165
167
  reconnect: (enabled) => {
166
168
  this.reconnectEnabled = enabled;
@@ -192,15 +194,24 @@ var OverleafRealtimeSocket = class {
192
194
  if (id && callback) {
193
195
  this.acknowledgements.set(id, callback);
194
196
  }
195
- this.send(encodePacket({
197
+ const packet = encodePacket({
196
198
  type: "event",
197
199
  id,
198
200
  ack: callback ? "data" : void 0,
199
201
  name: event,
200
202
  args
201
- }));
203
+ });
204
+ if (!this.sendApplicationEvent(packet) && callback) {
205
+ if (id) {
206
+ this.acknowledgements.delete(id);
207
+ }
208
+ queueMicrotask(() => callback(new Error("The Overleaf project session is not connected.")));
209
+ }
202
210
  return this;
203
211
  }
212
+ markProjectJoined() {
213
+ this.projectJoined = true;
214
+ }
204
215
  disconnect() {
205
216
  this.explicitlyDisconnected = true;
206
217
  this.reconnectEnabled = false;
@@ -215,6 +226,7 @@ var OverleafRealtimeSocket = class {
215
226
  }
216
227
  async connect() {
217
228
  try {
229
+ this.projectJoined = false;
218
230
  const handshakeUrl = new URL("/socket.io/1/", this.endpoint.origin);
219
231
  for (const [name, value] of this.endpoint.searchParams) {
220
232
  handshakeUrl.searchParams.set(name, value);
@@ -243,7 +255,7 @@ var OverleafRealtimeSocket = class {
243
255
  this.socket.addEventListener("open", () => this.events.emit("connecting", "websocket"));
244
256
  this.socket.addEventListener("message", (event) => void this.receive(event.data));
245
257
  this.socket.addEventListener("error", (error2) => this.events.emit("error", error2));
246
- this.socket.addEventListener("close", () => this.handleClose());
258
+ this.socket.addEventListener("close", (event) => this.handleClose(event));
247
259
  } catch (error2) {
248
260
  this.events.emit("connect_failed", error2);
249
261
  this.events.emit("error", error2);
@@ -263,7 +275,6 @@ var OverleafRealtimeSocket = class {
263
275
  const wasReconnecting = this.reconnectAttempts > 0;
264
276
  this.connected = true;
265
277
  this.events.emit("connect");
266
- this.flush();
267
278
  if (wasReconnecting) {
268
279
  this.events.emit("reconnect", "websocket", this.reconnectAttempts);
269
280
  this.reconnectAttempts = 0;
@@ -309,16 +320,16 @@ var OverleafRealtimeSocket = class {
309
320
  }
310
321
  }
311
322
  send(packet) {
312
- if (!this.connected || this.socket?.readyState !== WebSocket.OPEN) {
313
- this.sendBuffer.push(packet);
314
- return;
323
+ if (this.connected && this.socket?.readyState === WebSocket.OPEN) {
324
+ this.socket.send(packet);
315
325
  }
316
- this.socket.send(packet);
317
326
  }
318
- flush() {
319
- while (this.sendBuffer.length > 0 && this.socket?.readyState === WebSocket.OPEN) {
320
- this.socket.send(this.sendBuffer.shift());
327
+ sendApplicationEvent(packet) {
328
+ if (!this.connected || !this.projectJoined || this.socket?.readyState !== WebSocket.OPEN) {
329
+ return false;
321
330
  }
331
+ this.socket.send(packet);
332
+ return true;
322
333
  }
323
334
  resetInactivityTimer() {
324
335
  if (this.inactivityTimer) {
@@ -326,20 +337,25 @@ var OverleafRealtimeSocket = class {
326
337
  }
327
338
  this.inactivityTimer = setTimeout(() => this.closeSocket("timeout"), this.closeTimeoutMs);
328
339
  }
329
- handleClose() {
340
+ handleClose(event) {
330
341
  const wasConnected = this.connected;
331
342
  this.connected = false;
343
+ this.projectJoined = false;
332
344
  if (this.inactivityTimer) {
333
345
  clearTimeout(this.inactivityTimer);
334
346
  }
335
347
  if (wasConnected) {
336
- this.events.emit("disconnect", "transport close");
348
+ const detail = event.reason ? `: ${event.reason}` : "";
349
+ const reason = `transport close (code ${event.code})${detail}`;
350
+ this.failAcknowledgements(reason);
351
+ this.events.emit("disconnect", reason);
337
352
  }
338
353
  this.scheduleReconnect();
339
354
  }
340
355
  closeSocket(reason) {
341
356
  const wasConnected = this.connected;
342
357
  this.connected = false;
358
+ this.projectJoined = false;
343
359
  if (this.inactivityTimer) {
344
360
  clearTimeout(this.inactivityTimer);
345
361
  }
@@ -347,12 +363,20 @@ var OverleafRealtimeSocket = class {
347
363
  this.socket = void 0;
348
364
  socket?.close();
349
365
  if (wasConnected) {
366
+ this.failAcknowledgements(reason);
350
367
  this.events.emit("disconnect", reason);
351
368
  }
352
369
  if (!this.explicitlyDisconnected) {
353
370
  this.scheduleReconnect();
354
371
  }
355
372
  }
373
+ failAcknowledgements(reason) {
374
+ const error2 = new Error(`Overleaf realtime connection closed: ${reason}`);
375
+ for (const callback of this.acknowledgements.values()) {
376
+ callback(error2);
377
+ }
378
+ this.acknowledgements.clear();
379
+ }
356
380
  scheduleReconnect() {
357
381
  if (!this.reconnectEnabled || this.explicitlyDisconnected || this.reconnectTimer) {
358
382
  return;
@@ -1188,6 +1212,8 @@ var SocketIOAPI = class _SocketIOAPI {
1188
1212
  static alternativeSocketFactory;
1189
1213
  scheme;
1190
1214
  record;
1215
+ joinedProject;
1216
+ pendingProjectJoin;
1191
1217
  _handlers = [];
1192
1218
  connectedPublicId;
1193
1219
  socket;
@@ -1224,6 +1250,8 @@ var SocketIOAPI = class _SocketIOAPI {
1224
1250
  break;
1225
1251
  case "realtime":
1226
1252
  this.record = void 0;
1253
+ this.joinedProject = void 0;
1254
+ this.resetProjectJoin(new Error("The realtime connection was replaced."));
1227
1255
  const query = `?projectId=${this.projectId}&t=${Date.now()}`;
1228
1256
  this.socket = this.api._initSocketV0(this.identity, query);
1229
1257
  break;
@@ -1266,6 +1294,11 @@ var SocketIOAPI = class _SocketIOAPI {
1266
1294
  });
1267
1295
  this.socket.on("disconnect", (reason) => {
1268
1296
  log("SocketIOAPI: disconnect event", { scheme: this.scheme, projectId: this.projectId, reason });
1297
+ if (this.scheme === "realtime") {
1298
+ this.joinedProject = void 0;
1299
+ this.connectedPublicId = void 0;
1300
+ this.resetProjectJoin(new Error(`Overleaf realtime connection closed: ${String(reason ?? "unknown")}`));
1301
+ }
1269
1302
  });
1270
1303
  this.socket.on("reconnecting", (delay, attempt) => {
1271
1304
  log("SocketIOAPI: reconnecting", { scheme: this.scheme, projectId: this.projectId, delay, attempt });
@@ -1290,21 +1323,49 @@ var SocketIOAPI = class _SocketIOAPI {
1290
1323
  error(`Overleaf connection error: ${message}`);
1291
1324
  });
1292
1325
  if (this.scheme === "realtime") {
1293
- this.record = new Promise((resolve6, reject) => {
1294
- this.socket.on("joinProjectResponse", (res) => {
1295
- const publicId = res.publicId;
1296
- const project = res.project;
1297
- this.connectedPublicId = publicId;
1298
- for (const handler of this._handlers) {
1299
- handler.onConnectionAccepted?.(publicId);
1300
- }
1301
- resolve6(project);
1302
- });
1303
- this.socket.on("connectionRejected", (err) => {
1304
- reject(err?.message || err);
1305
- });
1326
+ this.pendingProjectJoin ??= this.createProjectJoin();
1327
+ this.socket.on("joinProjectResponse", (res) => {
1328
+ const publicId = res.publicId;
1329
+ const project = res.project;
1330
+ this.socket.markProjectJoined?.();
1331
+ log("SocketIOAPI: project joined", { scheme: this.scheme, projectId: this.projectId, publicId });
1332
+ this.connectedPublicId = publicId;
1333
+ this.joinedProject = project;
1334
+ this.pendingProjectJoin?.resolve(project);
1335
+ for (const handler of this._handlers) {
1336
+ handler.onProjectJoined?.(project);
1337
+ handler.onConnectionAccepted?.(publicId);
1338
+ }
1306
1339
  });
1340
+ this.socket.on("connectionRejected", (err) => {
1341
+ this.pendingProjectJoin?.reject(err?.message || err);
1342
+ });
1343
+ }
1344
+ }
1345
+ createProjectJoin() {
1346
+ let resolve6;
1347
+ let reject;
1348
+ const promise = new Promise((resolveProject, rejectProject) => {
1349
+ resolve6 = resolveProject;
1350
+ reject = rejectProject;
1351
+ });
1352
+ void promise.catch(() => void 0);
1353
+ return { promise, resolve: resolve6, reject };
1354
+ }
1355
+ resetProjectJoin(reason) {
1356
+ if (reason !== void 0) {
1357
+ this.pendingProjectJoin?.reject(reason);
1358
+ }
1359
+ this.pendingProjectJoin = this.createProjectJoin();
1360
+ }
1361
+ waitForRealtimeProject() {
1362
+ return this.joinedProject ? Promise.resolve(this.joinedProject) : this.pendingProjectJoin.promise;
1363
+ }
1364
+ async emitProjectEvent(event, ...args) {
1365
+ if (this.scheme === "realtime") {
1366
+ await this.waitForRealtimeProject();
1307
1367
  }
1368
+ return this.emit(event, ...args);
1308
1369
  }
1309
1370
  disconnect() {
1310
1371
  this.socket.disconnect();
@@ -1364,10 +1425,12 @@ var SocketIOAPI = class _SocketIOAPI {
1364
1425
  });
1365
1426
  break;
1366
1427
  case handlers.onDisconnected:
1367
- this.socket.on("disconnect", () => {
1368
- handler();
1428
+ this.socket.on("disconnect", (reason) => {
1429
+ handler(reason);
1369
1430
  });
1370
1431
  break;
1432
+ case handlers.onProjectJoined:
1433
+ break;
1371
1434
  case handlers.onConnectionAccepted:
1372
1435
  this.socket.on("connectionAccepted", (_, publicId) => {
1373
1436
  handler(publicId);
@@ -1437,7 +1500,7 @@ var SocketIOAPI = class _SocketIOAPI {
1437
1500
  timeoutPromise
1438
1501
  ]);
1439
1502
  case "realtime":
1440
- return Promise.race([this.record, timeoutPromise]);
1503
+ return Promise.race([this.waitForRealtimeProject(), timeoutPromise]);
1441
1504
  }
1442
1505
  }
1443
1506
  /**
@@ -1446,7 +1509,7 @@ var SocketIOAPI = class _SocketIOAPI {
1446
1509
  * @returns {Promise}
1447
1510
  */
1448
1511
  async joinDoc(docId) {
1449
- return this.emit("joinDoc", docId, { encodeRanges: true }).then((returns) => {
1512
+ return this.emitProjectEvent("joinDoc", docId, { encodeRanges: true }).then((returns) => {
1450
1513
  const [docLinesAscii, version, updates, ranges] = returns;
1451
1514
  const docLines = docLinesAscii.map((line) => decodePackedUtf8(line));
1452
1515
  return { docLines, version, updates, ranges };
@@ -1458,7 +1521,7 @@ var SocketIOAPI = class _SocketIOAPI {
1458
1521
  * @returns {Promise}
1459
1522
  */
1460
1523
  async leaveDoc(docId) {
1461
- return this.emit("leaveDoc", docId).then(() => {
1524
+ return this.emitProjectEvent("leaveDoc", docId).then(() => {
1462
1525
  return;
1463
1526
  });
1464
1527
  }
@@ -1470,7 +1533,7 @@ var SocketIOAPI = class _SocketIOAPI {
1470
1533
  */
1471
1534
  async applyOtUpdate(docId, update) {
1472
1535
  try {
1473
- await this.emit("applyOtUpdate", docId, update);
1536
+ await this.emitProjectEvent("applyOtUpdate", docId, update);
1474
1537
  } catch (error2) {
1475
1538
  let detail;
1476
1539
  if (error2 instanceof Error) {
@@ -1492,7 +1555,7 @@ var SocketIOAPI = class _SocketIOAPI {
1492
1555
  * @returns {Promise}
1493
1556
  */
1494
1557
  async getConnectedUsers() {
1495
- return this.emit("clientTracking.getConnectedUsers").then((returns) => {
1558
+ return this.emitProjectEvent("clientTracking.getConnectedUsers").then((returns) => {
1496
1559
  const [connectedUsers] = returns;
1497
1560
  return connectedUsers;
1498
1561
  });
@@ -1503,7 +1566,7 @@ var SocketIOAPI = class _SocketIOAPI {
1503
1566
  * @returns {Promise}
1504
1567
  */
1505
1568
  async updatePosition(doc_id, row, column) {
1506
- return this.emit("clientTracking.updatePosition", { row, column, doc_id }).then(() => {
1569
+ return this.emitProjectEvent("clientTracking.updatePosition", { row, column, doc_id }).then(() => {
1507
1570
  return;
1508
1571
  });
1509
1572
  }
@@ -1767,7 +1830,9 @@ var RemoteProject = class {
1767
1830
  project;
1768
1831
  entries = /* @__PURE__ */ new Map();
1769
1832
  idToPath = /* @__PURE__ */ new Map();
1833
+ collaboratorNames = /* @__PURE__ */ new Map();
1770
1834
  changeHandler;
1835
+ reconnectHandler;
1771
1836
  externalTransport;
1772
1837
  async connect() {
1773
1838
  if (this.externalTransport) {
@@ -1782,6 +1847,7 @@ var RemoteProject = class {
1782
1847
  this.socket = new SocketIOAPI(this.url, this.api, this.identity, this.projectId);
1783
1848
  }
1784
1849
  this.project = await this.socket.joinProject(this.projectId);
1850
+ this.indexCollaborators();
1785
1851
  this.reindex();
1786
1852
  this.registerEvents();
1787
1853
  }
@@ -1792,6 +1858,19 @@ var RemoteProject = class {
1792
1858
  }
1793
1859
  onChange(handler) {
1794
1860
  this.changeHandler = handler;
1861
+ return () => {
1862
+ if (this.changeHandler === handler) {
1863
+ this.changeHandler = void 0;
1864
+ }
1865
+ };
1866
+ }
1867
+ onReconnect(handler) {
1868
+ this.reconnectHandler = handler;
1869
+ return () => {
1870
+ if (this.reconnectHandler === handler) {
1871
+ this.reconnectHandler = void 0;
1872
+ }
1873
+ };
1795
1874
  }
1796
1875
  listEntries() {
1797
1876
  return [...this.entries.values()];
@@ -2006,11 +2085,40 @@ var RemoteProject = class {
2006
2085
  collection.splice(index, 1);
2007
2086
  }
2008
2087
  }
2009
- emitChange(path, previousPath) {
2010
- this.changeHandler?.({ path: normalizePath(path), previousPath });
2088
+ emitChange(path, previousPath, actor) {
2089
+ this.changeHandler?.({ path: normalizePath(path), previousPath, actor });
2090
+ }
2091
+ indexCollaborators() {
2092
+ for (const member of [this.project.owner, ...this.project.members]) {
2093
+ if (!member?._id) {
2094
+ continue;
2095
+ }
2096
+ const name = [member.first_name, member.last_name].filter(Boolean).join(" ").trim();
2097
+ if (name) {
2098
+ this.collaboratorNames.set(member._id, name);
2099
+ }
2100
+ }
2101
+ }
2102
+ actor(userId) {
2103
+ if (!userId) {
2104
+ return void 0;
2105
+ }
2106
+ const name = this.collaboratorNames.get(userId);
2107
+ return name ? `${name} (Overleaf)` : `Overleaf user ${userId}`;
2011
2108
  }
2012
2109
  registerEvents() {
2013
2110
  this.socket.updateEventHandlers({
2111
+ onProjectJoined: (project) => {
2112
+ this.project = project;
2113
+ this.indexCollaborators();
2114
+ this.reindex();
2115
+ this.reconnectHandler?.();
2116
+ },
2117
+ onClientUpdated: (user) => {
2118
+ if (user.user_id && user.name) {
2119
+ this.collaboratorNames.set(user.user_id, user.name);
2120
+ }
2121
+ },
2014
2122
  onFileCreated: (parentId, type, entity) => {
2015
2123
  const parent = this.folderById(parentId);
2016
2124
  if (!parent || type === "outputs") {
@@ -2075,12 +2183,121 @@ var RemoteProject = class {
2075
2183
  onFileChanged: (update) => {
2076
2184
  const path = this.idToPath.get(update.doc);
2077
2185
  if (path) {
2078
- this.emitChange(path);
2186
+ this.emitChange(path, void 0, this.actor(update.meta?.user_id));
2079
2187
  }
2080
2188
  }
2081
2189
  });
2082
2190
  }
2083
2191
  };
2192
+ var decoder = new TextDecoder("utf-8", { fatal: true });
2193
+ function decode(content) {
2194
+ if (content === void 0) {
2195
+ return "";
2196
+ }
2197
+ if (content.includes(0)) {
2198
+ return void 0;
2199
+ }
2200
+ try {
2201
+ return decoder.decode(content);
2202
+ } catch {
2203
+ return void 0;
2204
+ }
2205
+ }
2206
+ function lineStarts(content) {
2207
+ const starts = [0];
2208
+ for (let index = 0; index < content.length; index++) {
2209
+ if (content[index] === "\n" && index + 1 < content.length) {
2210
+ starts.push(index + 1);
2211
+ }
2212
+ }
2213
+ return starts;
2214
+ }
2215
+ function lineAt(starts, offset) {
2216
+ let low = 0;
2217
+ let high = starts.length;
2218
+ while (low < high) {
2219
+ const middle = Math.floor((low + high) / 2);
2220
+ if (starts[middle] <= offset) {
2221
+ low = middle + 1;
2222
+ } else {
2223
+ high = middle;
2224
+ }
2225
+ }
2226
+ return Math.max(1, low);
2227
+ }
2228
+ function toLineRange(content, start, end) {
2229
+ const starts = lineStarts(content);
2230
+ const safeStart = Math.min(start, Math.max(0, content.length - 1));
2231
+ const safeEnd = Math.min(Math.max(start, end - 1), Math.max(0, content.length - 1));
2232
+ return { start: lineAt(starts, safeStart), end: lineAt(starts, safeEnd) };
2233
+ }
2234
+ function mergeRanges(ranges) {
2235
+ const merged = [];
2236
+ for (const range of ranges.sort((a, b) => a.start - b.start)) {
2237
+ const previous = merged.at(-1);
2238
+ if (previous && range.start <= previous.end + 1) {
2239
+ previous.end = Math.max(previous.end, range.end);
2240
+ } else {
2241
+ merged.push({ ...range });
2242
+ }
2243
+ }
2244
+ return merged;
2245
+ }
2246
+ function formatRanges(ranges) {
2247
+ const shown = ranges.slice(0, 3).map((range) => range.start === range.end ? String(range.start) : `${range.start}-${range.end}`);
2248
+ return `${shown.join(", ")}${ranges.length > shown.length ? ` (+${ranges.length - shown.length} more)` : ""}`;
2249
+ }
2250
+ function describeContentChange(before, after) {
2251
+ if (before && after && Buffer.from(before).equals(Buffer.from(after))) {
2252
+ return void 0;
2253
+ }
2254
+ const oldText = decode(before);
2255
+ const newText = decode(after);
2256
+ if (oldText === void 0 || newText === void 0) {
2257
+ return `binary bytes ${before?.byteLength ?? 0} -> ${after?.byteLength ?? 0}`;
2258
+ }
2259
+ if (oldText.length === 0 && newText.length === 0) {
2260
+ return void 0;
2261
+ }
2262
+ if (oldText.length === 0) {
2263
+ const count = lineStarts(newText).length;
2264
+ return `added lines ${count === 1 ? "1" : `1-${count}`}`;
2265
+ }
2266
+ if (newText.length === 0) {
2267
+ const count = lineStarts(oldText).length;
2268
+ return `removed lines ${count === 1 ? "1" : `1-${count}`}`;
2269
+ }
2270
+ const changes = [];
2271
+ let oldOffset = 0;
2272
+ let newOffset = 0;
2273
+ let current;
2274
+ const finish = () => {
2275
+ if (current) {
2276
+ changes.push(current);
2277
+ current = void 0;
2278
+ }
2279
+ };
2280
+ for (const [operation, value] of new DiffMatchPatch2().diff_main(oldText, newText)) {
2281
+ if (operation === 0) {
2282
+ finish();
2283
+ oldOffset += value.length;
2284
+ newOffset += value.length;
2285
+ continue;
2286
+ }
2287
+ current ??= { oldStart: oldOffset, oldEnd: oldOffset, newStart: newOffset, newEnd: newOffset };
2288
+ if (operation === -1) {
2289
+ oldOffset += value.length;
2290
+ }
2291
+ if (operation === 1) {
2292
+ newOffset += value.length;
2293
+ }
2294
+ current.oldEnd = oldOffset;
2295
+ current.newEnd = newOffset;
2296
+ }
2297
+ finish();
2298
+ const ranges = mergeRanges(changes.map((change) => change.newEnd > change.newStart ? toLineRange(newText, change.newStart, change.newEnd) : toLineRange(oldText, change.oldStart, change.oldEnd)));
2299
+ return ranges.length === 0 ? void 0 : `lines ${formatRanges(ranges)}`;
2300
+ }
2084
2301
  var textDecoder = new TextDecoder("utf-8", { fatal: true });
2085
2302
  var textEncoder = new TextEncoder();
2086
2303
  function decodeText(content) {
@@ -2120,32 +2337,19 @@ function mergeText(baseContent, localContent, remoteContent) {
2120
2337
  content: textEncoder.encode(regions.flatMap((region) => region.ok ?? []).join(""))
2121
2338
  };
2122
2339
  }
2123
- var SYNC_STATE_SCHEMA_VERSION = 2;
2340
+ var SYNC_STATE_SCHEMA_VERSION = 1;
2124
2341
  function sha256(content) {
2125
2342
  return createHash2("sha256").update(content).digest("hex");
2126
2343
  }
2127
- function parseSyncState(serialized, projectUri) {
2128
- try {
2129
- const state = JSON.parse(serialized);
2130
- if (state.schemaVersion !== 1 && state.schemaVersion !== SYNC_STATE_SCHEMA_VERSION || state.projectUri !== projectUri || !Number.isInteger(state.remoteVersion) || typeof state.files !== "object" || state.files === null) {
2131
- return void 0;
2132
- }
2133
- return {
2134
- ...state,
2135
- schemaVersion: SYNC_STATE_SCHEMA_VERSION,
2136
- textBases: typeof state.textBases === "object" && state.textBases !== null ? state.textBases : {}
2137
- };
2138
- } catch {
2139
- return void 0;
2140
- }
2141
- }
2142
- function createSyncState(projectUri, remoteVersion) {
2344
+ function createSyncState(projectUri, remoteVersion, initialized = true) {
2143
2345
  return {
2144
2346
  schemaVersion: SYNC_STATE_SCHEMA_VERSION,
2145
2347
  projectUri,
2146
2348
  remoteVersion,
2349
+ initialized,
2147
2350
  files: {},
2148
- textBases: {}
2351
+ textBases: {},
2352
+ conflicts: {}
2149
2353
  };
2150
2354
  }
2151
2355
  function updateSyncCheckpoint(state, path, content) {
@@ -2158,10 +2362,12 @@ function updateSyncCheckpoint(state, path, content) {
2158
2362
  }
2159
2363
  }
2160
2364
  function removeSyncCheckpoint(state, path) {
2161
- for (const checkpointPath of Object.keys(state.files)) {
2365
+ const checkpointPaths = /* @__PURE__ */ new Set([...Object.keys(state.files), ...Object.keys(state.conflicts)]);
2366
+ for (const checkpointPath of checkpointPaths) {
2162
2367
  if (checkpointPath === path || checkpointPath.startsWith(`${path}/`)) {
2163
2368
  delete state.files[checkpointPath];
2164
2369
  delete state.textBases[checkpointPath];
2370
+ delete state.conflicts[checkpointPath];
2165
2371
  }
2166
2372
  }
2167
2373
  }
@@ -2184,41 +2390,54 @@ var SyncEngine = class {
2184
2390
  queue = Promise.resolve();
2185
2391
  conflicts = /* @__PURE__ */ new Set();
2186
2392
  stopLocalWatch;
2393
+ stopRemoteChange;
2394
+ stopRemoteReconnect;
2187
2395
  eventTimers = /* @__PURE__ */ new Map();
2396
+ stopped = false;
2188
2397
  async start() {
2398
+ this.stopped = false;
2189
2399
  this.state = await this.options.stateStore.load();
2190
2400
  const currentVersion = await this.options.remote.getCurrentVersion();
2191
2401
  const localFiles = await this.options.local.listFiles();
2192
2402
  const remoteFiles = this.remoteFilePaths();
2193
- if (!this.state) {
2194
- this.state = createSyncState(this.options.projectUri, currentVersion);
2195
- if (localFiles.length !== 0 && remoteFiles.length !== 0) {
2196
- throw new Error("Synchronization paused: both the local and Overleaf projects contain files, but no common checkpoint exists.");
2197
- }
2198
- const source = localFiles.length === 0 ? "remote" : "local";
2199
- for (const path of source === "remote" ? remoteFiles : localFiles) {
2200
- await this.reconcilePath(path, source);
2201
- }
2202
- this.state.remoteVersion = currentVersion;
2203
- await this.options.stateStore.save(this.state);
2403
+ if (!this.state || !this.state.initialized) {
2404
+ await this.initializeState(currentVersion, localFiles, remoteFiles);
2204
2405
  } else {
2406
+ this.restoreConflicts();
2205
2407
  await this.startupReconcile(currentVersion, localFiles, remoteFiles);
2206
2408
  }
2207
2409
  this.stopLocalWatch = await this.options.local.watch((path) => this.schedule(path, "local"));
2208
- this.options.remote.onChange((change) => {
2410
+ this.stopRemoteChange = this.options.remote.onChange((change) => {
2411
+ if (this.stopped) {
2412
+ return;
2413
+ }
2209
2414
  if (change.previousPath) {
2210
- this.schedule(change.previousPath, "remote");
2415
+ this.schedule(change.previousPath, "remote", change.actor);
2416
+ }
2417
+ this.schedule(change.path, "remote", change.actor);
2418
+ });
2419
+ this.stopRemoteReconnect = this.options.remote.onReconnect?.(() => {
2420
+ if (this.stopped) {
2421
+ return;
2211
2422
  }
2212
- this.schedule(change.path, "remote");
2423
+ this.queue = this.queue.then(() => this.reconcileAfterReconnect()).catch((error2) => {
2424
+ this.options.log(`Failed to reconcile after reconnecting: ${error2 instanceof Error ? error2.message : String(error2)}`);
2425
+ });
2213
2426
  });
2214
2427
  this.options.log(`Synchronization active at Overleaf version ${this.state.remoteVersion}.`);
2215
2428
  }
2216
2429
  async stop() {
2217
- for (const timer of this.eventTimers.values()) {
2218
- clearTimeout(timer);
2430
+ this.stopped = true;
2431
+ this.stopRemoteChange?.();
2432
+ this.stopRemoteChange = void 0;
2433
+ this.stopRemoteReconnect?.();
2434
+ this.stopRemoteReconnect = void 0;
2435
+ for (const pending of this.eventTimers.values()) {
2436
+ clearTimeout(pending.timer);
2219
2437
  }
2220
2438
  this.eventTimers.clear();
2221
2439
  await this.stopLocalWatch?.();
2440
+ this.stopLocalWatch = void 0;
2222
2441
  await this.queue;
2223
2442
  }
2224
2443
  getConflicts() {
@@ -2254,15 +2473,95 @@ var SyncEngine = class {
2254
2473
  await this.options.local.remove(normalized);
2255
2474
  removeSyncCheckpoint(this.state, normalized);
2256
2475
  }
2257
- await this.options.stateStore.save(this.state);
2258
2476
  this.conflicts.delete(normalized);
2477
+ delete this.state.conflicts[normalized];
2478
+ await this.options.stateStore.save(this.state);
2259
2479
  this.options.log(`Resolved ${normalized} using ${winner === "local" ? "local" : "Overleaf"} content.`);
2260
2480
  });
2261
2481
  }
2262
2482
  async retry(path) {
2263
2483
  const normalized = normalizePath2(path);
2484
+ const reason = this.state.conflicts[normalized];
2264
2485
  this.conflicts.delete(normalized);
2265
- await this.enqueue(() => this.reconcilePath(normalized, "local"));
2486
+ delete this.state.conflicts[normalized];
2487
+ try {
2488
+ await this.enqueue(() => this.reconcilePath(normalized, "local"));
2489
+ } catch (error2) {
2490
+ if (reason !== void 0) {
2491
+ this.conflicts.add(normalized);
2492
+ this.state.conflicts[normalized] = reason;
2493
+ await this.options.stateStore.save(this.state);
2494
+ }
2495
+ throw error2;
2496
+ }
2497
+ }
2498
+ async initializeState(currentVersion, localFiles, remoteFiles) {
2499
+ if (!this.state) {
2500
+ this.state = createSyncState(this.options.projectUri, currentVersion, false);
2501
+ await this.options.stateStore.save(this.state);
2502
+ }
2503
+ this.restoreConflicts();
2504
+ const paths = /* @__PURE__ */ new Set([...localFiles, ...remoteFiles, ...Object.keys(this.state.files)]);
2505
+ for (const path of [...paths].sort()) {
2506
+ const normalized = normalizePath2(path);
2507
+ if (this.conflicts.has(normalized)) {
2508
+ continue;
2509
+ }
2510
+ if (this.state.files[normalized] !== void 0) {
2511
+ await this.reconcilePath(normalized, "local");
2512
+ continue;
2513
+ }
2514
+ await this.initializePath(normalized);
2515
+ }
2516
+ this.state.remoteVersion = currentVersion;
2517
+ this.state.initialized = true;
2518
+ await this.options.stateStore.save(this.state);
2519
+ if (this.conflicts.size !== 0) {
2520
+ this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
2521
+ }
2522
+ }
2523
+ async initializePath(path) {
2524
+ const remoteEntry = this.options.remote.entry(path);
2525
+ if (remoteEntry?.type === "folder") {
2526
+ return;
2527
+ }
2528
+ const localContent = await this.options.local.read(path);
2529
+ const remoteContent = isRemoteFile(remoteEntry) ? await this.options.remote.read(path) : void 0;
2530
+ const localHash = localContent && sha256(localContent);
2531
+ const remoteHash = remoteContent && sha256(remoteContent);
2532
+ if (localHash === remoteHash) {
2533
+ if (localContent) {
2534
+ updateSyncCheckpoint(this.state, path, localContent);
2535
+ await this.options.stateStore.save(this.state);
2536
+ }
2537
+ return;
2538
+ }
2539
+ if (!localContent && remoteContent) {
2540
+ await this.options.local.write(path, remoteContent);
2541
+ updateSyncCheckpoint(this.state, path, remoteContent);
2542
+ await this.options.stateStore.save(this.state);
2543
+ this.logChange("pull", "add", path, void 0, remoteContent);
2544
+ return;
2545
+ }
2546
+ await this.recordConflict(
2547
+ path,
2548
+ remoteContent ? "Local and Overleaf content differ, but no common checkpoint exists." : "The local path has no matching Overleaf file and no common checkpoint exists."
2549
+ );
2550
+ }
2551
+ restoreConflicts() {
2552
+ for (const [path, reason] of Object.entries(this.state.conflicts)) {
2553
+ if (this.conflicts.has(path)) {
2554
+ continue;
2555
+ }
2556
+ this.conflicts.add(path);
2557
+ this.options.onConflict(path, reason);
2558
+ }
2559
+ }
2560
+ async recordConflict(path, reason) {
2561
+ this.conflicts.add(path);
2562
+ this.state.conflicts[path] = reason;
2563
+ await this.options.stateStore.save(this.state);
2564
+ this.options.onConflict(path, reason);
2266
2565
  }
2267
2566
  async startupReconcile(currentVersion, localFiles, remoteFiles) {
2268
2567
  const state = this.state;
@@ -2291,6 +2590,11 @@ var SyncEngine = class {
2291
2590
  }
2292
2591
  const candidates = new Set(localChanged);
2293
2592
  const knownFiles = /* @__PURE__ */ new Set([...Object.keys(state.files), ...localFiles, ...remoteFiles]);
2593
+ for (const path of [...localFiles, ...remoteFiles]) {
2594
+ if (state.files[path] === void 0) {
2595
+ candidates.add(path);
2596
+ }
2597
+ }
2294
2598
  for (const change of remoteDiff.diff) {
2295
2599
  const paths = [change.pathname, change.newPathname].filter((path) => Boolean(path));
2296
2600
  for (const changedPath of paths.map(normalizePath2)) {
@@ -2324,30 +2628,61 @@ var SyncEngine = class {
2324
2628
  this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
2325
2629
  }
2326
2630
  }
2327
- schedule(path, source) {
2631
+ async reconcileAfterReconnect() {
2632
+ if (this.stopped) {
2633
+ return;
2634
+ }
2635
+ const currentVersion = await this.options.remote.getCurrentVersion();
2636
+ await this.startupReconcile(
2637
+ currentVersion,
2638
+ await this.options.local.listFiles(),
2639
+ this.remoteFilePaths()
2640
+ );
2641
+ this.options.log(`Realtime connection restored at Overleaf version ${currentVersion}.`);
2642
+ }
2643
+ schedule(path, source, actor) {
2328
2644
  const normalized = normalizePath2(path);
2329
- if (this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2645
+ if (this.stopped || this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2330
2646
  return;
2331
2647
  }
2332
2648
  const previous = this.eventTimers.get(normalized);
2333
2649
  if (previous) {
2334
- clearTimeout(previous);
2650
+ clearTimeout(previous.timer);
2335
2651
  }
2336
- this.eventTimers.set(normalized, setTimeout(() => {
2337
- this.eventTimers.delete(normalized);
2338
- this.queue = this.queue.then(() => this.reconcilePath(normalized, source)).catch((error2) => {
2339
- this.options.log(`Failed to synchronize ${normalized}: ${error2 instanceof Error ? error2.message : String(error2)}`);
2340
- });
2341
- }, 250));
2652
+ const actors = previous?.actors ?? /* @__PURE__ */ new Set();
2653
+ let unknownRemoteActor = previous?.unknownRemoteActor ?? false;
2654
+ if (source === "remote") {
2655
+ if (actor) {
2656
+ actors.add(actor);
2657
+ } else {
2658
+ unknownRemoteActor = true;
2659
+ }
2660
+ }
2661
+ const pending = {
2662
+ source: previous?.source === "local" || source === "local" ? "local" : "remote",
2663
+ actors,
2664
+ unknownRemoteActor,
2665
+ timer: setTimeout(() => {
2666
+ this.eventTimers.delete(normalized);
2667
+ if (this.stopped) {
2668
+ return;
2669
+ }
2670
+ const remoteActor = pending.unknownRemoteActor ? void 0 : pending.actors.size === 1 ? [...pending.actors][0] : pending.actors.size > 1 ? "multiple Overleaf collaborators" : void 0;
2671
+ this.queue = this.queue.then(() => this.reconcilePath(normalized, pending.source, remoteActor)).catch((error2) => {
2672
+ this.options.log(`Failed to synchronize ${normalized}: ${error2 instanceof Error ? error2.message : String(error2)}`);
2673
+ });
2674
+ }, 250)
2675
+ };
2676
+ this.eventTimers.set(normalized, pending);
2342
2677
  }
2343
2678
  enqueue(operation) {
2344
2679
  const result = this.queue.then(operation);
2345
2680
  this.queue = result.then(() => void 0, () => void 0);
2346
2681
  return result;
2347
2682
  }
2348
- async reconcilePath(path, source) {
2683
+ async reconcilePath(path, source, actor) {
2349
2684
  const normalized = normalizePath2(path);
2350
- if (this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2685
+ if (this.stopped || this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
2351
2686
  return;
2352
2687
  }
2353
2688
  const remoteEntry = this.options.remote.entry(normalized);
@@ -2357,38 +2692,43 @@ var SyncEngine = class {
2357
2692
  const localContent = await this.options.local.read(normalized);
2358
2693
  const remoteContent = isRemoteFile(remoteEntry) ? await this.options.remote.read(normalized) : void 0;
2359
2694
  const baseHash = this.state.files[normalized];
2695
+ const baseContent = readTextBase(this.state, normalized);
2360
2696
  const localHash = localContent && sha256(localContent);
2361
2697
  const remoteHash = remoteContent && sha256(remoteContent);
2362
2698
  if (localHash === remoteHash) {
2363
- if (localContent) {
2364
- updateSyncCheckpoint(this.state, normalized, localContent);
2365
- } else {
2366
- removeSyncCheckpoint(this.state, normalized);
2699
+ if (localHash !== baseHash) {
2700
+ if (localContent) {
2701
+ updateSyncCheckpoint(this.state, normalized, localContent);
2702
+ } else {
2703
+ removeSyncCheckpoint(this.state, normalized);
2704
+ }
2705
+ await this.options.stateStore.save(this.state);
2367
2706
  }
2368
- await this.options.stateStore.save(this.state);
2369
2707
  return;
2370
2708
  }
2371
2709
  const localChanged = localHash !== baseHash;
2372
2710
  const remoteChanged = remoteHash !== baseHash;
2373
2711
  if (localChanged && remoteChanged) {
2374
- const base = readTextBase(this.state, normalized);
2375
- if (base && localContent && remoteContent) {
2376
- const merged = mergeText(base, localContent, remoteContent);
2712
+ if (baseContent && localContent && remoteContent) {
2713
+ const merged = mergeText(baseContent, localContent, remoteContent);
2377
2714
  if (merged.status === "merged" && merged.content) {
2378
2715
  await this.options.local.write(normalized, merged.content);
2379
2716
  await this.options.remote.write(normalized, merged.content);
2380
2717
  updateSyncCheckpoint(this.state, normalized, merged.content);
2381
2718
  await this.options.stateStore.save(this.state);
2382
- this.logChange("merge", "update", normalized);
2719
+ this.logChange("merge", "update", normalized, baseContent, merged.content, actor);
2383
2720
  return;
2384
2721
  }
2385
2722
  }
2386
- this.conflicts.add(normalized);
2387
2723
  const reason = "Both local and Overleaf content changed since the last synchronized checkpoint.";
2388
- this.options.onConflict(normalized, reason);
2724
+ await this.recordConflict(normalized, reason);
2725
+ return;
2726
+ }
2727
+ if (source === "remote" && localChanged && !remoteChanged) {
2389
2728
  return;
2390
2729
  }
2391
2730
  if (localChanged || !remoteChanged && source === "local") {
2731
+ let pushedContent = localContent;
2392
2732
  if (!localContent) {
2393
2733
  if (remoteEntry) {
2394
2734
  await this.options.remote.remove(normalized);
@@ -2400,13 +2740,21 @@ var SyncEngine = class {
2400
2740
  if (sha256(synchronized) !== sha256(localContent)) {
2401
2741
  await this.options.local.write(normalized, synchronized);
2402
2742
  }
2743
+ pushedContent = synchronized;
2403
2744
  updateSyncCheckpoint(this.state, normalized, synchronized);
2404
2745
  }
2405
2746
  await this.options.stateStore.save(this.state);
2406
2747
  if (!localContent && remoteEntry) {
2407
- this.logChange("push", "delete", normalized);
2748
+ this.logChange("push", "delete", normalized, baseContent ?? remoteContent, void 0, actor);
2408
2749
  } else if (localContent) {
2409
- this.logChange("push", remoteEntry ? "update" : "add", normalized);
2750
+ this.logChange(
2751
+ "push",
2752
+ remoteEntry ? "update" : "add",
2753
+ normalized,
2754
+ baseContent ?? remoteContent,
2755
+ pushedContent,
2756
+ actor
2757
+ );
2410
2758
  }
2411
2759
  return;
2412
2760
  }
@@ -2421,13 +2769,26 @@ var SyncEngine = class {
2421
2769
  }
2422
2770
  await this.options.stateStore.save(this.state);
2423
2771
  if (!remoteContent && localContent) {
2424
- this.logChange("pull", "delete", normalized);
2772
+ this.logChange("pull", "delete", normalized, baseContent ?? localContent, void 0, actor);
2425
2773
  } else if (remoteContent) {
2426
- this.logChange("pull", localContent ? "update" : "add", normalized);
2427
- }
2428
- }
2429
- logChange(direction, operation, path) {
2430
- this.options.log(`[${direction}] ${operation} ${JSON.stringify(path)}`);
2774
+ this.logChange(
2775
+ "pull",
2776
+ localContent ? "update" : "add",
2777
+ normalized,
2778
+ baseContent ?? localContent,
2779
+ remoteContent,
2780
+ actor
2781
+ );
2782
+ }
2783
+ }
2784
+ logChange(direction, operation, path, before, after, actor) {
2785
+ const summary = describeContentChange(before, after);
2786
+ const source = direction === "push" ? "local filesystem" : direction === "pull" ? actor ?? "Overleaf" : `local filesystem + ${actor ?? "Overleaf"}`;
2787
+ this.options.log([
2788
+ `[${direction}] ${operation} ${JSON.stringify(path)}`,
2789
+ summary,
2790
+ `source=${source}`
2791
+ ].filter(Boolean).join("; "));
2431
2792
  }
2432
2793
  remoteFilePaths() {
2433
2794
  return this.options.remote.listEntries().filter((entry) => isRemoteFile(entry) && !this.shouldIgnore(entry.path)).map((entry) => entry.path);
@@ -2436,39 +2797,325 @@ var SyncEngine = class {
2436
2797
  return this.options.ignore(path) || path.split("/").some((part) => part.startsWith("."));
2437
2798
  }
2438
2799
  };
2800
+ var HASH_PATTERN = /^[a-f0-9]{64}$/;
2801
+ var encoder = new TextEncoder();
2802
+ function json(value) {
2803
+ return `${JSON.stringify(value, null, 2)}
2804
+ `;
2805
+ }
2806
+ function pathKey(path) {
2807
+ return sha256(encoder.encode(path));
2808
+ }
2809
+ async function readJson(path) {
2810
+ return JSON.parse(await readFile4(path, "utf8"));
2811
+ }
2812
+ async function listFiles(directory) {
2813
+ const result = [];
2814
+ const visit = async (current) => {
2815
+ let entries;
2816
+ try {
2817
+ entries = await readdir2(current, { withFileTypes: true });
2818
+ } catch (error2) {
2819
+ if (error2.code === "ENOENT") {
2820
+ return;
2821
+ }
2822
+ throw error2;
2823
+ }
2824
+ for (const entry of entries) {
2825
+ const path = join2(current, entry.name);
2826
+ if (entry.isDirectory()) {
2827
+ await visit(path);
2828
+ } else if (entry.isFile()) {
2829
+ result.push(path);
2830
+ }
2831
+ }
2832
+ };
2833
+ await visit(directory);
2834
+ return result;
2835
+ }
2836
+ async function atomicWrite(path, content) {
2837
+ await mkdir3(dirname3(path), { recursive: true });
2838
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
2839
+ try {
2840
+ await writeFile3(temporary, content);
2841
+ await rename(temporary, path);
2842
+ } finally {
2843
+ await rm2(temporary, { force: true });
2844
+ }
2845
+ }
2846
+ function parseFormat(value, projectUri) {
2847
+ if (typeof value !== "object" || value === null) {
2848
+ return false;
2849
+ }
2850
+ const format = value;
2851
+ return format.schemaVersion === SYNC_STATE_SCHEMA_VERSION && format.projectUri === projectUri;
2852
+ }
2853
+ function parseCursor(value) {
2854
+ if (typeof value !== "object" || value === null) {
2855
+ return false;
2856
+ }
2857
+ const cursor = value;
2858
+ return Number.isInteger(cursor.remoteVersion) && cursor.remoteVersion >= 0 && typeof cursor.initialized === "boolean";
2859
+ }
2860
+ function parsePathRecord(value) {
2861
+ if (typeof value !== "object" || value === null) {
2862
+ return false;
2863
+ }
2864
+ const record = value;
2865
+ if (typeof record.path !== "string" || !record.path.startsWith("/")) {
2866
+ return false;
2867
+ }
2868
+ if (record.hash !== void 0 && (typeof record.hash !== "string" || !HASH_PATTERN.test(record.hash))) {
2869
+ return false;
2870
+ }
2871
+ if (record.baseHash !== void 0 && (typeof record.baseHash !== "string" || !HASH_PATTERN.test(record.baseHash) || record.hash !== record.baseHash)) {
2872
+ return false;
2873
+ }
2874
+ if (record.conflict !== void 0 && typeof record.conflict !== "string") {
2875
+ return false;
2876
+ }
2877
+ return record.hash !== void 0 || record.conflict !== void 0;
2878
+ }
2879
+ function createRecord(state, path) {
2880
+ const hash = state.files[path];
2881
+ const textBase = state.textBases[path];
2882
+ const conflict = state.conflicts[path];
2883
+ if (hash === void 0 && conflict === void 0) {
2884
+ return void 0;
2885
+ }
2886
+ return {
2887
+ path,
2888
+ ...hash === void 0 ? {} : { hash },
2889
+ ...textBase === void 0 ? {} : { baseHash: hash },
2890
+ ...conflict === void 0 ? {} : { conflict }
2891
+ };
2892
+ }
2439
2893
  var FileSyncStateStore = class {
2440
2894
  constructor(directory, projectUri) {
2441
2895
  this.projectUri = projectUri;
2442
- this.path = resolve4(directory, ".overleaf", "sync-state.json");
2896
+ const overleafDirectory = resolve4(directory, ".overleaf");
2897
+ this.legacyPath = join2(overleafDirectory, "sync-state.json");
2898
+ this.syncDirectory = join2(overleafDirectory, "sync");
2899
+ this.formatPath = join2(this.syncDirectory, "format.json");
2900
+ this.cursorPath = join2(this.syncDirectory, "cursor.json");
2901
+ this.pathsDirectory = join2(this.syncDirectory, "paths");
2902
+ this.basesDirectory = join2(this.syncDirectory, "bases");
2443
2903
  }
2444
2904
  projectUri;
2445
- path;
2446
- serialized;
2905
+ legacyPath;
2906
+ syncDirectory;
2907
+ formatPath;
2908
+ cursorPath;
2909
+ pathsDirectory;
2910
+ basesDirectory;
2911
+ persistedRecords = /* @__PURE__ */ new Map();
2912
+ persistedCursor;
2913
+ invalidRecordPaths = /* @__PURE__ */ new Set();
2914
+ initialized = false;
2447
2915
  async load() {
2916
+ this.invalidRecordPaths.clear();
2448
2917
  try {
2449
- const content = await readFile4(this.path, "utf8");
2450
- const state = parseSyncState(content, this.projectUri);
2451
- if (state) {
2452
- this.serialized = `${JSON.stringify(state, null, 2)}
2453
- `;
2918
+ const format = await readJson(this.formatPath);
2919
+ const cursor = await readJson(this.cursorPath);
2920
+ if (!parseFormat(format, this.projectUri) || !parseCursor(cursor)) {
2921
+ return void 0;
2922
+ }
2923
+ const state = {
2924
+ schemaVersion: SYNC_STATE_SCHEMA_VERSION,
2925
+ projectUri: this.projectUri,
2926
+ remoteVersion: cursor.remoteVersion,
2927
+ initialized: cursor.initialized,
2928
+ files: {},
2929
+ textBases: {},
2930
+ conflicts: {}
2931
+ };
2932
+ const records = /* @__PURE__ */ new Map();
2933
+ const referencedBases = /* @__PURE__ */ new Set();
2934
+ let stateIncomplete = false;
2935
+ for (const recordPath of await listFiles(this.pathsDirectory)) {
2936
+ const record = await this.loadRecord(recordPath);
2937
+ if (!record) {
2938
+ if (recordPath.endsWith(".json")) {
2939
+ this.invalidRecordPaths.add(recordPath);
2940
+ stateIncomplete = true;
2941
+ } else {
2942
+ await rm2(recordPath, { force: true });
2943
+ }
2944
+ continue;
2945
+ }
2946
+ if (record.hash !== void 0) {
2947
+ state.files[record.path] = record.hash;
2948
+ }
2949
+ if (record.baseHash !== void 0) {
2950
+ let textBase;
2951
+ try {
2952
+ textBase = await readFile4(this.basePath(record.baseHash), "utf8");
2953
+ } catch (error2) {
2954
+ if (error2.code === "ENOENT") {
2955
+ this.invalidRecordPaths.add(recordPath);
2956
+ stateIncomplete = true;
2957
+ delete state.files[record.path];
2958
+ continue;
2959
+ }
2960
+ throw error2;
2961
+ }
2962
+ if (sha256(encoder.encode(textBase)) !== record.baseHash) {
2963
+ this.invalidRecordPaths.add(recordPath);
2964
+ stateIncomplete = true;
2965
+ delete state.files[record.path];
2966
+ continue;
2967
+ }
2968
+ state.textBases[record.path] = textBase;
2969
+ referencedBases.add(record.baseHash);
2970
+ }
2971
+ if (record.conflict !== void 0) {
2972
+ state.conflicts[record.path] = record.conflict;
2973
+ }
2974
+ records.set(record.path, json(record));
2975
+ }
2976
+ if (stateIncomplete) {
2977
+ state.initialized = false;
2454
2978
  }
2979
+ await this.removeOrphanBases(referencedBases);
2980
+ this.persistedRecords = records;
2981
+ this.persistedCursor = json(cursor);
2982
+ this.initialized = true;
2455
2983
  return state;
2456
2984
  } catch (error2) {
2457
- if (error2.code === "ENOENT") {
2985
+ if (error2.code === "ENOENT" || error2 instanceof SyntaxError) {
2458
2986
  return void 0;
2459
2987
  }
2460
2988
  throw error2;
2461
2989
  }
2462
2990
  }
2463
2991
  async save(state) {
2464
- const serialized = `${JSON.stringify(state, null, 2)}
2465
- `;
2466
- if (serialized === this.serialized) {
2992
+ if (state.projectUri !== this.projectUri || state.schemaVersion !== SYNC_STATE_SCHEMA_VERSION) {
2993
+ throw new Error("Cannot persist synchronization state for a different project or schema.");
2994
+ }
2995
+ if (!this.initialized) {
2996
+ await this.initializeStorage();
2997
+ }
2998
+ const paths = /* @__PURE__ */ new Set([
2999
+ ...this.persistedRecords.keys(),
3000
+ ...Object.keys(state.files),
3001
+ ...Object.keys(state.conflicts)
3002
+ ]);
3003
+ const obsoleteBases = /* @__PURE__ */ new Set();
3004
+ for (const path of [...paths].sort()) {
3005
+ const record = createRecord(state, path);
3006
+ const serialized = record && json(record);
3007
+ const previous = this.persistedRecords.get(path);
3008
+ if (serialized === previous) {
3009
+ continue;
3010
+ }
3011
+ if (previous) {
3012
+ const previousRecord = JSON.parse(previous);
3013
+ if (previousRecord.baseHash) {
3014
+ obsoleteBases.add(previousRecord.baseHash);
3015
+ }
3016
+ }
3017
+ if (!record || !serialized) {
3018
+ await rm2(this.recordPath(path), { force: true });
3019
+ this.persistedRecords.delete(path);
3020
+ continue;
3021
+ }
3022
+ if (record.baseHash) {
3023
+ const textBase = state.textBases[path];
3024
+ if (textBase === void 0 || sha256(encoder.encode(textBase)) !== record.baseHash) {
3025
+ throw new Error(`Text checkpoint does not match its content hash: ${path}`);
3026
+ }
3027
+ await this.writeBase(record.baseHash, textBase);
3028
+ }
3029
+ const destination = this.recordPath(path);
3030
+ await atomicWrite(destination, serialized);
3031
+ this.invalidRecordPaths.delete(destination);
3032
+ this.persistedRecords.set(path, serialized);
3033
+ }
3034
+ const cursor = json({ remoteVersion: state.remoteVersion, initialized: state.initialized });
3035
+ if (cursor !== this.persistedCursor) {
3036
+ await atomicWrite(this.cursorPath, cursor);
3037
+ this.persistedCursor = cursor;
3038
+ }
3039
+ if (state.initialized) {
3040
+ await Promise.all([...this.invalidRecordPaths].map((path) => rm2(path, { force: true })));
3041
+ this.invalidRecordPaths.clear();
3042
+ }
3043
+ await rm2(this.legacyPath, { force: true });
3044
+ await this.removeObsoleteBases(state, obsoleteBases);
3045
+ }
3046
+ async initializeStorage() {
3047
+ await rm2(this.syncDirectory, { recursive: true, force: true });
3048
+ await mkdir3(this.pathsDirectory, { recursive: true });
3049
+ await mkdir3(this.basesDirectory, { recursive: true });
3050
+ await atomicWrite(this.formatPath, json({
3051
+ schemaVersion: SYNC_STATE_SCHEMA_VERSION,
3052
+ projectUri: this.projectUri
3053
+ }));
3054
+ this.persistedRecords.clear();
3055
+ this.persistedCursor = void 0;
3056
+ this.invalidRecordPaths.clear();
3057
+ this.initialized = true;
3058
+ }
3059
+ recordPath(path) {
3060
+ const key = pathKey(path);
3061
+ return join2(this.pathsDirectory, key.slice(0, 2), `${key}.json`);
3062
+ }
3063
+ async loadRecord(recordPath) {
3064
+ if (!recordPath.endsWith(".json")) {
3065
+ return void 0;
3066
+ }
3067
+ try {
3068
+ const record = await readJson(recordPath);
3069
+ if (!parsePathRecord(record) || recordPath !== this.recordPath(record.path)) {
3070
+ return void 0;
3071
+ }
3072
+ return record;
3073
+ } catch (error2) {
3074
+ if (error2.code === "ENOENT" || error2 instanceof SyntaxError) {
3075
+ return void 0;
3076
+ }
3077
+ throw error2;
3078
+ }
3079
+ }
3080
+ basePath(hash) {
3081
+ return join2(this.basesDirectory, hash.slice(0, 2), `${hash}.txt`);
3082
+ }
3083
+ async writeBase(hash, content) {
3084
+ const path = this.basePath(hash);
3085
+ try {
3086
+ if (sha256(encoder.encode(await readFile4(path, "utf8"))) === hash) {
3087
+ return;
3088
+ }
3089
+ } catch (error2) {
3090
+ if (error2.code !== "ENOENT") {
3091
+ throw error2;
3092
+ }
3093
+ }
3094
+ await atomicWrite(path, content);
3095
+ }
3096
+ async removeObsoleteBases(state, candidates) {
3097
+ if (candidates.size === 0) {
2467
3098
  return;
2468
3099
  }
2469
- await mkdir3(dirname3(this.path), { recursive: true });
2470
- await writeFile3(this.path, serialized, "utf8");
2471
- this.serialized = serialized;
3100
+ const referenced = /* @__PURE__ */ new Set();
3101
+ for (const [path, textBase] of Object.entries(state.textBases)) {
3102
+ if (textBase !== void 0 && state.files[path] !== void 0) {
3103
+ referenced.add(state.files[path]);
3104
+ }
3105
+ }
3106
+ for (const hash of candidates) {
3107
+ if (!referenced.has(hash)) {
3108
+ await rm2(this.basePath(hash), { force: true });
3109
+ }
3110
+ }
3111
+ }
3112
+ async removeOrphanBases(referencedHashes) {
3113
+ const referencedPaths = new Set([...referencedHashes].map((hash) => this.basePath(hash)));
3114
+ for (const path of await listFiles(this.basesDirectory)) {
3115
+ if (!referencedPaths.has(path)) {
3116
+ await rm2(path, { force: true });
3117
+ }
3118
+ }
2472
3119
  }
2473
3120
  };
2474
3121
  async function startServe(directory = process.cwd(), options = {}) {
@@ -2534,4 +3181,4 @@ export {
2534
3181
  RemoteProject,
2535
3182
  startServe
2536
3183
  };
2537
- //# sourceMappingURL=chunk-Q3R5N4YE.js.map
3184
+ //# sourceMappingURL=chunk-OZ4FLSVP.js.map