texlite 0.8.4 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/DESIGN.md +75 -8
  2. package/OPERATIONS.md +19 -2
  3. package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-Dazar9cj.js} +1 -1
  4. package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-VSUiSPgG.js} +1 -1
  5. package/dist/client/assets/Dashboard-CoE5ESfb.js +2 -0
  6. package/dist/client/assets/GitDialog-C9Loz4Al.js +3 -0
  7. package/dist/client/assets/HistoryDialog-3UNN_jhU.js +7 -0
  8. package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-Bq76v8sO.js} +2 -2
  9. package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BKhcS-2c.js} +1 -1
  10. package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-BoWMjQVm.js} +1 -1
  11. package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-CZKTnxrl.js} +1 -1
  12. package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-DiOQVJ40.js} +1 -1
  13. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +27 -0
  14. package/dist/client/assets/SelectionHistoryDialog-D3dO83lg.js +3 -0
  15. package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-0zCwLtH_.js} +1 -1
  16. package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-C6KDwtvd.js} +1 -1
  17. package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-TwpyAP3m.js} +1 -1
  18. package/dist/client/assets/check-sZxOOKtS.js +1 -0
  19. package/dist/client/assets/chevron-left-32SrOwa5.js +1 -0
  20. package/dist/client/assets/hard-drive-XHSiS_A7.js +1 -0
  21. package/dist/client/assets/index-BRr3Kuxh.css +1 -0
  22. package/dist/client/assets/index-BjzELSQ-.js +12 -0
  23. package/dist/client/assets/index-Cd6-F29b.js +5 -0
  24. package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-C-CXCWnz.js} +1 -1
  25. package/dist/client/assets/minimize-2-BMDPVaF-.js +1 -0
  26. package/dist/client/assets/{minus-Beso_n-6.js → minus-BIa7-KuM.js} +1 -1
  27. package/dist/client/assets/{plus-BfEC7mzf.js → plus-BKDWy4Ui.js} +1 -1
  28. package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Bo7-a7-I.js} +1 -1
  29. package/dist/client/assets/{search-DL2UGvdj.js → search-Bz9Ob5mT.js} +1 -1
  30. package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-DxlHf9gv.js} +1 -1
  31. package/dist/client/assets/spellCheck-BfZEnpwY.js +1 -0
  32. package/dist/client/assets/{tags-CjkWzXqX.js → tags-IMZehjzE.js} +1 -1
  33. package/dist/client/assets/{type-D1mmDdLM.js → type-DTWpFQNQ.js} +1 -1
  34. package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus--aVpd8K6.js} +1 -1
  35. package/dist/client/assets/x-Tbo3aA0J.js +1 -0
  36. package/dist/client/index.html +2 -2
  37. package/dist/server/app.js +45 -4
  38. package/dist/server/cli.js +4 -0
  39. package/dist/server/collaboration.js +219 -7
  40. package/dist/server/commentMentions.js +139 -0
  41. package/dist/server/config.js +9 -2
  42. package/dist/server/db.js +59 -0
  43. package/dist/server/editHistory.js +411 -0
  44. package/dist/server/editHistoryRetry.js +90 -0
  45. package/dist/server/harper.js +205 -8
  46. package/dist/server/history.js +189 -23
  47. package/dist/server/historyRetention.js +37 -0
  48. package/dist/server/i18n.js +12 -0
  49. package/dist/server/index.js +6 -0
  50. package/dist/server/latexSpellMask.js +197 -43
  51. package/dist/server/logRotation.js +82 -0
  52. package/dist/server/pm2.js +7 -0
  53. package/dist/server/routes/comments.js +120 -17
  54. package/dist/server/routes/projectHistory.js +106 -8
  55. package/dist/server/routes/projectShared.js +2 -1
  56. package/dist/server/routes/projects.js +39 -5
  57. package/package.json +1 -1
  58. package/texlite.config.example.json +4 -1
  59. package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
  60. package/dist/client/assets/GitDialog-BCfSHuMB.js +0 -3
  61. package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
  62. package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
  63. package/dist/client/assets/check-bnimQPun.js +0 -1
  64. package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
  65. package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
  66. package/dist/client/assets/index-CzVDvC5h.js +0 -14
  67. package/dist/client/assets/index-RjUuWk8F.css +0 -1
  68. package/dist/client/assets/spellCheck-L9gD2d1M.js +0 -1
  69. package/dist/client/assets/x-BDSEosRE.js +0 -1
@@ -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
+ }