texlite 0.8.4 → 0.9.1

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 (69) hide show
  1. package/DESIGN.md +75 -8
  2. package/OPERATIONS.md +19 -2
  3. package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-Dazar9cj.js} +1 -1
  4. package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-VSUiSPgG.js} +1 -1
  5. package/dist/client/assets/Dashboard-CoE5ESfb.js +2 -0
  6. package/dist/client/assets/GitDialog-C9Loz4Al.js +3 -0
  7. package/dist/client/assets/HistoryDialog-3UNN_jhU.js +7 -0
  8. package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-Bq76v8sO.js} +2 -2
  9. package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BKhcS-2c.js} +1 -1
  10. package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-BoWMjQVm.js} +1 -1
  11. package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-CZKTnxrl.js} +1 -1
  12. package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-DiOQVJ40.js} +1 -1
  13. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +27 -0
  14. package/dist/client/assets/SelectionHistoryDialog-D3dO83lg.js +3 -0
  15. package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-0zCwLtH_.js} +1 -1
  16. package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-C6KDwtvd.js} +1 -1
  17. package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-TwpyAP3m.js} +1 -1
  18. package/dist/client/assets/check-sZxOOKtS.js +1 -0
  19. package/dist/client/assets/chevron-left-32SrOwa5.js +1 -0
  20. package/dist/client/assets/hard-drive-XHSiS_A7.js +1 -0
  21. package/dist/client/assets/index-BRr3Kuxh.css +1 -0
  22. package/dist/client/assets/index-BjzELSQ-.js +12 -0
  23. package/dist/client/assets/index-Cd6-F29b.js +5 -0
  24. package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-C-CXCWnz.js} +1 -1
  25. package/dist/client/assets/minimize-2-BMDPVaF-.js +1 -0
  26. package/dist/client/assets/{minus-Beso_n-6.js → minus-BIa7-KuM.js} +1 -1
  27. package/dist/client/assets/{plus-BfEC7mzf.js → plus-BKDWy4Ui.js} +1 -1
  28. package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Bo7-a7-I.js} +1 -1
  29. package/dist/client/assets/{search-DL2UGvdj.js → search-Bz9Ob5mT.js} +1 -1
  30. package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-DxlHf9gv.js} +1 -1
  31. package/dist/client/assets/spellCheck-BfZEnpwY.js +1 -0
  32. package/dist/client/assets/{tags-CjkWzXqX.js → tags-IMZehjzE.js} +1 -1
  33. package/dist/client/assets/{type-D1mmDdLM.js → type-DTWpFQNQ.js} +1 -1
  34. package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus--aVpd8K6.js} +1 -1
  35. package/dist/client/assets/x-Tbo3aA0J.js +1 -0
  36. package/dist/client/index.html +2 -2
  37. package/dist/server/app.js +45 -4
  38. package/dist/server/cli.js +4 -0
  39. package/dist/server/collaboration.js +219 -7
  40. package/dist/server/commentMentions.js +139 -0
  41. package/dist/server/config.js +9 -2
  42. package/dist/server/db.js +59 -0
  43. package/dist/server/editHistory.js +411 -0
  44. package/dist/server/editHistoryRetry.js +90 -0
  45. package/dist/server/harper.js +205 -8
  46. package/dist/server/history.js +189 -23
  47. package/dist/server/historyRetention.js +37 -0
  48. package/dist/server/i18n.js +12 -0
  49. package/dist/server/index.js +6 -0
  50. package/dist/server/latexSpellMask.js +197 -43
  51. package/dist/server/logRotation.js +82 -0
  52. package/dist/server/pm2.js +7 -0
  53. package/dist/server/routes/comments.js +120 -17
  54. package/dist/server/routes/projectHistory.js +106 -8
  55. package/dist/server/routes/projectShared.js +2 -1
  56. package/dist/server/routes/projects.js +39 -5
  57. package/package.json +1 -1
  58. package/texlite.config.example.json +4 -1
  59. package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
  60. package/dist/client/assets/GitDialog-BCfSHuMB.js +0 -3
  61. package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
  62. package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
  63. package/dist/client/assets/check-bnimQPun.js +0 -1
  64. package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
  65. package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
  66. package/dist/client/assets/index-CzVDvC5h.js +0 -14
  67. package/dist/client/assets/index-RjUuWk8F.css +0 -1
  68. package/dist/client/assets/spellCheck-L9gD2d1M.js +0 -1
  69. package/dist/client/assets/x-BDSEosRE.js +0 -1
@@ -11,12 +11,26 @@ const maxCachedSourceBytes = 512 * 1024;
11
11
  const maxCachedResults = 24;
12
12
  const cacheTtlMs = 15_000;
13
13
  const unavailableRetryMs = 15_000;
14
+ // A browser can send an older request after a newer one when HTTP requests
15
+ // race. Keep the latest sequence briefly so that late arrivals cannot replace
16
+ // a newer queued revision. The bound is intentionally generous for a small
17
+ // collaborative installation while keeping abandoned browser-page lanes from
18
+ // accumulating forever.
19
+ const laneSequenceRetentionMs = 10 * 60_000;
20
+ const maxTrackedLaneSequences = 512;
14
21
  export class HarperUnavailableError extends Error {
15
22
  constructor(message) {
16
23
  super(message);
17
24
  this.name = "HarperUnavailableError";
18
25
  }
19
26
  }
27
+ /** A newer source revision made a waiting writing check irrelevant. */
28
+ export class HarperLintSupersededError extends Error {
29
+ constructor() {
30
+ super("A newer writing check replaced this request.");
31
+ this.name = "HarperLintSupersededError";
32
+ }
33
+ }
20
34
  function lintCacheKey(source, filePath) {
21
35
  // Harper selects its parser from the input suffix, so equivalent text in a
22
36
  // .tex and a .sty file must not share a diagnostic result.
@@ -78,8 +92,20 @@ export class HarperService {
78
92
  availability = "unknown";
79
93
  lastUnavailableAt = 0;
80
94
  probePromise = null;
81
- queue = Promise.resolve();
95
+ /** One harper-cli process at a time; Harper itself is comparatively heavy. */
96
+ queueRunner = null;
97
+ // Set preserves insertion order, giving the scheduler FIFO behaviour while
98
+ // allowing a superseded waiting item to be removed immediately. An array
99
+ // would otherwise retain its complete source string until the active check
100
+ // finishes.
101
+ queued = new Set();
102
+ /** Only waiting work belongs here; a running process must finish safely. */
103
+ waitingByLane = new Map();
104
+ /** Latest accepted browser revision for each project/user/page/file lane. */
105
+ latestSequenceByLane = new Map();
82
106
  inFlight = new Map();
107
+ /** Lets coalesced browser lanes share one queued operation safely. */
108
+ scheduledByKey = new Map();
83
109
  cache = new Map();
84
110
  activeChild = null;
85
111
  disposed = false;
@@ -89,35 +115,206 @@ export class HarperService {
89
115
  async preload() {
90
116
  await this.ensureAvailable();
91
117
  }
92
- async lint(source, filePath = "main.tex") {
118
+ /**
119
+ * Schedule one host-side check. A lane represents one browser page's
120
+ * current file, so an edit can replace only its own stale waiting request
121
+ * without cancelling checks requested by collaborators.
122
+ */
123
+ async lint(source, filePath = "main.tex", lane, sequence) {
93
124
  if (this.disposed)
94
125
  throw new HarperUnavailableError("Harper service stopped.");
95
126
  const key = lintCacheKey(source, filePath);
127
+ const laneKey = lane || null;
128
+ if (laneKey && sequence !== undefined)
129
+ this.acceptLaneSequence(laneKey, sequence, key);
130
+ const waiting = laneKey ? this.waitingByLane.get(laneKey) : undefined;
131
+ // Repeated requests for the same queued revision share it. A newer
132
+ // revision replaces only work that has not started yet.
133
+ if (waiting?.key === key)
134
+ return waiting.promise;
135
+ if (laneKey && waiting)
136
+ this.releaseWaitingLane(laneKey, waiting);
96
137
  const cacheable = Buffer.byteLength(source, "utf8") <= maxCachedSourceBytes;
97
138
  const cached = cacheable ? this.cachedResult(key) : null;
98
139
  if (cached)
99
140
  return cached;
100
- const existing = cacheable ? this.inFlight.get(key) : undefined;
101
- if (existing)
141
+ // Result caching is deliberately size-bounded, but duplicate active work
142
+ // is still coalesced for large files. The latter is transient and avoids
143
+ // starting multiple expensive CLI processes for identical content.
144
+ const existing = this.inFlight.get(key);
145
+ if (existing) {
146
+ const scheduled = this.scheduledByKey.get(key);
147
+ if (laneKey && scheduled)
148
+ this.addWaitingLane(scheduled, laneKey);
149
+ else if (scheduled && !scheduled.started)
150
+ scheduled.hasUnscopedWaiter = true;
102
151
  return existing;
103
- const operation = this.queue.then(() => this.lintOnce(source, filePath));
104
- this.queue = operation.then(() => undefined, () => undefined);
152
+ }
153
+ let resolve;
154
+ let reject;
155
+ const operation = new Promise((resolveOperation, rejectOperation) => {
156
+ resolve = resolveOperation;
157
+ reject = rejectOperation;
158
+ });
159
+ const scheduled = {
160
+ key,
161
+ source,
162
+ filePath,
163
+ started: false,
164
+ cancelled: false,
165
+ lanes: laneKey ? new Set([laneKey]) : new Set(),
166
+ hasUnscopedWaiter: laneKey === null,
167
+ promise: operation,
168
+ resolve,
169
+ reject
170
+ };
171
+ this.inFlight.set(key, operation);
172
+ this.scheduledByKey.set(key, scheduled);
173
+ if (laneKey)
174
+ this.waitingByLane.set(laneKey, scheduled);
175
+ this.queued.add(scheduled);
176
+ this.startQueue();
105
177
  if (cacheable) {
106
- this.inFlight.set(key, operation);
107
178
  void operation.then((lints) => this.cacheResult(key, lints), () => undefined).finally(() => {
108
179
  if (this.inFlight.get(key) === operation)
109
180
  this.inFlight.delete(key);
181
+ if (this.scheduledByKey.get(key) === scheduled)
182
+ this.scheduledByKey.delete(key);
183
+ });
184
+ }
185
+ else {
186
+ // Attach a rejection handler even for uncached results. This keeps a
187
+ // superseded request from becoming an unhandled rejection when a client
188
+ // disconnects before it awaits the response.
189
+ void operation.catch(() => undefined).finally(() => {
190
+ if (this.inFlight.get(key) === operation)
191
+ this.inFlight.delete(key);
192
+ if (this.scheduledByKey.get(key) === scheduled)
193
+ this.scheduledByKey.delete(key);
110
194
  });
111
195
  }
112
196
  return operation;
113
197
  }
114
198
  async dispose() {
115
199
  this.disposed = true;
200
+ const stopped = new HarperUnavailableError("Harper service stopped.");
201
+ for (const scheduled of [...this.queued]) {
202
+ if (!scheduled.cancelled && !scheduled.started) {
203
+ scheduled.cancelled = true;
204
+ this.discardQueuedLint(scheduled);
205
+ scheduled.reject(stopped);
206
+ }
207
+ }
208
+ this.waitingByLane.clear();
209
+ this.latestSequenceByLane.clear();
116
210
  this.activeChild?.kill("SIGTERM");
117
- await this.queue.catch(() => undefined);
211
+ await this.queueRunner?.catch(() => undefined);
118
212
  this.inFlight.clear();
213
+ this.scheduledByKey.clear();
119
214
  this.cache.clear();
120
215
  }
216
+ addWaitingLane(scheduled, lane) {
217
+ if (scheduled.started || scheduled.cancelled)
218
+ return;
219
+ scheduled.lanes.add(lane);
220
+ this.waitingByLane.set(lane, scheduled);
221
+ }
222
+ releaseWaitingLane(lane, scheduled) {
223
+ if (this.waitingByLane.get(lane) === scheduled)
224
+ this.waitingByLane.delete(lane);
225
+ scheduled.lanes.delete(lane);
226
+ // The exact same queued source can be requested by multiple sessions.
227
+ // Discard it only after every interested lane has moved on.
228
+ if (scheduled.started || scheduled.cancelled || scheduled.hasUnscopedWaiter || scheduled.lanes.size > 0)
229
+ return;
230
+ this.cancelScheduledLint(scheduled);
231
+ }
232
+ cancelScheduledLint(scheduled) {
233
+ if (scheduled.started || scheduled.cancelled)
234
+ return;
235
+ scheduled.cancelled = true;
236
+ for (const lane of scheduled.lanes) {
237
+ if (this.waitingByLane.get(lane) === scheduled)
238
+ this.waitingByLane.delete(lane);
239
+ }
240
+ scheduled.lanes.clear();
241
+ // Remove this rejected promise immediately so a later return to the same
242
+ // text can enqueue fresh work instead of inheriting a stale rejection.
243
+ if (this.inFlight.get(scheduled.key) === scheduled.promise) {
244
+ this.inFlight.delete(scheduled.key);
245
+ }
246
+ if (this.scheduledByKey.get(scheduled.key) === scheduled) {
247
+ this.scheduledByKey.delete(scheduled.key);
248
+ }
249
+ this.discardQueuedLint(scheduled);
250
+ scheduled.reject(new HarperLintSupersededError());
251
+ }
252
+ /** Remove a waiting task and promptly release its potentially large text. */
253
+ discardQueuedLint(scheduled) {
254
+ this.queued.delete(scheduled);
255
+ scheduled.source = "";
256
+ scheduled.filePath = "";
257
+ }
258
+ /** Reject an out-of-order request before it can alter queued work. */
259
+ acceptLaneSequence(lane, sequence, key) {
260
+ if (!Number.isSafeInteger(sequence) || sequence <= 0) {
261
+ throw new HarperLintSupersededError();
262
+ }
263
+ const now = Date.now();
264
+ this.pruneLaneSequences(now);
265
+ const previous = this.latestSequenceByLane.get(lane);
266
+ if (previous && (sequence < previous.sequence || (sequence === previous.sequence && previous.key !== key))) {
267
+ throw new HarperLintSupersededError();
268
+ }
269
+ // Refresh insertion order so the bounded map evicts the least recently
270
+ // used abandoned page lane first.
271
+ this.latestSequenceByLane.delete(lane);
272
+ this.latestSequenceByLane.set(lane, { sequence, key, seenAt: now });
273
+ while (this.latestSequenceByLane.size > maxTrackedLaneSequences) {
274
+ const oldest = this.latestSequenceByLane.keys().next().value;
275
+ if (oldest === undefined)
276
+ break;
277
+ this.latestSequenceByLane.delete(oldest);
278
+ }
279
+ }
280
+ pruneLaneSequences(now) {
281
+ for (const [lane, state] of this.latestSequenceByLane) {
282
+ if (now - state.seenAt <= laneSequenceRetentionMs)
283
+ continue;
284
+ this.latestSequenceByLane.delete(lane);
285
+ }
286
+ }
287
+ startQueue() {
288
+ if (this.queueRunner)
289
+ return;
290
+ this.queueRunner = (async () => {
291
+ while (!this.disposed) {
292
+ const scheduled = this.queued.values().next().value;
293
+ if (!scheduled)
294
+ return;
295
+ this.queued.delete(scheduled);
296
+ if (scheduled.cancelled)
297
+ continue;
298
+ scheduled.started = true;
299
+ for (const lane of scheduled.lanes) {
300
+ if (this.waitingByLane.get(lane) === scheduled)
301
+ this.waitingByLane.delete(lane);
302
+ }
303
+ scheduled.lanes.clear();
304
+ try {
305
+ scheduled.resolve(await this.lintOnce(scheduled.source, scheduled.filePath));
306
+ }
307
+ catch (error) {
308
+ scheduled.reject(error);
309
+ }
310
+ }
311
+ })().finally(() => {
312
+ this.queueRunner = null;
313
+ // A request can arrive just after the loop observes an empty queue.
314
+ if (!this.disposed && this.queued.size)
315
+ this.startQueue();
316
+ });
317
+ }
121
318
  async ensureAvailable() {
122
319
  if (this.availability === "available")
123
320
  return;
@@ -5,6 +5,12 @@ import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, reso
5
5
  import { httpError } from "./http.js";
6
6
  import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
7
7
  const AUTOSAVE_COALESCE_MS = 2 * 60_000;
8
+ // Logical stored payload; SQLite pages, indexes and WAL are shared database overhead.
9
+ function historyMetadataBytes(row) {
10
+ return Object.values(row).reduce((sum, value) => sum + (typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0), 0);
11
+ }
12
+ const DEFAULT_HISTORY_PAGE_SIZE = 100;
13
+ const MAX_HISTORY_PAGE_SIZE = 100;
8
14
  export class ProjectHistoryService {
9
15
  config;
10
16
  db;
@@ -12,7 +18,7 @@ export class ProjectHistoryService {
12
18
  this.config = config;
13
19
  this.db = db;
14
20
  }
15
- record(projectId, authorId, reason, changedRoots) {
21
+ record(projectId, authorId, reason, changedRoots, options = {}) {
16
22
  const project = this.project(projectId);
17
23
  const previous = this.latestRow(projectId);
18
24
  const previousManifest = this.baseline(projectId) ?? (previous ? parseManifest(previous.manifest_json) : null);
@@ -38,17 +44,19 @@ export class ProjectHistoryService {
38
44
  return null;
39
45
  }
40
46
  const createdAt = new Date().toISOString();
41
- if (reason === "autosave" && previous?.reason === "autosave" && !previous.label && previous.author_id === authorId
47
+ if (reason === "autosave" && previous?.reason === "autosave" && !previous.label
42
48
  && Date.parse(createdAt) - Date.parse(previous.created_at) < AUTOSAVE_COALESCE_MS) {
43
49
  const merged = [...new Set([...parseStringArray(previous.changed_paths_json), ...changedPaths])].sort();
44
50
  this.db.prepare(`UPDATE project_history_versions
45
- SET manifest_json = ?, changed_paths_json = ? WHERE id = ?`)
46
- .run(JSON.stringify(manifest), JSON.stringify(merged), previous.id);
51
+ SET manifest_json = ?, changed_paths_json = ?, author_id = ? WHERE id = ?`)
52
+ .run(JSON.stringify(manifest), JSON.stringify(merged), previous.author_id === authorId ? authorId : null, previous.id);
47
53
  this.saveBaseline(projectId, manifest);
48
- this.removeUnreferencedObjects(projectId, new Set(previousManifest
49
- ? Object.values(previousManifest.files).map((file) => file.digest)
50
- : []));
51
- this.pruneVersions(projectId);
54
+ if (!options.deferRetention) {
55
+ this.removeUnreferencedObjects(projectId, new Set(previousManifest
56
+ ? Object.values(previousManifest.files).map((file) => file.digest)
57
+ : []));
58
+ this.pruneVersions(projectId);
59
+ }
52
60
  return this.version(previous.id);
53
61
  }
54
62
  const id = randomUUID();
@@ -57,7 +65,8 @@ export class ProjectHistoryService {
57
65
  VALUES (?, ?, ?, ?, ?, ?, ?)`)
58
66
  .run(id, projectId, authorId, reason, JSON.stringify(manifest), JSON.stringify(changedPaths), createdAt);
59
67
  this.saveBaseline(projectId, manifest);
60
- this.pruneVersions(projectId);
68
+ if (!options.deferRetention)
69
+ this.pruneVersions(projectId);
61
70
  return this.version(id);
62
71
  }
63
72
  list(projectId, limit = 100) {
@@ -67,13 +76,50 @@ export class ProjectHistoryService {
67
76
  .all(projectId, Math.min(200, Math.max(1, limit)));
68
77
  return rows.map((row) => versionJson(row));
69
78
  }
79
+ /**
80
+ * List immutable recovery snapshots from newest to oldest. Cursor pagination
81
+ * is preferable to offsets here: concurrent saves/deletions cannot shift an
82
+ * already loaded page or make the next request repeat a snapshot.
83
+ */
84
+ listPage(projectId, limit = DEFAULT_HISTORY_PAGE_SIZE, cursorInput) {
85
+ const requestedLimit = Number.isFinite(limit) ? Math.floor(limit) : DEFAULT_HISTORY_PAGE_SIZE;
86
+ const limitValue = Math.min(MAX_HISTORY_PAGE_SIZE, Math.max(1, requestedLimit));
87
+ const cursor = cursorInput === undefined ? null : decodeHistoryCursor(cursorInput);
88
+ if (cursorInput !== undefined && !cursor)
89
+ throw httpError(400, "REQUEST_INVALID");
90
+ const values = [projectId];
91
+ let cursorFilter = "";
92
+ if (cursor) {
93
+ cursorFilter = "AND (history.created_at < ? OR (history.created_at = ? AND history.rowid < ?))";
94
+ values.push(cursor.createdAt, cursor.createdAt, cursor.rowId);
95
+ }
96
+ values.push(limitValue + 1);
97
+ const rows = this.db.prepare(`SELECT history.*, history.rowid AS history_rowid,
98
+ user.username AS author_username, user.display_name AS author_name
99
+ FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
100
+ WHERE history.project_id = ? ${cursorFilter}
101
+ ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
102
+ .all(...values);
103
+ const pageRows = rows.slice(0, limitValue);
104
+ return {
105
+ versions: pageRows.map((row) => versionJson(row)),
106
+ nextCursor: rows.length > limitValue && pageRows.length > 0 ? encodeHistoryCursor(pageRows.at(-1)) : null
107
+ };
108
+ }
70
109
  stats(projectId) {
71
- const rows = this.db.prepare(`SELECT reason, label, manifest_json FROM project_history_versions
72
- WHERE project_id = ?`).all(projectId);
110
+ const rows = this.db.prepare(`SELECT * FROM project_history_versions
111
+ WHERE project_id = ? ORDER BY created_at DESC, rowid DESC`).all(projectId);
73
112
  const objects = new Map();
113
+ const protectedObjects = new Map();
114
+ let metadataBytes = 0;
115
+ let protectedMetadataBytes = 0;
74
116
  let ordinaryVersionCount = 0;
75
117
  let labeledVersionCount = 0;
76
118
  for (const row of rows) {
119
+ const protectedVersion = row === rows[0] || row.reason === "initial" || Boolean(row.label);
120
+ metadataBytes += historyMetadataBytes(row);
121
+ if (protectedVersion)
122
+ protectedMetadataBytes += historyMetadataBytes(row);
77
123
  if (row.label)
78
124
  labeledVersionCount += 1;
79
125
  else if (row.reason !== "initial")
@@ -81,13 +127,19 @@ export class ProjectHistoryService {
81
127
  for (const file of Object.values(parseManifest(row.manifest_json).files)) {
82
128
  if (!objects.has(file.digest))
83
129
  objects.set(file.digest, file.size);
130
+ if (protectedVersion)
131
+ protectedObjects.set(file.digest, file.size);
84
132
  }
85
133
  }
86
134
  const baseline = this.baseline(projectId);
87
135
  if (baseline) {
136
+ const baselineBytes = Buffer.byteLength(JSON.stringify(baseline), "utf8");
137
+ metadataBytes += baselineBytes;
138
+ protectedMetadataBytes += baselineBytes;
88
139
  for (const file of Object.values(baseline.files)) {
89
140
  if (!objects.has(file.digest))
90
141
  objects.set(file.digest, file.size);
142
+ protectedObjects.set(file.digest, file.size);
91
143
  }
92
144
  }
93
145
  const objectBytes = [...objects.values()].reduce((sum, size) => sum + size, 0);
@@ -97,13 +149,21 @@ export class ProjectHistoryService {
97
149
  labeledVersionCount,
98
150
  objectCount: objects.size,
99
151
  objectBytes,
152
+ metadataBytes,
153
+ totalBytes: objectBytes + metadataBytes,
154
+ protectedBytes: [...protectedObjects.values()].reduce((sum, size) => sum + size, protectedMetadataBytes),
100
155
  maxVersions: this.config.historyMaxVersions,
101
156
  maxStorageBytes: this.config.historyMaxStorageBytes,
102
- storageLimitExceeded: objectBytes > this.config.historyMaxStorageBytes
157
+ storageLimitExceeded: objectBytes + metadataBytes > this.config.historyMaxStorageBytes
103
158
  };
104
159
  }
105
160
  enforceRetention(projectId) {
106
161
  this.pruneVersions(projectId);
162
+ // Deferred autosave maintenance can leave an object from a coalesced
163
+ // version without any manifest referring to it. Scan the small
164
+ // content-addressed object store here (never in the save path) so a
165
+ // restart also recovers objects left behind before a timer could run.
166
+ this.removeUnreferencedObjects(projectId, this.storedObjectDigests(projectId));
107
167
  }
108
168
  deleteVersion(projectId, versionId) {
109
169
  const row = this.db.prepare("SELECT id, manifest_json FROM project_history_versions WHERE id = ? AND project_id = ?")
@@ -133,6 +193,23 @@ export class ProjectHistoryService {
133
193
  .get(versionId, projectId);
134
194
  return row ? parseManifest(row.manifest_json) : null;
135
195
  }
196
+ /**
197
+ * A short autosave window deliberately coalesces updates into one row. A
198
+ * client that has been inspecting that row must not restore newer contents
199
+ * it has not reviewed, so verify the manifest fingerprint immediately
200
+ * before a restore mutates the project.
201
+ */
202
+ assertSnapshotHash(projectId, versionId, expectedHash) {
203
+ if (!expectedHash)
204
+ return;
205
+ const row = this.db.prepare("SELECT manifest_json FROM project_history_versions WHERE id = ? AND project_id = ?")
206
+ .get(versionId, projectId);
207
+ if (!row)
208
+ throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
209
+ if (historySnapshotHash(row.manifest_json) !== expectedHash) {
210
+ throw httpError(409, "HISTORY_VERSION_CHANGED");
211
+ }
212
+ }
136
213
  /**
137
214
  * Validate a single-file restore before the project enters maintenance.
138
215
  * restore() repeats the target check while the exclusive operation is held
@@ -151,6 +228,14 @@ export class ProjectHistoryService {
151
228
  }
152
229
  this.assertRestoreTargetIsFile(projectId, filePath);
153
230
  }
231
+ previousVersion(projectId, versionId) {
232
+ const row = this.db.prepare(`SELECT older.id FROM project_history_versions older
233
+ JOIN project_history_versions selected ON selected.id = ? AND selected.project_id = older.project_id
234
+ WHERE older.project_id = ? AND (older.created_at < selected.created_at
235
+ OR (older.created_at = selected.created_at AND older.rowid < selected.rowid))
236
+ ORDER BY older.created_at DESC, older.rowid DESC LIMIT 1`).get(versionId, projectId);
237
+ return row ? this.version(row.id, projectId) : null;
238
+ }
154
239
  setLabel(projectId, versionId, label) {
155
240
  const result = this.db.prepare("UPDATE project_history_versions SET label = ? WHERE id = ? AND project_id = ?")
156
241
  .run(label, versionId, projectId);
@@ -161,7 +246,7 @@ export class ProjectHistoryService {
161
246
  const entry = this.manifest(projectId, versionId)?.files[filePath];
162
247
  if (!entry || entry.size > MAX_TEXT_PREVIEW_BYTES)
163
248
  return null;
164
- return fs.readFileSync(this.objectPath(projectId, entry.digest), "utf8");
249
+ return this.readStoredObject(projectId, entry.digest, filePath);
165
250
  }
166
251
  restore(projectId, versionId, filePathInput) {
167
252
  const manifest = this.manifest(projectId, versionId);
@@ -177,7 +262,7 @@ export class ProjectHistoryService {
177
262
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
178
263
  const temporary = `${target}.history-${randomUUID()}.tmp`;
179
264
  try {
180
- fs.copyFileSync(this.objectPath(projectId, entry.digest), temporary);
265
+ this.copyStoredObject(projectId, entry.digest, filePath, temporary);
181
266
  fs.chmodSync(temporary, 0o600);
182
267
  fs.renameSync(temporary, target);
183
268
  }
@@ -272,8 +357,54 @@ export class ProjectHistoryService {
272
357
  throw new Error("Invalid history object digest");
273
358
  return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
274
359
  }
360
+ /**
361
+ * A history manifest can outlive a damaged or manually removed object. Keep
362
+ * that operational failure distinct from an absent path in the snapshot so
363
+ * the UI can tell the user why viewing or restoring it is impossible.
364
+ */
365
+ readStoredObject(projectId, digest, filePath) {
366
+ try {
367
+ return fs.readFileSync(this.objectPath(projectId, digest), "utf8");
368
+ }
369
+ catch (error) {
370
+ this.rethrowHistoryObjectError(error, filePath);
371
+ }
372
+ }
373
+ copyStoredObject(projectId, digest, filePath, target) {
374
+ try {
375
+ fs.copyFileSync(this.objectPath(projectId, digest), target);
376
+ }
377
+ catch (error) {
378
+ this.rethrowHistoryObjectError(error, filePath);
379
+ }
380
+ }
381
+ rethrowHistoryObjectError(error, filePath) {
382
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
383
+ ? error.code : "";
384
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
385
+ throw httpError(410, "HISTORY_OBJECT_MISSING", { path: filePath });
386
+ }
387
+ throw error;
388
+ }
389
+ storedObjectDigests(projectId) {
390
+ const objectsRoot = path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects");
391
+ if (!fs.existsSync(objectsRoot))
392
+ return new Set();
393
+ const digests = new Set();
394
+ for (const prefix of fs.readdirSync(objectsRoot, { withFileTypes: true })) {
395
+ if (!prefix.isDirectory() || !/^[a-f0-9]{2}$/.test(prefix.name))
396
+ continue;
397
+ const prefixPath = path.join(objectsRoot, prefix.name);
398
+ for (const entry of fs.readdirSync(prefixPath, { withFileTypes: true })) {
399
+ if (entry.isFile() && /^[a-f0-9]{64}$/.test(entry.name) && entry.name.startsWith(prefix.name)) {
400
+ digests.add(entry.name);
401
+ }
402
+ }
403
+ }
404
+ return digests;
405
+ }
275
406
  pruneVersions(projectId) {
276
- const rows = this.db.prepare(`SELECT id, reason, label, manifest_json, created_at
407
+ const rows = this.db.prepare(`SELECT *
277
408
  FROM project_history_versions WHERE project_id = ?
278
409
  ORDER BY created_at DESC, rowid DESC`).all(projectId);
279
410
  if (!rows.length)
@@ -299,19 +430,22 @@ export class ProjectHistoryService {
299
430
  sizeMap.set(file.digest, file.size);
300
431
  }
301
432
  }
302
- let currentObjectBytes = 0;
433
+ let retainedBytes = rows.reduce((sum, row) => sum + historyMetadataBytes(row), 0)
434
+ + (baseline ? Buffer.byteLength(JSON.stringify(baseline), "utf8") : 0);
303
435
  for (const [digest, count] of refCounts.entries()) {
304
436
  if (count > 0)
305
- currentObjectBytes += sizeMap.get(digest) ?? 0;
437
+ retainedBytes += sizeMap.get(digest) ?? 0;
306
438
  }
307
439
  const latestId = rows[0]?.id;
308
440
  // Ordinary versions eligible for version count capping (ordered newest to oldest)
309
441
  const ordinaryDesc = rows.filter((row) => row.label === null && row.reason !== "initial");
310
442
  const toDeleteIds = new Set();
443
+ const rowsById = new Map(rows.map((row) => [row.id, row]));
311
444
  const removeVersionRefs = (versionId) => {
312
445
  if (toDeleteIds.has(versionId))
313
446
  return;
314
447
  toDeleteIds.add(versionId);
448
+ retainedBytes -= historyMetadataBytes(rowsById.get(versionId));
315
449
  const manifest = manifests.get(versionId);
316
450
  if (!manifest)
317
451
  return;
@@ -320,7 +454,7 @@ export class ProjectHistoryService {
320
454
  if (count !== undefined) {
321
455
  if (count === 1) {
322
456
  refCounts.set(file.digest, 0);
323
- currentObjectBytes -= sizeMap.get(file.digest) ?? 0;
457
+ retainedBytes -= sizeMap.get(file.digest) ?? 0;
324
458
  }
325
459
  else if (count > 1) {
326
460
  refCounts.set(file.digest, count - 1);
@@ -328,19 +462,19 @@ export class ProjectHistoryService {
328
462
  }
329
463
  }
330
464
  };
331
- // 1. Cap by historyMaxVersions (prune excess ordinary versions from newest to oldest offset)
332
- if (ordinaryDesc.length > this.config.historyMaxVersions) {
465
+ // 1. Cap by historyMaxVersions (prune excess ordinary versions when configured; 0 = unlimited)
466
+ if (this.config.historyMaxVersions > 0 && ordinaryDesc.length > this.config.historyMaxVersions) {
333
467
  const obsolete = ordinaryDesc.slice(this.config.historyMaxVersions);
334
468
  for (const row of obsolete)
335
469
  removeVersionRefs(row.id);
336
470
  }
337
471
  // 2. Cap by historyMaxStorageBytes (prune remaining eligible versions from oldest to newest)
338
- if (currentObjectBytes > this.config.historyMaxStorageBytes) {
472
+ if (retainedBytes > this.config.historyMaxStorageBytes) {
339
473
  const remainingEligibleAsc = ordinaryDesc
340
474
  .filter((row) => row.id !== latestId && !toDeleteIds.has(row.id))
341
475
  .reverse();
342
476
  for (const row of remainingEligibleAsc) {
343
- if (currentObjectBytes <= this.config.historyMaxStorageBytes)
477
+ if (retainedBytes <= this.config.historyMaxStorageBytes)
344
478
  break;
345
479
  removeVersionRefs(row.id);
346
480
  }
@@ -405,7 +539,7 @@ export class ProjectHistoryService {
405
539
  for (const [filePath, entry] of Object.entries(manifest.files)) {
406
540
  const target = path.join(temporary, safeRelativePath(filePath));
407
541
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
408
- fs.copyFileSync(this.objectPath(projectId, entry.digest), target);
542
+ this.copyStoredObject(projectId, entry.digest, filePath, target);
409
543
  fs.chmodSync(target, 0o600);
410
544
  }
411
545
  fs.renameSync(live, backup);
@@ -456,6 +590,34 @@ function parseStringArray(value) {
456
590
  return [];
457
591
  }
458
592
  }
593
+ /**
594
+ * Keep cursors opaque so the public API is free to change its physical
595
+ * ordering later. `rowid` breaks ties for snapshots written in the same
596
+ * millisecond, which is common for file operations touching multiple paths.
597
+ */
598
+ function encodeHistoryCursor(row) {
599
+ return Buffer.from(JSON.stringify({ createdAt: row.created_at, rowId: row.history_rowid }), "utf8").toString("base64url");
600
+ }
601
+ function decodeHistoryCursor(value) {
602
+ // Cursors are generated by this service and normally only a few dozen
603
+ // characters long. Bound input before decoding to keep malformed query
604
+ // strings cheap to reject.
605
+ if (value.length === 0 || value.length > 256)
606
+ return null;
607
+ try {
608
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
609
+ const { createdAt, rowId } = parsed;
610
+ if (typeof createdAt !== "string" || typeof rowId !== "number" || !Number.isSafeInteger(rowId) || rowId <= 0)
611
+ return null;
612
+ const timestamp = Date.parse(createdAt);
613
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === createdAt
614
+ ? { createdAt, rowId }
615
+ : null;
616
+ }
617
+ catch {
618
+ return null;
619
+ }
620
+ }
459
621
  function changedManifestPaths(previous, next) {
460
622
  const paths = new Set([...Object.keys(previous.files), ...Object.keys(next.files)]);
461
623
  return [...paths].filter((filePath) => previous.files[filePath]?.digest !== next.files[filePath]?.digest).sort();
@@ -465,6 +627,7 @@ function versionJson(row) {
465
627
  const files = Object.values(manifest.files);
466
628
  return {
467
629
  id: row.id,
630
+ snapshotHash: historySnapshotHash(row.manifest_json),
468
631
  reason: row.reason,
469
632
  label: row.label,
470
633
  createdAt: row.created_at,
@@ -478,3 +641,6 @@ function versionJson(row) {
478
641
  totalSize: files.reduce((sum, file) => sum + file.size, 0)
479
642
  };
480
643
  }
644
+ function historySnapshotHash(manifestJson) {
645
+ return createHash("sha256").update(manifestJson, "utf8").digest("hex");
646
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Coalesces non-critical history retention work. A history version is already
3
+ * durable before this scheduler runs; delaying maintenance can only leave
4
+ * extra old versions/objects temporarily, never lose a recoverable version.
5
+ */
6
+ export const HISTORY_RETENTION_DELAY_MS = 30_000;
7
+ export class HistoryRetentionScheduler {
8
+ history;
9
+ timers = new Map();
10
+ delayMs;
11
+ onError;
12
+ constructor(history, options = {}) {
13
+ this.history = history;
14
+ this.delayMs = options.delayMs ?? HISTORY_RETENTION_DELAY_MS;
15
+ this.onError = options.onError;
16
+ }
17
+ schedule(projectId) {
18
+ if (this.timers.has(projectId))
19
+ return;
20
+ const timer = setTimeout(() => {
21
+ this.timers.delete(projectId);
22
+ try {
23
+ this.history.enforceRetention(projectId);
24
+ }
25
+ catch (error) {
26
+ this.onError?.(error, projectId);
27
+ }
28
+ }, this.delayMs);
29
+ timer.unref?.();
30
+ this.timers.set(projectId, timer);
31
+ }
32
+ dispose() {
33
+ for (const timer of this.timers.values())
34
+ clearTimeout(timer);
35
+ this.timers.clear();
36
+ }
37
+ }