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.
- package/DESIGN.md +75 -8
- package/OPERATIONS.md +19 -2
- package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-Dazar9cj.js} +1 -1
- package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-VSUiSPgG.js} +1 -1
- package/dist/client/assets/Dashboard-CoE5ESfb.js +2 -0
- package/dist/client/assets/GitDialog-C9Loz4Al.js +3 -0
- package/dist/client/assets/HistoryDialog-3UNN_jhU.js +7 -0
- package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-Bq76v8sO.js} +2 -2
- package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BKhcS-2c.js} +1 -1
- package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-BoWMjQVm.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-CZKTnxrl.js} +1 -1
- package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-DiOQVJ40.js} +1 -1
- package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +27 -0
- package/dist/client/assets/SelectionHistoryDialog-D3dO83lg.js +3 -0
- package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-0zCwLtH_.js} +1 -1
- package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-C6KDwtvd.js} +1 -1
- package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-TwpyAP3m.js} +1 -1
- package/dist/client/assets/check-sZxOOKtS.js +1 -0
- package/dist/client/assets/chevron-left-32SrOwa5.js +1 -0
- package/dist/client/assets/hard-drive-XHSiS_A7.js +1 -0
- package/dist/client/assets/index-BRr3Kuxh.css +1 -0
- package/dist/client/assets/index-BjzELSQ-.js +12 -0
- package/dist/client/assets/index-Cd6-F29b.js +5 -0
- package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-C-CXCWnz.js} +1 -1
- package/dist/client/assets/minimize-2-BMDPVaF-.js +1 -0
- package/dist/client/assets/{minus-Beso_n-6.js → minus-BIa7-KuM.js} +1 -1
- package/dist/client/assets/{plus-BfEC7mzf.js → plus-BKDWy4Ui.js} +1 -1
- package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Bo7-a7-I.js} +1 -1
- package/dist/client/assets/{search-DL2UGvdj.js → search-Bz9Ob5mT.js} +1 -1
- package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-DxlHf9gv.js} +1 -1
- package/dist/client/assets/spellCheck-BfZEnpwY.js +1 -0
- package/dist/client/assets/{tags-CjkWzXqX.js → tags-IMZehjzE.js} +1 -1
- package/dist/client/assets/{type-D1mmDdLM.js → type-DTWpFQNQ.js} +1 -1
- package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus--aVpd8K6.js} +1 -1
- package/dist/client/assets/x-Tbo3aA0J.js +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +45 -4
- package/dist/server/cli.js +4 -0
- package/dist/server/collaboration.js +219 -7
- package/dist/server/commentMentions.js +139 -0
- package/dist/server/config.js +9 -2
- package/dist/server/db.js +59 -0
- package/dist/server/editHistory.js +411 -0
- package/dist/server/editHistoryRetry.js +90 -0
- package/dist/server/harper.js +205 -8
- package/dist/server/history.js +189 -23
- package/dist/server/historyRetention.js +37 -0
- package/dist/server/i18n.js +12 -0
- package/dist/server/index.js +6 -0
- package/dist/server/latexSpellMask.js +197 -43
- package/dist/server/logRotation.js +82 -0
- package/dist/server/pm2.js +7 -0
- package/dist/server/routes/comments.js +120 -17
- package/dist/server/routes/projectHistory.js +106 -8
- package/dist/server/routes/projectShared.js +2 -1
- package/dist/server/routes/projects.js +39 -5
- package/package.json +1 -1
- package/texlite.config.example.json +4 -1
- package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
- package/dist/client/assets/GitDialog-BCfSHuMB.js +0 -3
- package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
- package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
- package/dist/client/assets/check-bnimQPun.js +0 -1
- package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
- package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
- package/dist/client/assets/index-CzVDvC5h.js +0 -14
- package/dist/client/assets/index-RjUuWk8F.css +0 -1
- package/dist/client/assets/spellCheck-L9gD2d1M.js +0 -1
- package/dist/client/assets/x-BDSEosRE.js +0 -1
package/dist/server/harper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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.
|
|
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;
|
package/dist/server/history.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
WHERE project_id =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|