texlite 0.8.4 → 0.9.0
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-3CHb5012.js} +1 -1
- package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-zPpsUhWJ.js} +1 -1
- package/dist/client/assets/Dashboard-CWfSLzD3.js +2 -0
- package/dist/client/assets/{GitDialog-BCfSHuMB.js → GitDialog-BUU2U_LV.js} +2 -2
- package/dist/client/assets/HistoryDialog-B6aopsrw.js +7 -0
- package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-CM445Tk0.js} +1 -1
- package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BFVNczSk.js} +1 -1
- package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-Cf0zBf8u.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-TPbe11iR.js} +1 -1
- package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-CDQvIlQ4.js} +1 -1
- package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +27 -0
- package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +3 -0
- package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-CuqHPUnn.js} +1 -1
- package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-gRLM3lLx.js} +1 -1
- package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-B4PI0vEk.js} +1 -1
- package/dist/client/assets/check-BXvzrdoz.js +1 -0
- package/dist/client/assets/chevron-left-BB5MYb7g.js +1 -0
- package/dist/client/assets/hard-drive-6uYFyezl.js +1 -0
- package/dist/client/assets/index-BRT5O-Hg.js +5 -0
- package/dist/client/assets/index-C0t11tlT.js +14 -0
- package/dist/client/assets/index-DXWp2bzM.css +1 -0
- package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-DOAxDSfz.js} +1 -1
- package/dist/client/assets/minimize-2-BWtbag2I.js +1 -0
- package/dist/client/assets/{minus-Beso_n-6.js → minus-9eVJnugf.js} +1 -1
- package/dist/client/assets/{plus-BfEC7mzf.js → plus-BGXipRoG.js} +1 -1
- package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Dvp3O90f.js} +1 -1
- package/dist/client/assets/{search-DL2UGvdj.js → search-b1ORp1k8.js} +1 -1
- package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-Bk0_MzWi.js} +1 -1
- package/dist/client/assets/{spellCheck-L9gD2d1M.js → spellCheck-BJjWgnef.js} +1 -1
- package/dist/client/assets/{tags-CjkWzXqX.js → tags-JAkbmWns.js} +1 -1
- package/dist/client/assets/{type-D1mmDdLM.js → type-CE2DYkPH.js} +1 -1
- package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus-CKalKSsw.js} +1 -1
- package/dist/client/assets/x-Bq29njHL.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/history.js +136 -20
- package/dist/server/historyRetention.js +37 -0
- package/dist/server/i18n.js +4 -0
- package/dist/server/index.js +6 -0
- package/dist/server/logRotation.js +82 -0
- package/dist/server/pm2.js +7 -0
- package/dist/server/routes/comments.js +107 -16
- package/dist/server/routes/projectHistory.js +93 -7
- package/dist/server/routes/projectShared.js +2 -1
- package/dist/server/routes/projects.js +3 -1
- 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/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/x-BDSEosRE.js +0 -1
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 = ?")
|
|
@@ -151,6 +211,14 @@ export class ProjectHistoryService {
|
|
|
151
211
|
}
|
|
152
212
|
this.assertRestoreTargetIsFile(projectId, filePath);
|
|
153
213
|
}
|
|
214
|
+
previousVersion(projectId, versionId) {
|
|
215
|
+
const row = this.db.prepare(`SELECT older.id FROM project_history_versions older
|
|
216
|
+
JOIN project_history_versions selected ON selected.id = ? AND selected.project_id = older.project_id
|
|
217
|
+
WHERE older.project_id = ? AND (older.created_at < selected.created_at
|
|
218
|
+
OR (older.created_at = selected.created_at AND older.rowid < selected.rowid))
|
|
219
|
+
ORDER BY older.created_at DESC, older.rowid DESC LIMIT 1`).get(versionId, projectId);
|
|
220
|
+
return row ? this.version(row.id, projectId) : null;
|
|
221
|
+
}
|
|
154
222
|
setLabel(projectId, versionId, label) {
|
|
155
223
|
const result = this.db.prepare("UPDATE project_history_versions SET label = ? WHERE id = ? AND project_id = ?")
|
|
156
224
|
.run(label, versionId, projectId);
|
|
@@ -272,8 +340,25 @@ export class ProjectHistoryService {
|
|
|
272
340
|
throw new Error("Invalid history object digest");
|
|
273
341
|
return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
|
|
274
342
|
}
|
|
343
|
+
storedObjectDigests(projectId) {
|
|
344
|
+
const objectsRoot = path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects");
|
|
345
|
+
if (!fs.existsSync(objectsRoot))
|
|
346
|
+
return new Set();
|
|
347
|
+
const digests = new Set();
|
|
348
|
+
for (const prefix of fs.readdirSync(objectsRoot, { withFileTypes: true })) {
|
|
349
|
+
if (!prefix.isDirectory() || !/^[a-f0-9]{2}$/.test(prefix.name))
|
|
350
|
+
continue;
|
|
351
|
+
const prefixPath = path.join(objectsRoot, prefix.name);
|
|
352
|
+
for (const entry of fs.readdirSync(prefixPath, { withFileTypes: true })) {
|
|
353
|
+
if (entry.isFile() && /^[a-f0-9]{64}$/.test(entry.name) && entry.name.startsWith(prefix.name)) {
|
|
354
|
+
digests.add(entry.name);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return digests;
|
|
359
|
+
}
|
|
275
360
|
pruneVersions(projectId) {
|
|
276
|
-
const rows = this.db.prepare(`SELECT
|
|
361
|
+
const rows = this.db.prepare(`SELECT *
|
|
277
362
|
FROM project_history_versions WHERE project_id = ?
|
|
278
363
|
ORDER BY created_at DESC, rowid DESC`).all(projectId);
|
|
279
364
|
if (!rows.length)
|
|
@@ -299,19 +384,22 @@ export class ProjectHistoryService {
|
|
|
299
384
|
sizeMap.set(file.digest, file.size);
|
|
300
385
|
}
|
|
301
386
|
}
|
|
302
|
-
let
|
|
387
|
+
let retainedBytes = rows.reduce((sum, row) => sum + historyMetadataBytes(row), 0)
|
|
388
|
+
+ (baseline ? Buffer.byteLength(JSON.stringify(baseline), "utf8") : 0);
|
|
303
389
|
for (const [digest, count] of refCounts.entries()) {
|
|
304
390
|
if (count > 0)
|
|
305
|
-
|
|
391
|
+
retainedBytes += sizeMap.get(digest) ?? 0;
|
|
306
392
|
}
|
|
307
393
|
const latestId = rows[0]?.id;
|
|
308
394
|
// Ordinary versions eligible for version count capping (ordered newest to oldest)
|
|
309
395
|
const ordinaryDesc = rows.filter((row) => row.label === null && row.reason !== "initial");
|
|
310
396
|
const toDeleteIds = new Set();
|
|
397
|
+
const rowsById = new Map(rows.map((row) => [row.id, row]));
|
|
311
398
|
const removeVersionRefs = (versionId) => {
|
|
312
399
|
if (toDeleteIds.has(versionId))
|
|
313
400
|
return;
|
|
314
401
|
toDeleteIds.add(versionId);
|
|
402
|
+
retainedBytes -= historyMetadataBytes(rowsById.get(versionId));
|
|
315
403
|
const manifest = manifests.get(versionId);
|
|
316
404
|
if (!manifest)
|
|
317
405
|
return;
|
|
@@ -320,7 +408,7 @@ export class ProjectHistoryService {
|
|
|
320
408
|
if (count !== undefined) {
|
|
321
409
|
if (count === 1) {
|
|
322
410
|
refCounts.set(file.digest, 0);
|
|
323
|
-
|
|
411
|
+
retainedBytes -= sizeMap.get(file.digest) ?? 0;
|
|
324
412
|
}
|
|
325
413
|
else if (count > 1) {
|
|
326
414
|
refCounts.set(file.digest, count - 1);
|
|
@@ -328,19 +416,19 @@ export class ProjectHistoryService {
|
|
|
328
416
|
}
|
|
329
417
|
}
|
|
330
418
|
};
|
|
331
|
-
// 1. Cap by historyMaxVersions (prune excess ordinary versions
|
|
332
|
-
if (ordinaryDesc.length > this.config.historyMaxVersions) {
|
|
419
|
+
// 1. Cap by historyMaxVersions (prune excess ordinary versions when configured; 0 = unlimited)
|
|
420
|
+
if (this.config.historyMaxVersions > 0 && ordinaryDesc.length > this.config.historyMaxVersions) {
|
|
333
421
|
const obsolete = ordinaryDesc.slice(this.config.historyMaxVersions);
|
|
334
422
|
for (const row of obsolete)
|
|
335
423
|
removeVersionRefs(row.id);
|
|
336
424
|
}
|
|
337
425
|
// 2. Cap by historyMaxStorageBytes (prune remaining eligible versions from oldest to newest)
|
|
338
|
-
if (
|
|
426
|
+
if (retainedBytes > this.config.historyMaxStorageBytes) {
|
|
339
427
|
const remainingEligibleAsc = ordinaryDesc
|
|
340
428
|
.filter((row) => row.id !== latestId && !toDeleteIds.has(row.id))
|
|
341
429
|
.reverse();
|
|
342
430
|
for (const row of remainingEligibleAsc) {
|
|
343
|
-
if (
|
|
431
|
+
if (retainedBytes <= this.config.historyMaxStorageBytes)
|
|
344
432
|
break;
|
|
345
433
|
removeVersionRefs(row.id);
|
|
346
434
|
}
|
|
@@ -456,6 +544,34 @@ function parseStringArray(value) {
|
|
|
456
544
|
return [];
|
|
457
545
|
}
|
|
458
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* Keep cursors opaque so the public API is free to change its physical
|
|
549
|
+
* ordering later. `rowid` breaks ties for snapshots written in the same
|
|
550
|
+
* millisecond, which is common for file operations touching multiple paths.
|
|
551
|
+
*/
|
|
552
|
+
function encodeHistoryCursor(row) {
|
|
553
|
+
return Buffer.from(JSON.stringify({ createdAt: row.created_at, rowId: row.history_rowid }), "utf8").toString("base64url");
|
|
554
|
+
}
|
|
555
|
+
function decodeHistoryCursor(value) {
|
|
556
|
+
// Cursors are generated by this service and normally only a few dozen
|
|
557
|
+
// characters long. Bound input before decoding to keep malformed query
|
|
558
|
+
// strings cheap to reject.
|
|
559
|
+
if (value.length === 0 || value.length > 256)
|
|
560
|
+
return null;
|
|
561
|
+
try {
|
|
562
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
563
|
+
const { createdAt, rowId } = parsed;
|
|
564
|
+
if (typeof createdAt !== "string" || typeof rowId !== "number" || !Number.isSafeInteger(rowId) || rowId <= 0)
|
|
565
|
+
return null;
|
|
566
|
+
const timestamp = Date.parse(createdAt);
|
|
567
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === createdAt
|
|
568
|
+
? { createdAt, rowId }
|
|
569
|
+
: null;
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
459
575
|
function changedManifestPaths(previous, next) {
|
|
460
576
|
const paths = new Set([...Object.keys(previous.files), ...Object.keys(next.files)]);
|
|
461
577
|
return [...paths].filter((filePath) => previous.files[filePath]?.digest !== next.files[filePath]?.digest).sort();
|
|
@@ -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
|
+
}
|
package/dist/server/i18n.js
CHANGED
|
@@ -48,6 +48,7 @@ const enMessages = {
|
|
|
48
48
|
HISTORY_FILE_PREVIEW_UNSUPPORTED: "This historical file cannot be compared as text.",
|
|
49
49
|
HISTORY_TARGET_CONFLICT: "The historical version cannot be restored to the requested path.",
|
|
50
50
|
HISTORY_VERSION_NOT_FOUND: "The history version does not exist.",
|
|
51
|
+
SELECTION_HISTORY_SOURCE_CHANGED: "The source changed while opening selection history. Wait for synchronization, then select the passage and open its history again.",
|
|
51
52
|
INVALID_PATH: "The file path is invalid.",
|
|
52
53
|
LAST_ADMIN: "At least one active administrator must remain.",
|
|
53
54
|
LATEXMKRC_DISABLED: "Project latexmkrc files are disabled by the administrator.",
|
|
@@ -59,6 +60,7 @@ const enMessages = {
|
|
|
59
60
|
MAIN_FILE_INVALID: "The main document must be an existing .tex file.",
|
|
60
61
|
MAIN_FILE_NOT_FOUND: "The selected main document does not exist.",
|
|
61
62
|
MEMBERS_MANAGE_FORBIDDEN: "Only the project owner can manage members.",
|
|
63
|
+
MENTION_NOT_FOUND: "The mention notification does not exist.",
|
|
62
64
|
MOVE_INTO_SELF: "A folder cannot be moved into itself.",
|
|
63
65
|
OWNER_MEMBER_FORBIDDEN: "The project owner cannot be added as a member.",
|
|
64
66
|
PASSWORD_TOO_SHORT: "The password must contain at least {{minLength}} characters.",
|
|
@@ -174,6 +176,7 @@ const zhMessages = {
|
|
|
174
176
|
HISTORY_FILE_PREVIEW_UNSUPPORTED: "该历史文件不能作为文本比较。",
|
|
175
177
|
HISTORY_TARGET_CONFLICT: "无法将历史版本恢复到所请求的路径。",
|
|
176
178
|
HISTORY_VERSION_NOT_FOUND: "历史版本不存在。",
|
|
179
|
+
SELECTION_HISTORY_SOURCE_CHANGED: "打开选区历史时源码已发生变化。请等待同步完成,重新选择文本后再打开历史。",
|
|
177
180
|
INVALID_PATH: "文件路径无效。",
|
|
178
181
|
LAST_ADMIN: "系统必须保留至少一名可用管理员。",
|
|
179
182
|
LATEXMKRC_DISABLED: "管理员已禁用项目级 latexmkrc。",
|
|
@@ -185,6 +188,7 @@ const zhMessages = {
|
|
|
185
188
|
MAIN_FILE_INVALID: "主文档必须是存在的 .tex 文件。",
|
|
186
189
|
MAIN_FILE_NOT_FOUND: "所选主文档不存在。",
|
|
187
190
|
MEMBERS_MANAGE_FORBIDDEN: "只有项目所有者可以管理成员。",
|
|
191
|
+
MENTION_NOT_FOUND: "@ 提醒不存在。",
|
|
188
192
|
MOVE_INTO_SELF: "不能把文件夹移动到自身内部。",
|
|
189
193
|
OWNER_MEMBER_FORBIDDEN: "项目所有者不能作为成员添加。",
|
|
190
194
|
PASSWORD_TOO_SHORT: "密码至少需要 {{minLength}} 个字符。",
|
package/dist/server/index.js
CHANGED
|
@@ -5,12 +5,16 @@ import { activeAdminCount, openDatabase } from "./db.js";
|
|
|
5
5
|
import { buildApp } from "./app.js";
|
|
6
6
|
import { assertEnvironment } from "./environment.js";
|
|
7
7
|
import { acquireDataDirectoryLock } from "./instanceLock.js";
|
|
8
|
+
import { LogRotation } from "./logRotation.js";
|
|
8
9
|
export async function startServer(configPath) {
|
|
9
10
|
const config = loadConfig(configPath);
|
|
10
11
|
const lock = acquireDataDirectoryLock(config);
|
|
11
12
|
let db = null;
|
|
12
13
|
let app = null;
|
|
14
|
+
const logs = process.env.TEXLITE_MANAGED_LOGS === "1"
|
|
15
|
+
? new LogRotation(config.dataDir, (error) => console.error("TexLite log rotation failed:", error)) : null;
|
|
13
16
|
try {
|
|
17
|
+
await logs?.start();
|
|
14
18
|
const environment = await assertEnvironment(config);
|
|
15
19
|
db = openDatabase(config);
|
|
16
20
|
if (activeAdminCount(db) === 0) {
|
|
@@ -34,6 +38,7 @@ export async function startServer(configPath) {
|
|
|
34
38
|
db?.close();
|
|
35
39
|
}
|
|
36
40
|
finally {
|
|
41
|
+
await logs?.stop();
|
|
37
42
|
lock.release();
|
|
38
43
|
}
|
|
39
44
|
}
|
|
@@ -50,6 +55,7 @@ export async function startServer(configPath) {
|
|
|
50
55
|
db?.close();
|
|
51
56
|
}
|
|
52
57
|
finally {
|
|
58
|
+
await logs?.stop();
|
|
53
59
|
lock.release();
|
|
54
60
|
}
|
|
55
61
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
export const LOG_CHECK_INTERVAL_MS = 60_000;
|
|
5
|
+
const LOG_BACKUPS = 3;
|
|
6
|
+
export function managedLogPaths(dataDir) {
|
|
7
|
+
const directory = path.resolve(dataDir, "logs");
|
|
8
|
+
return { stdout: path.join(directory, "stdout.log"), stderr: path.join(directory, "stderr.log") };
|
|
9
|
+
}
|
|
10
|
+
/** PM2 owns the open descriptors: copy/truncate keeps them valid across rotation. */
|
|
11
|
+
export class LogRotation {
|
|
12
|
+
dataDir;
|
|
13
|
+
onError;
|
|
14
|
+
maxBytes;
|
|
15
|
+
pending = null;
|
|
16
|
+
timer = null;
|
|
17
|
+
stopped = false;
|
|
18
|
+
constructor(dataDir, onError, maxBytes = LOG_MAX_BYTES) {
|
|
19
|
+
this.dataDir = dataDir;
|
|
20
|
+
this.onError = onError;
|
|
21
|
+
this.maxBytes = maxBytes;
|
|
22
|
+
}
|
|
23
|
+
async start() {
|
|
24
|
+
await this.check();
|
|
25
|
+
if (!this.stopped && !this.timer) {
|
|
26
|
+
this.timer = setInterval(() => { void this.check(); }, LOG_CHECK_INTERVAL_MS);
|
|
27
|
+
this.timer.unref();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
check() {
|
|
31
|
+
if (this.stopped)
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
if (this.pending)
|
|
34
|
+
return this.pending;
|
|
35
|
+
this.pending = this.rotateLogs().finally(() => { this.pending = null; });
|
|
36
|
+
return this.pending;
|
|
37
|
+
}
|
|
38
|
+
async stop() {
|
|
39
|
+
this.stopped = true;
|
|
40
|
+
if (this.timer)
|
|
41
|
+
clearInterval(this.timer);
|
|
42
|
+
this.timer = null;
|
|
43
|
+
await this.pending;
|
|
44
|
+
}
|
|
45
|
+
async rotateLogs() {
|
|
46
|
+
for (const file of Object.values(managedLogPaths(this.dataDir))) {
|
|
47
|
+
try {
|
|
48
|
+
await this.rotate(file);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
this.onError(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async rotate(file) {
|
|
56
|
+
const stat = await fs.lstat(file).catch((error) => {
|
|
57
|
+
if (error.code === "ENOENT")
|
|
58
|
+
return null;
|
|
59
|
+
throw error;
|
|
60
|
+
});
|
|
61
|
+
if (!stat?.isFile() || stat.size < this.maxBytes)
|
|
62
|
+
return;
|
|
63
|
+
const temporary = `${file}.rotate.tmp`;
|
|
64
|
+
try {
|
|
65
|
+
// Do not truncate until a complete backup has been successfully published.
|
|
66
|
+
await fs.copyFile(file, temporary);
|
|
67
|
+
await fs.rm(`${file}.${LOG_BACKUPS}`, { force: true });
|
|
68
|
+
for (let index = LOG_BACKUPS - 1; index >= 1; index--) {
|
|
69
|
+
await fs.rename(`${file}.${index}`, `${file}.${index + 1}`).catch((error) => {
|
|
70
|
+
if (error.code !== "ENOENT")
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
await fs.rename(temporary, `${file}.1`);
|
|
75
|
+
// Writes between the copy and truncate can be lost; these are diagnostic logs.
|
|
76
|
+
await fs.truncate(file, 0);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await fs.rm(temporary, { force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/server/pm2.js
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { managedLogPaths } from "./logRotation.js";
|
|
5
6
|
import { defaultConfigPath, packageRootDirectory, packageServerEntry } from "./runtimePaths.js";
|
|
6
7
|
let pm2Client = null;
|
|
7
8
|
async function loadPm2() {
|
|
@@ -64,12 +65,15 @@ export async function managedProcess(configPath) {
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
function startOptions(config) {
|
|
68
|
+
const logs = managedLogPaths(config.dataDir);
|
|
69
|
+
fs.mkdirSync(path.dirname(logs.stdout), { recursive: true, mode: 0o700 });
|
|
67
70
|
const packageRoot = packageRootDirectory();
|
|
68
71
|
const script = packageServerEntry();
|
|
69
72
|
const environment = {
|
|
70
73
|
...Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] === "string")),
|
|
71
74
|
NODE_ENV: "production",
|
|
72
75
|
TEXLITE_CONFIG: path.resolve(config.configPath),
|
|
76
|
+
TEXLITE_MANAGED_LOGS: "1",
|
|
73
77
|
TEXLITE_CLIENT_DIR: path.resolve(config.clientDir)
|
|
74
78
|
};
|
|
75
79
|
return {
|
|
@@ -78,6 +82,9 @@ function startOptions(config) {
|
|
|
78
82
|
cwd: packageRoot,
|
|
79
83
|
exec_mode: "fork",
|
|
80
84
|
instances: 1,
|
|
85
|
+
output: logs.stdout,
|
|
86
|
+
error: logs.stderr,
|
|
87
|
+
merge_logs: true,
|
|
81
88
|
autorestart: true,
|
|
82
89
|
watch: false,
|
|
83
90
|
wait_ready: true,
|