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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { safeRelativePath } from "./files.js";
|
|
3
|
+
const MAX_SEGMENTS_PER_PROJECT = 5_000;
|
|
4
|
+
const MAX_SEGMENTS_TO_SCAN = MAX_SEGMENTS_PER_PROJECT;
|
|
5
|
+
const DEFAULT_RESULT_LIMIT = 60;
|
|
6
|
+
const EDIT_SESSION_GAP_MS = 2 * 60 * 1_000;
|
|
7
|
+
const MAX_PASSAGE_PREVIEW_CHARS = 6_000;
|
|
8
|
+
export const DEFAULT_EDIT_HISTORY_MAX_STORAGE_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* Stores author-isolated collaboration edits independently from retained
|
|
11
|
+
* project snapshots. A segment is a short, contiguous run from one user and
|
|
12
|
+
* one file; its steps remain ordered so selection mapping remains exact.
|
|
13
|
+
*/
|
|
14
|
+
export class ProjectEditHistoryService {
|
|
15
|
+
db;
|
|
16
|
+
maxStorageBytes;
|
|
17
|
+
constructor(db, maxStorageBytes = DEFAULT_EDIT_HISTORY_MAX_STORAGE_BYTES) {
|
|
18
|
+
this.db = db;
|
|
19
|
+
this.maxStorageBytes = normalizeStorageLimit(maxStorageBytes);
|
|
20
|
+
}
|
|
21
|
+
record(projectId, inputs) {
|
|
22
|
+
const normalized = inputs
|
|
23
|
+
.map(normalizeSegment)
|
|
24
|
+
.filter((segment) => segment !== null);
|
|
25
|
+
if (!normalized.length)
|
|
26
|
+
return;
|
|
27
|
+
const segments = [];
|
|
28
|
+
const skipped = [];
|
|
29
|
+
for (const segment of normalized) {
|
|
30
|
+
const stepsJson = JSON.stringify(segment.steps);
|
|
31
|
+
const stepsBytes = Buffer.byteLength(stepsJson, "utf8");
|
|
32
|
+
// A single oversized burst would make the quota ineffective. Dropping it
|
|
33
|
+
// creates an explicit, safe history boundary rather than retaining a
|
|
34
|
+
// partial delta that could misattribute an older selection.
|
|
35
|
+
if (stepsBytes > this.maxStorageBytes)
|
|
36
|
+
skipped.push(segment);
|
|
37
|
+
else
|
|
38
|
+
segments.push({ segment, stepsJson, stepsBytes });
|
|
39
|
+
}
|
|
40
|
+
const insert = this.db.prepare(`INSERT INTO project_edit_segments
|
|
41
|
+
(id, project_id, file_path, author_id, kind, before_hash, after_hash, steps_json, created_at, updated_at, steps_bytes)
|
|
42
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
43
|
+
this.db.transaction(() => {
|
|
44
|
+
for (const { segment, stepsJson, stepsBytes } of segments) {
|
|
45
|
+
insert.run(randomUUID(), projectId, segment.filePath, segment.authorId, segment.kind, segment.beforeHash, segment.afterHash, stepsJson, segment.createdAt, segment.updatedAt, stepsBytes);
|
|
46
|
+
}
|
|
47
|
+
for (const segment of skipped)
|
|
48
|
+
this.recordRetentionBoundary(projectId, segment.filePath, segment.afterHash, segment.updatedAt);
|
|
49
|
+
this.prune(projectId);
|
|
50
|
+
})();
|
|
51
|
+
}
|
|
52
|
+
/** Logical edit payload; shared SQLite pages and indexes are not included. */
|
|
53
|
+
stats(projectId) {
|
|
54
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS segmentCount,
|
|
55
|
+
COALESCE(SUM(steps_bytes), 0) AS payloadBytes
|
|
56
|
+
FROM project_edit_segments WHERE project_id = ?`).get(projectId);
|
|
57
|
+
return { ...row, maxStorageBytes: this.maxStorageBytes };
|
|
58
|
+
}
|
|
59
|
+
clear(projectId) {
|
|
60
|
+
this.db.transaction(() => {
|
|
61
|
+
this.db.prepare("DELETE FROM project_edit_segments WHERE project_id = ?").run(projectId);
|
|
62
|
+
this.db.prepare("DELETE FROM project_edit_history_boundaries WHERE project_id = ?").run(projectId);
|
|
63
|
+
})();
|
|
64
|
+
}
|
|
65
|
+
/** Apply a changed storage setting to retained records during startup. */
|
|
66
|
+
enforceRetention(projectId) {
|
|
67
|
+
this.db.transaction(() => this.prune(projectId))();
|
|
68
|
+
}
|
|
69
|
+
/** Keep the newest contiguous suffix within both record and payload limits. */
|
|
70
|
+
prune(projectId) {
|
|
71
|
+
const rows = this.db.prepare(`SELECT id, file_path, after_hash, updated_at, steps_bytes
|
|
72
|
+
FROM project_edit_segments WHERE project_id = ?
|
|
73
|
+
ORDER BY updated_at DESC, rowid DESC`).all(projectId);
|
|
74
|
+
let retainedBytes = 0;
|
|
75
|
+
let retainedCount = 0;
|
|
76
|
+
let firstDiscard = rows.length;
|
|
77
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
78
|
+
const size = Math.max(0, Number(rows[index].steps_bytes) || 0);
|
|
79
|
+
if (retainedCount >= MAX_SEGMENTS_PER_PROJECT || retainedBytes + size > this.maxStorageBytes) {
|
|
80
|
+
firstDiscard = index;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
retainedBytes += size;
|
|
84
|
+
retainedCount += 1;
|
|
85
|
+
}
|
|
86
|
+
if (firstDiscard === rows.length)
|
|
87
|
+
return;
|
|
88
|
+
const remove = this.db.prepare("DELETE FROM project_edit_segments WHERE id = ?");
|
|
89
|
+
const boundaryFiles = new Set();
|
|
90
|
+
for (const row of rows.slice(firstDiscard)) {
|
|
91
|
+
// Rows are newest first. The first discarded row for a source file is
|
|
92
|
+
// precisely where a backward selection map must stop.
|
|
93
|
+
if (!boundaryFiles.has(row.file_path)) {
|
|
94
|
+
boundaryFiles.add(row.file_path);
|
|
95
|
+
this.recordRetentionBoundary(projectId, row.file_path, row.after_hash, row.updated_at);
|
|
96
|
+
}
|
|
97
|
+
remove.run(row.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
recordRetentionBoundary(projectId, filePath, afterHash, updatedAt) {
|
|
101
|
+
this.db.prepare(`INSERT INTO project_edit_history_boundaries (project_id, file_path, after_hash, updated_at)
|
|
102
|
+
VALUES (?, ?, ?, ?)
|
|
103
|
+
ON CONFLICT(project_id, file_path) DO UPDATE SET after_hash = excluded.after_hash, updated_at = excluded.updated_at`)
|
|
104
|
+
.run(projectId, filePath, afterHash, updatedAt);
|
|
105
|
+
}
|
|
106
|
+
selectionHistory(projectId, filePathInput, currentSource, startInput, endInput, resultLimit = DEFAULT_RESULT_LIMIT) {
|
|
107
|
+
const filePath = safeRelativePath(filePathInput);
|
|
108
|
+
const start = clamp(Math.min(startInput, endInput), 0, currentSource.length);
|
|
109
|
+
const end = clamp(Math.max(startInput, endInput), start, currentSource.length);
|
|
110
|
+
const rows = this.db.prepare(`SELECT segment.*, user.username AS author_username, user.display_name AS author_name
|
|
111
|
+
FROM project_edit_segments segment LEFT JOIN users user ON user.id = segment.author_id
|
|
112
|
+
WHERE segment.project_id = ? AND segment.file_path = ?
|
|
113
|
+
ORDER BY segment.updated_at DESC, segment.rowid DESC LIMIT ?`)
|
|
114
|
+
.all(projectId, filePath, MAX_SEGMENTS_TO_SCAN);
|
|
115
|
+
const entries = [];
|
|
116
|
+
let passage = {
|
|
117
|
+
range: { start, end },
|
|
118
|
+
text: currentSource.slice(start, end),
|
|
119
|
+
exact: true
|
|
120
|
+
};
|
|
121
|
+
let openEntry = null;
|
|
122
|
+
let expectedHash = hashText(currentSource);
|
|
123
|
+
let chainComplete = true;
|
|
124
|
+
let exhaustedRows = true;
|
|
125
|
+
let baseline = null;
|
|
126
|
+
const limit = clamp(resultLimit, 1, DEFAULT_RESULT_LIMIT);
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
// A direct file replacement, restoration, import, or retained-history
|
|
129
|
+
// pruning can intentionally create a gap. Never guess across it: doing
|
|
130
|
+
// so would attribute someone else's text to the wrong user.
|
|
131
|
+
if (row.after_hash !== expectedHash) {
|
|
132
|
+
chainComplete = false;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const steps = parseSteps(row.steps_json);
|
|
136
|
+
if (!steps.length) {
|
|
137
|
+
chainComplete = false;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
let relevant = false;
|
|
141
|
+
let valid = true;
|
|
142
|
+
const afterPassage = passage;
|
|
143
|
+
const afterHash = expectedHash;
|
|
144
|
+
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
145
|
+
const step = steps[index];
|
|
146
|
+
if (step.afterHash !== expectedHash) {
|
|
147
|
+
valid = false;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
const beforeRange = mapRangeBackward(passage.range, step.spans);
|
|
151
|
+
const relevantSpans = step.spans.filter((span) => spanIntersectsRange(span, passage.range, beforeRange));
|
|
152
|
+
if (relevantSpans.length)
|
|
153
|
+
relevant = true;
|
|
154
|
+
passage = undoPassage(passage, beforeRange, relevantSpans);
|
|
155
|
+
expectedHash = step.beforeHash;
|
|
156
|
+
}
|
|
157
|
+
if (!valid || expectedHash !== row.before_hash) {
|
|
158
|
+
passage = afterPassage;
|
|
159
|
+
expectedHash = afterHash;
|
|
160
|
+
chainComplete = false;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (relevant) {
|
|
164
|
+
if (openEntry && (canMergeIntoEntry(openEntry, row) || hasSameSelectionContent(openEntry, afterPassage))) {
|
|
165
|
+
mergeSelectionHistoryEntry(openEntry, row);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
if (openEntry) {
|
|
169
|
+
entries.push(stripEntryMetadata(openEntry));
|
|
170
|
+
if (entries.length >= limit) {
|
|
171
|
+
const preview = previewPassage(afterPassage.exact ? afterPassage.text : null);
|
|
172
|
+
baseline = { content: preview.text, contentTruncated: preview.truncated };
|
|
173
|
+
exhaustedRows = false;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
openEntry = createSelectionHistoryEntry(row, afterPassage);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (openEntry && entries.length < limit)
|
|
182
|
+
entries.push(stripEntryMetadata(openEntry));
|
|
183
|
+
if (chainComplete && exhaustedRows && this.reachedRetentionBoundary(projectId, filePath, expectedHash)) {
|
|
184
|
+
chainComplete = false;
|
|
185
|
+
}
|
|
186
|
+
if (exhaustedRows && entries.length) {
|
|
187
|
+
const preview = previewPassage(passage.exact ? passage.text : null);
|
|
188
|
+
baseline = { content: preview.text, contentTruncated: preview.truncated };
|
|
189
|
+
}
|
|
190
|
+
return { entries, baseline, hasMore: !exhaustedRows, chainComplete };
|
|
191
|
+
}
|
|
192
|
+
reachedRetentionBoundary(projectId, filePath, expectedHash) {
|
|
193
|
+
const row = this.db.prepare(`SELECT after_hash FROM project_edit_history_boundaries
|
|
194
|
+
WHERE project_id = ? AND file_path = ?`).get(projectId, filePath);
|
|
195
|
+
return row?.after_hash === expectedHash;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function createSelectionHistoryEntry(row, afterPassage) {
|
|
199
|
+
const content = previewPassage(afterPassage.exact ? afterPassage.text : null);
|
|
200
|
+
return {
|
|
201
|
+
id: row.id,
|
|
202
|
+
filePath: row.file_path,
|
|
203
|
+
createdAt: row.created_at,
|
|
204
|
+
updatedAt: row.updated_at,
|
|
205
|
+
authorKeys: new Set([authorKey(row)]),
|
|
206
|
+
authors: [authorFromRow(row)],
|
|
207
|
+
content: content.text,
|
|
208
|
+
contentTruncated: content.truncated
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function canMergeIntoEntry(entry, row) {
|
|
212
|
+
const latest = Date.parse(entry.updatedAt);
|
|
213
|
+
const current = Date.parse(row.updated_at);
|
|
214
|
+
return Number.isFinite(latest) && Number.isFinite(current)
|
|
215
|
+
&& latest >= current && latest - current <= EDIT_SESSION_GAP_MS;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* A history row can be relevant because it moved a selection boundary even
|
|
219
|
+
* when the reconstructed selected text did not change. Do not make users
|
|
220
|
+
* page through duplicate states: retain one block, but aggregate its time and
|
|
221
|
+
* contributors. Truncated or inexact text is deliberately never treated as
|
|
222
|
+
* equal.
|
|
223
|
+
*/
|
|
224
|
+
function hasSameSelectionContent(entry, passage) {
|
|
225
|
+
const candidate = previewPassage(passage.exact ? passage.text : null);
|
|
226
|
+
return entry.content !== null && !entry.contentTruncated
|
|
227
|
+
&& candidate.text !== null && !candidate.truncated
|
|
228
|
+
&& entry.content === candidate.text;
|
|
229
|
+
}
|
|
230
|
+
function mergeSelectionHistoryEntry(entry, row) {
|
|
231
|
+
entry.createdAt = row.created_at;
|
|
232
|
+
if (!entry.authorKeys.has(authorKey(row))) {
|
|
233
|
+
entry.authorKeys.add(authorKey(row));
|
|
234
|
+
entry.authors.push(authorFromRow(row));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function stripEntryMetadata(entry) {
|
|
238
|
+
const { authorKeys: _authorKeys, ...result } = entry;
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
function authorFromRow(row) {
|
|
242
|
+
return row.author_id && row.author_username && row.author_name
|
|
243
|
+
? { id: row.author_id, username: row.author_username, name: row.author_name }
|
|
244
|
+
: { id: null, username: null, name: null };
|
|
245
|
+
}
|
|
246
|
+
function authorKey(row) {
|
|
247
|
+
return row.author_id ?? "<deleted>";
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Reconstruct just the selected passage while walking a Yjs step backwards.
|
|
251
|
+
* We avoid rebuilding the full source tree and abandon the exact passage only
|
|
252
|
+
* when a deliberately truncated stored preview makes reconstruction unsafe.
|
|
253
|
+
*/
|
|
254
|
+
function undoPassage(current, beforeRange, spans) {
|
|
255
|
+
if (!spans.length)
|
|
256
|
+
return { ...current, range: beforeRange, exact: current.exact && current.text.length === beforeRange.end - beforeRange.start };
|
|
257
|
+
let text = current.text;
|
|
258
|
+
let exact = current.exact;
|
|
259
|
+
for (const span of [...spans].sort((left, right) => right.afterStart - left.afterStart || right.afterEnd - left.afterEnd)) {
|
|
260
|
+
if (span.truncated || span.deletedPreview.length !== span.deletedLength || span.insertedPreview.length !== span.insertedLength) {
|
|
261
|
+
exact = false;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (span.insertedLength === 0) {
|
|
265
|
+
const position = clamp(span.afterStart - current.range.start, 0, text.length);
|
|
266
|
+
text = `${text.slice(0, position)}${span.deletedPreview}${text.slice(position)}`;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const start = clamp(Math.max(span.afterStart, current.range.start) - current.range.start, 0, text.length);
|
|
270
|
+
const end = clamp(Math.min(span.afterEnd, current.range.end) - current.range.start, start, text.length);
|
|
271
|
+
text = `${text.slice(0, start)}${span.deletedPreview}${text.slice(end)}`;
|
|
272
|
+
}
|
|
273
|
+
if (exact && text.length !== beforeRange.end - beforeRange.start)
|
|
274
|
+
exact = false;
|
|
275
|
+
return { range: beforeRange, text, exact };
|
|
276
|
+
}
|
|
277
|
+
function previewPassage(value) {
|
|
278
|
+
if (value === null)
|
|
279
|
+
return { text: null, truncated: false };
|
|
280
|
+
if (value.length <= MAX_PASSAGE_PREVIEW_CHARS)
|
|
281
|
+
return { text: value, truncated: false };
|
|
282
|
+
const edge = Math.floor((MAX_PASSAGE_PREVIEW_CHARS - 1) / 2);
|
|
283
|
+
return { text: `${value.slice(0, edge)}…${value.slice(value.length - edge)}`, truncated: true };
|
|
284
|
+
}
|
|
285
|
+
export function hashText(content) {
|
|
286
|
+
return createHash("sha256").update(content).digest("hex");
|
|
287
|
+
}
|
|
288
|
+
function normalizeSegment(input) {
|
|
289
|
+
if (!input.authorId || !input.steps.length)
|
|
290
|
+
return null;
|
|
291
|
+
let filePath;
|
|
292
|
+
try {
|
|
293
|
+
filePath = safeRelativePath(input.filePath);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const steps = input.steps.map(normalizeStep).filter((step) => step !== null);
|
|
299
|
+
if (!steps.length)
|
|
300
|
+
return null;
|
|
301
|
+
const first = steps[0];
|
|
302
|
+
const last = steps.at(-1);
|
|
303
|
+
if (input.beforeHash !== first.beforeHash || input.afterHash !== last.afterHash)
|
|
304
|
+
return null;
|
|
305
|
+
for (let index = 1; index < steps.length; index += 1) {
|
|
306
|
+
if (steps[index - 1].afterHash !== steps[index].beforeHash)
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
filePath,
|
|
311
|
+
authorId: input.authorId,
|
|
312
|
+
kind: input.kind === "format" ? "format" : "edit",
|
|
313
|
+
beforeHash: input.beforeHash,
|
|
314
|
+
afterHash: input.afterHash,
|
|
315
|
+
createdAt: safeTimestamp(input.createdAt),
|
|
316
|
+
updatedAt: safeTimestamp(input.updatedAt),
|
|
317
|
+
steps
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function normalizeStep(step) {
|
|
321
|
+
if (!isHash(step.beforeHash) || !isHash(step.afterHash))
|
|
322
|
+
return null;
|
|
323
|
+
const spans = step.spans.map(normalizeSpan).filter((span) => span !== null);
|
|
324
|
+
return spans.length ? { beforeHash: step.beforeHash, afterHash: step.afterHash, createdAt: safeTimestamp(step.createdAt), spans } : null;
|
|
325
|
+
}
|
|
326
|
+
function normalizeSpan(span) {
|
|
327
|
+
const numeric = [span.beforeStart, span.beforeEnd, span.afterStart, span.afterEnd, span.deletedLength, span.insertedLength];
|
|
328
|
+
if (numeric.some((value) => !Number.isInteger(value) || value < 0)
|
|
329
|
+
|| span.beforeEnd < span.beforeStart || span.afterEnd < span.afterStart
|
|
330
|
+
|| span.deletedLength !== span.beforeEnd - span.beforeStart
|
|
331
|
+
|| span.insertedLength !== span.afterEnd - span.afterStart)
|
|
332
|
+
return null;
|
|
333
|
+
return {
|
|
334
|
+
beforeStart: span.beforeStart,
|
|
335
|
+
beforeEnd: span.beforeEnd,
|
|
336
|
+
afterStart: span.afterStart,
|
|
337
|
+
afterEnd: span.afterEnd,
|
|
338
|
+
deletedLength: span.deletedLength,
|
|
339
|
+
insertedLength: span.insertedLength,
|
|
340
|
+
deletedPreview: typeof span.deletedPreview === "string" ? span.deletedPreview.slice(0, 4_096) : "",
|
|
341
|
+
insertedPreview: typeof span.insertedPreview === "string" ? span.insertedPreview.slice(0, 4_096) : "",
|
|
342
|
+
truncated: Boolean(span.truncated)
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
function parseSteps(input) {
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(input);
|
|
348
|
+
if (!Array.isArray(parsed))
|
|
349
|
+
return [];
|
|
350
|
+
return parsed.map(normalizeStep).filter((step) => step !== null);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function mapRangeBackward(range, spans) {
|
|
357
|
+
return {
|
|
358
|
+
start: mapPositionBackward(range.start, spans, -1),
|
|
359
|
+
end: mapPositionBackward(range.end, spans, 1)
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/** Map a position from a step's post-edit document back to its pre-edit document. */
|
|
363
|
+
function mapPositionBackward(position, spans, association) {
|
|
364
|
+
let delta = 0;
|
|
365
|
+
for (const span of spans) {
|
|
366
|
+
// Nonempty replacements have unambiguous endpoints. Association only
|
|
367
|
+
// chooses a side inside a replacement or at a collapsed deletion.
|
|
368
|
+
if (span.insertedLength > 0 && position === span.afterStart)
|
|
369
|
+
return span.beforeStart;
|
|
370
|
+
if (span.insertedLength > 0 && position === span.afterEnd)
|
|
371
|
+
return span.beforeEnd;
|
|
372
|
+
if (position < span.afterStart || (position === span.afterStart && association < 0))
|
|
373
|
+
return position + delta;
|
|
374
|
+
if (position > span.afterEnd || (position === span.afterEnd && association > 0)) {
|
|
375
|
+
delta += span.deletedLength - span.insertedLength;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
return span.beforeStart + (association < 0 ? 0 : span.deletedLength);
|
|
379
|
+
}
|
|
380
|
+
return position + delta;
|
|
381
|
+
}
|
|
382
|
+
function spanIntersectsRange(span, after, before) {
|
|
383
|
+
if (rangesIntersect(after.start, after.end, span.afterStart, span.afterEnd))
|
|
384
|
+
return true;
|
|
385
|
+
// A deletion has no surviving post-edit range, so the mapped pre-edit
|
|
386
|
+
// selection is its only reliable signal. For replacements and insertions,
|
|
387
|
+
// checking the pre-edit range as well would falsely include a change that
|
|
388
|
+
// merely touches the selection's right boundary.
|
|
389
|
+
return span.insertedLength === 0 && rangesIntersect(before.start, before.end, span.beforeStart, span.beforeEnd);
|
|
390
|
+
}
|
|
391
|
+
function rangesIntersect(leftStart, leftEnd, rightStart, rightEnd) {
|
|
392
|
+
if (leftStart === leftEnd)
|
|
393
|
+
return leftStart >= rightStart && leftStart <= rightEnd;
|
|
394
|
+
if (rightStart === rightEnd)
|
|
395
|
+
return rightStart >= leftStart && rightStart <= leftEnd;
|
|
396
|
+
return leftStart < rightEnd && rightStart < leftEnd;
|
|
397
|
+
}
|
|
398
|
+
function safeTimestamp(value) {
|
|
399
|
+
return Number.isNaN(Date.parse(value)) ? new Date().toISOString() : value;
|
|
400
|
+
}
|
|
401
|
+
function isHash(value) {
|
|
402
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/i.test(value);
|
|
403
|
+
}
|
|
404
|
+
function normalizeStorageLimit(value) {
|
|
405
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
406
|
+
return DEFAULT_EDIT_HISTORY_MAX_STORAGE_BYTES;
|
|
407
|
+
return Math.floor(value);
|
|
408
|
+
}
|
|
409
|
+
function clamp(value, min, max) {
|
|
410
|
+
return Math.max(min, Math.min(max, Number.isFinite(value) ? Math.floor(value) : min));
|
|
411
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const RETRY_DELAY_MS = 5_000;
|
|
2
|
+
const MAX_RETRIES = 3;
|
|
3
|
+
const MAX_PENDING_PROJECT_BYTES = 4 * 1024 * 1024;
|
|
4
|
+
const MAX_PENDING_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
/** Preserve failed durable-source edits in order, without unbounded memory or retries. */
|
|
6
|
+
export class EditHistoryRetry {
|
|
7
|
+
record;
|
|
8
|
+
exists;
|
|
9
|
+
report;
|
|
10
|
+
pending = new Map();
|
|
11
|
+
timer;
|
|
12
|
+
constructor(record, exists, report) {
|
|
13
|
+
this.record = record;
|
|
14
|
+
this.exists = exists;
|
|
15
|
+
this.report = report;
|
|
16
|
+
}
|
|
17
|
+
save(id, edits) {
|
|
18
|
+
if (!edits.length)
|
|
19
|
+
return;
|
|
20
|
+
const old = this.pending.get(id);
|
|
21
|
+
if (!old) {
|
|
22
|
+
// The queue limits protect only a failed retry buffer. A valid large
|
|
23
|
+
// edit must always get its normal transactional write attempt first.
|
|
24
|
+
try {
|
|
25
|
+
this.record(id, edits);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
this.enqueue(id, [...edits], 0, error);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Preserve the exact order of a previously failed batch. Trying a new
|
|
33
|
+
// write before it has succeeded would be wasteful during an outage and
|
|
34
|
+
// makes the retry path harder to reason about.
|
|
35
|
+
this.enqueue(id, [...old.edits, ...edits], old.attempts);
|
|
36
|
+
}
|
|
37
|
+
clear(id) { this.pending.delete(id); }
|
|
38
|
+
dispose() { clearTimeout(this.timer); this.pending.clear(); }
|
|
39
|
+
enqueue(id, edits, attempts, error) {
|
|
40
|
+
let bytes;
|
|
41
|
+
try {
|
|
42
|
+
bytes = Buffer.byteLength(JSON.stringify(edits));
|
|
43
|
+
}
|
|
44
|
+
catch (serializationError) {
|
|
45
|
+
this.pending.delete(id);
|
|
46
|
+
this.report(id, "incomplete", serializationError);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const previousBytes = this.pending.get(id)?.bytes ?? 0;
|
|
50
|
+
const total = [...this.pending.values()].reduce((sum, item) => sum + item.bytes, 0) - previousBytes;
|
|
51
|
+
if (bytes > MAX_PENDING_PROJECT_BYTES || total + bytes > MAX_PENDING_BYTES) {
|
|
52
|
+
this.pending.delete(id);
|
|
53
|
+
this.report(id, "incomplete", new Error("Edit-history retry queue reached its memory limit"));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.pending.set(id, { edits, bytes, attempts });
|
|
57
|
+
this.report(id, "retrying", error);
|
|
58
|
+
this.schedule();
|
|
59
|
+
}
|
|
60
|
+
schedule() {
|
|
61
|
+
if (this.timer)
|
|
62
|
+
return;
|
|
63
|
+
this.timer = setTimeout(() => {
|
|
64
|
+
this.timer = undefined;
|
|
65
|
+
for (const [id, batch] of this.pending) {
|
|
66
|
+
if (!this.exists(id)) {
|
|
67
|
+
this.pending.delete(id);
|
|
68
|
+
this.report(id, "discarded");
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
this.record(id, batch.edits);
|
|
73
|
+
this.pending.delete(id);
|
|
74
|
+
this.report(id, "ok");
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (++batch.attempts >= MAX_RETRIES) {
|
|
78
|
+
this.pending.delete(id);
|
|
79
|
+
this.report(id, "incomplete", error);
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
this.report(id, "retrying", error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (this.pending.size)
|
|
86
|
+
this.schedule();
|
|
87
|
+
}, RETRY_DELAY_MS);
|
|
88
|
+
this.timer.unref?.();
|
|
89
|
+
}
|
|
90
|
+
}
|