@erdemtuna/doc-review 0.9.0 → 0.11.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.
Files changed (103) hide show
  1. package/README.md +58 -222
  2. package/{src → lib}/SKILL.md +5 -2
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/changes-controller.js +184 -0
  6. package/lib/chrome-api.js +78 -0
  7. package/lib/chrome-client.js +1700 -0
  8. package/lib/chrome-session.js +73 -0
  9. package/lib/chrome.html +53 -0
  10. package/lib/cli.js +255 -0
  11. package/lib/click-target.js +28 -0
  12. package/lib/comment-anchor.js +23 -0
  13. package/lib/comment-target.js +164 -0
  14. package/lib/comments-controller.js +109 -0
  15. package/lib/contextual-controller.js +33 -0
  16. package/lib/contracts/feedback.js +1 -0
  17. package/lib/contracts/frame.js +7 -0
  18. package/lib/contracts/history.js +1 -0
  19. package/lib/contracts/index.js +2 -0
  20. package/lib/contracts/page.js +23 -0
  21. package/lib/controller-store.js +96 -0
  22. package/lib/document-execution.js +115 -0
  23. package/lib/edit-limits.js +24 -0
  24. package/lib/editing.js +107 -0
  25. package/lib/execution-client.js +19 -0
  26. package/lib/feedback-controller.js +113 -0
  27. package/lib/feedback-panel-controller.js +161 -0
  28. package/lib/frame-channel.js +42 -0
  29. package/lib/frame-controller.js +313 -0
  30. package/lib/frame-host.js +136 -0
  31. package/lib/frame-policy.js +50 -0
  32. package/lib/history-client.js +112 -0
  33. package/lib/history-coordinator.js +217 -0
  34. package/lib/history-policy.js +43 -0
  35. package/lib/history-server.js +464 -0
  36. package/lib/html-transform.js +52 -0
  37. package/lib/icons.js +325 -0
  38. package/{src → lib}/markdown.js +25 -26
  39. package/lib/paths.js +83 -0
  40. package/lib/poll-transport.js +220 -0
  41. package/lib/positioning.js +97 -0
  42. package/lib/recovery-controller.js +123 -0
  43. package/lib/review-controller.js +93 -0
  44. package/lib/review-mode.js +66 -0
  45. package/lib/revision-diff.js +482 -0
  46. package/lib/revision-schema.js +235 -0
  47. package/lib/revision-store.js +190 -0
  48. package/lib/save-controller.js +303 -0
  49. package/lib/sdk.js +2629 -0
  50. package/lib/semantic-snapshot.js +268 -0
  51. package/lib/serialize.js +32 -0
  52. package/lib/server-entry.js +26 -0
  53. package/lib/server-lock.js +109 -0
  54. package/lib/server.js +1501 -0
  55. package/lib/setup-guidance.js +119 -0
  56. package/{src → lib}/setup.js +44 -55
  57. package/lib/state.js +733 -0
  58. package/lib/toolbar-controller.js +32 -0
  59. package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
  60. package/lib/ui/chrome.css +4881 -0
  61. package/lib/ui/chrome.js +52 -0
  62. package/lib/view-identity.js +112 -0
  63. package/package.json +50 -14
  64. package/src/anchor-text.js +0 -160
  65. package/src/atomic-write.js +0 -53
  66. package/src/chrome-client.js +0 -2747
  67. package/src/chrome-session.js +0 -85
  68. package/src/chrome.css +0 -652
  69. package/src/chrome.html +0 -196
  70. package/src/cli.js +0 -271
  71. package/src/click-target.js +0 -26
  72. package/src/comment-anchor.js +0 -22
  73. package/src/comment-target.js +0 -164
  74. package/src/comparison-view.js +0 -337
  75. package/src/document-execution.js +0 -103
  76. package/src/edit-limits.js +0 -23
  77. package/src/editing.js +0 -99
  78. package/src/execution-client.js +0 -63
  79. package/src/frame-channel.js +0 -44
  80. package/src/frame-policy.js +0 -54
  81. package/src/history-client.js +0 -104
  82. package/src/history-coordinator.js +0 -186
  83. package/src/history-policy.js +0 -43
  84. package/src/history-server.js +0 -463
  85. package/src/html-transform.js +0 -62
  86. package/src/icons.js +0 -251
  87. package/src/paths.js +0 -91
  88. package/src/poll-transport.js +0 -222
  89. package/src/positioning.js +0 -107
  90. package/src/review-mode.js +0 -59
  91. package/src/revision-diff.js +0 -440
  92. package/src/revision-schema.js +0 -219
  93. package/src/revision-store.js +0 -177
  94. package/src/sdk.js +0 -2607
  95. package/src/semantic-snapshot.js +0 -230
  96. package/src/serialize.js +0 -32
  97. package/src/server-entry.js +0 -26
  98. package/src/server-lock.js +0 -101
  99. package/src/server.js +0 -1466
  100. package/src/setup-guidance.js +0 -128
  101. package/src/state.js +0 -732
  102. package/src/view-identity.js +0 -99
  103. /package/{src → lib}/document-trust.js +0 -0
@@ -0,0 +1,235 @@
1
+ export const REVISION_SCHEMA_VERSION = 1;
2
+ export const HISTORY_SCHEMA_VERSION = 1;
3
+ export const COMPLETED_ROUNDS_TO_KEEP = 5;
4
+ export const CAPTURE_LEASE_MS = 60_000;
5
+ export const ROUND_FEEDBACK_STATUSES = Object.freeze(["queued", "delivered", "acknowledged", "superseded"]);
6
+ export const ROUND_CAPTURE_STATUSES = Object.freeze(["pending", "ready", "partial", "failed", "cancelled"]);
7
+ export const REVISION_LIMITS = Object.freeze({
8
+ sourceBytes: 8 * 1024 * 1024,
9
+ semanticBytes: 4 * 1024 * 1024,
10
+ totalBytes: 12 * 1024 * 1024,
11
+ blocks: 2_000,
12
+ textCharacters: 100_000,
13
+ totalTextCharacters: 1_000_000,
14
+ runsPerBlock: 1_000,
15
+ depth: 64,
16
+ attributeCharacters: 4_096,
17
+ selectorCharacters: 4_096,
18
+ });
19
+ const encoder = new TextEncoder();
20
+ const TAGS = new Set([
21
+ "article", "section", "main", "header", "footer", "aside", "nav", "div",
22
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "blockquote", "pre", "code",
23
+ "ul", "ol", "li", "dl", "dt", "dd", "table", "caption", "thead", "tbody",
24
+ "tfoot", "tr", "th", "td", "figure", "figcaption", "img", "a", "span",
25
+ "strong", "em", "b", "i", "u", "s", "del", "ins", "sub", "sup", "br", "hr",
26
+ "details", "summary", "body", "address", "button", "dialog", "fieldset",
27
+ ]);
28
+ const ATTRIBUTES = new Set([
29
+ "id", "href", "src", "alt", "title", "start", "reversed", "value",
30
+ "colspan", "rowspan", "scope", "headers", "type",
31
+ ]);
32
+ const MARKS = new Set([
33
+ "strong", "em", "underline", "strike", "delete", "insert",
34
+ "code", "kbd", "samp", "sub", "sup", "mark",
35
+ ]);
36
+ export function revisionError(message, code = "INVALID_REVISION") {
37
+ const status = code === "SNAPSHOT_TOO_LARGE" ? 413
38
+ : code === "CAPTURE_CONFLICT" || code === "CAPTURE_FINALIZED" ? 409
39
+ : code === "SNAPSHOT_CORRUPT" ? 500 : 400;
40
+ return Object.assign(new Error(message), { code, status });
41
+ }
42
+ export function utf8Bytes(value) {
43
+ return encoder.encode(value).byteLength;
44
+ }
45
+ export function normalizeRevisionLimits(value = {}) {
46
+ object(value, "snapshot limits");
47
+ const limits = { ...REVISION_LIMITS };
48
+ for (const [key, limit] of Object.entries(value)) {
49
+ if (!(key in limits) || !Number.isSafeInteger(limit) || limit < 0)
50
+ throw revisionError("Invalid snapshot limit.");
51
+ limits[key] = limit;
52
+ }
53
+ return limits;
54
+ }
55
+ function object(value, name) {
56
+ if (!value || typeof value !== "object" || Array.isArray(value))
57
+ throw revisionError(`Invalid ${name}.`);
58
+ }
59
+ function boundedString(value, name, limit, { empty = true } = {}) {
60
+ if (typeof value !== "string" || (!empty && !value)) {
61
+ throw revisionError(`Invalid ${name}.`);
62
+ }
63
+ if (value.length > limit)
64
+ throw revisionError(`${name} exceeds the size limit.`, "SNAPSHOT_TOO_LARGE");
65
+ return value;
66
+ }
67
+ function referenceUrl(value) {
68
+ if (/^(?:javascript|vbscript|data|blob):/i.test(value.replace(/[\u0000-\u0020\u007f]+/g, ""))) {
69
+ throw revisionError("Executable or embedded URLs cannot be captured.");
70
+ }
71
+ try {
72
+ const url = new URL(value, "https://doc-review.invalid/");
73
+ if (url.username || url.password)
74
+ throw revisionError("URL credentials cannot be captured.");
75
+ }
76
+ catch (error) {
77
+ if (error.code === "INVALID_REVISION")
78
+ throw error;
79
+ throw revisionError("Invalid semantic URL.");
80
+ }
81
+ return value;
82
+ }
83
+ export function isRevisionId(value) {
84
+ return typeof value === "string" && /^v_[a-f0-9]{64}$/.test(value);
85
+ }
86
+ export function normalizeSemanticSnapshot(value, limits = REVISION_LIMITS) {
87
+ limits = normalizeRevisionLimits(limits);
88
+ object(value, "semantic snapshot");
89
+ if (value.version !== REVISION_SCHEMA_VERSION || !Array.isArray(value.blocks)) {
90
+ throw revisionError("Unsupported semantic snapshot schema.");
91
+ }
92
+ if (value.blocks.length > limits.blocks)
93
+ throw revisionError("Semantic snapshot has too many blocks.", "SNAPSHOT_TOO_LARGE");
94
+ const ids = new Set();
95
+ let totalText = 0;
96
+ const blocks = value.blocks.map((block) => {
97
+ object(block, "semantic block");
98
+ if ("html" in block || "children" in block)
99
+ throw revisionError("Semantic blocks must be flat and cannot contain HTML.");
100
+ const id = boundedString(block.id, "block id", 200, { empty: false });
101
+ if (ids.has(id))
102
+ throw revisionError("Duplicate semantic block id.");
103
+ ids.add(id);
104
+ if (!TAGS.has(block.tag))
105
+ throw revisionError("Unsupported semantic block tag.");
106
+ if (block.path !== undefined && typeof block.path !== "string" && !Array.isArray(block.path)) {
107
+ throw revisionError("Invalid block path.");
108
+ }
109
+ const normalized = {
110
+ id,
111
+ tag: block.tag,
112
+ text: boundedString(block.text, "block text", limits.textCharacters),
113
+ path: Array.isArray(block.path)
114
+ ? block.path.map((part) => boundedString(part, "block path entry", limits.selectorCharacters))
115
+ : block.path ? [boundedString(block.path, "block path", limits.selectorCharacters)] : [],
116
+ selector: boundedString(block.selector ?? "", "block selector", limits.selectorCharacters),
117
+ attributes: {},
118
+ runs: [],
119
+ };
120
+ if (Array.isArray(normalized.path) && normalized.path.length > limits.depth) {
121
+ throw revisionError("Semantic path exceeds the depth limit.", "SNAPSHOT_TOO_LARGE");
122
+ }
123
+ totalText += normalized.text.length;
124
+ if (totalText > limits.totalTextCharacters)
125
+ throw revisionError("Semantic text exceeds the size limit.", "SNAPSHOT_TOO_LARGE");
126
+ if (block.parentId !== undefined && block.parentId !== null) {
127
+ normalized.parentId = boundedString(block.parentId, "parent id", 200, { empty: false });
128
+ if (normalized.parentId === id || !ids.has(normalized.parentId)) {
129
+ throw revisionError("Semantic parents must precede their children.");
130
+ }
131
+ }
132
+ if (block.attrs !== undefined && block.attributes !== undefined)
133
+ throw revisionError("Ambiguous semantic attributes.");
134
+ const attributes = block.attributes !== undefined ? block.attributes : block.attrs;
135
+ if (attributes !== undefined) {
136
+ object(attributes, "block attributes");
137
+ normalized.attributes = {};
138
+ for (const name of Object.keys(attributes).sort()) {
139
+ if (!ATTRIBUTES.has(name) || (name === "value" && block.tag !== "li") ||
140
+ (name === "type" && block.tag !== "button"))
141
+ throw revisionError("Unsupported semantic attribute.");
142
+ const attribute = boundedString(attributes[name], "semantic attribute", limits.attributeCharacters);
143
+ normalized.attributes[name] = name === "src" || name === "href" ? referenceUrl(attribute) : attribute;
144
+ }
145
+ }
146
+ if (block.runs !== undefined) {
147
+ if (!Array.isArray(block.runs))
148
+ throw revisionError("Invalid inline runs.");
149
+ if (block.runs.length > limits.runsPerBlock)
150
+ throw revisionError("Too many inline runs.", "SNAPSHOT_TOO_LARGE");
151
+ let runText = 0;
152
+ normalized.runs = block.runs.map((run) => {
153
+ object(run, "inline run");
154
+ if ("html" in run || !Array.isArray(run.marks) || run.marks.length > MARKS.size ||
155
+ run.marks.some((mark) => !MARKS.has(mark)))
156
+ throw revisionError("Invalid inline formatting.");
157
+ const text = boundedString(run.text, "inline text", limits.textCharacters);
158
+ runText += text.length;
159
+ if (runText > limits.textCharacters)
160
+ throw revisionError("Inline text exceeds the size limit.", "SNAPSHOT_TOO_LARGE");
161
+ return {
162
+ text,
163
+ marks: [...new Set(run.marks)].sort(),
164
+ ...(run.href !== undefined ? { href: referenceUrl(boundedString(run.href, "inline link", limits.attributeCharacters)) } : {}),
165
+ };
166
+ });
167
+ if (normalized.runs.map((run) => run.text).join("") !== normalized.text) {
168
+ throw revisionError("Inline runs do not match the block text.");
169
+ }
170
+ }
171
+ else if (normalized.text)
172
+ normalized.runs = [{ text: normalized.text, marks: [] }];
173
+ return normalized;
174
+ });
175
+ const snapshot = { version: REVISION_SCHEMA_VERSION, blocks, limitations: [] };
176
+ if (value.limitations !== undefined) {
177
+ if (!Array.isArray(value.limitations) || value.limitations.length > 100)
178
+ throw revisionError("Invalid semantic limitations.");
179
+ snapshot.limitations = [...new Set(value.limitations.map((code) => boundedString(code, "limitation", 500)))];
180
+ }
181
+ if (utf8Bytes(JSON.stringify(snapshot)) > limits.semanticBytes) {
182
+ throw revisionError("Semantic snapshot exceeds the size limit.", "SNAPSHOT_TOO_LARGE");
183
+ }
184
+ return snapshot;
185
+ }
186
+ export function normalizeCaptureProvenance(value = {}) {
187
+ object(value, "capture provenance");
188
+ const result = {};
189
+ for (const name of ["sessionId", "pageKey", "sourceHash", "sourceRevisionId", "captureId"]) {
190
+ if (value[name] !== undefined)
191
+ result[name] = boundedString(value[name], name, 200);
192
+ }
193
+ if (value.generation !== undefined) {
194
+ if (!Number.isSafeInteger(value.generation) || value.generation < 0)
195
+ throw revisionError("Invalid capture generation.");
196
+ result.generation = value.generation;
197
+ }
198
+ if (value.feedbackOnlyEdits !== undefined)
199
+ result.feedbackOnlyEdits = value.feedbackOnlyEdits === true;
200
+ if (value.trustedInteractive !== undefined) {
201
+ if (typeof value.trustedInteractive !== "boolean")
202
+ throw revisionError("Invalid trusted capture provenance.");
203
+ result.trustedInteractive = value.trustedInteractive;
204
+ }
205
+ return result;
206
+ }
207
+ export function normalizeHistoryTargets(targets) {
208
+ if (!Array.isArray(targets) || !targets.length || targets.length > 100)
209
+ throw revisionError("Invalid history targets.");
210
+ const keys = new Set();
211
+ return targets.map((target) => {
212
+ object(target, "history target");
213
+ const key = boundedString(target.key, "document key", 200, { empty: false });
214
+ if (keys.has(key))
215
+ throw revisionError("Duplicate history target.");
216
+ keys.add(key);
217
+ if (target.baselineRevisionId !== undefined && !isRevisionId(target.baselineRevisionId)) {
218
+ throw revisionError("Invalid baseline revision ID.");
219
+ }
220
+ if (!target.baselineRevisionId && !target.baselineUnavailable) {
221
+ throw revisionError("A target needs a baseline or an explicit unavailable reason.");
222
+ }
223
+ if (target.baselineRevisionId && target.baselineUnavailable)
224
+ throw revisionError("Baseline availability is ambiguous.");
225
+ return {
226
+ key,
227
+ ...(target.ownerSessionId !== undefined ? {
228
+ ownerSessionId: boundedString(target.ownerSessionId, "baseline owner session", 200, { empty: false }),
229
+ } : {}),
230
+ ...(target.baselineRevisionId ? { baselineRevisionId: target.baselineRevisionId } : {
231
+ baselineUnavailable: boundedString(target.baselineUnavailable, "baseline unavailable reason", 500, { empty: false }),
232
+ }),
233
+ };
234
+ });
235
+ }
@@ -0,0 +1,190 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { atomicWrite } from "./atomic-write.js";
5
+ import { stateDir } from "./paths.js";
6
+ import { normalizeView } from "./view-identity.js";
7
+ import { REVISION_SCHEMA_VERSION, REVISION_LIMITS, isRevisionId, normalizeCaptureProvenance, normalizeSemanticSnapshot, normalizeRevisionLimits, revisionError, utf8Bytes, } from "./revision-schema.js";
8
+ const digest = (text) => crypto.createHash("sha256").update(text).digest("hex");
9
+ const BLOB_ID = /^[a-f0-9]{64}$/;
10
+ function captureTime(value, now) {
11
+ const time = value ?? now;
12
+ if (!Number.isSafeInteger(time) || time < 0)
13
+ throw revisionError("Invalid capture timestamp.");
14
+ return time;
15
+ }
16
+ export class RevisionStore {
17
+ constructor({ root = path.join(stateDir(), "history"), write = atomicWrite, limits = REVISION_LIMITS } = {}) {
18
+ this.root = root;
19
+ this.write = write;
20
+ this.limits = normalizeRevisionLimits(limits);
21
+ }
22
+ blobPath(id) {
23
+ if (!BLOB_ID.test(id))
24
+ throw revisionError("Invalid blob ID.");
25
+ return path.join(this.root, "blobs", `${id}.json`);
26
+ }
27
+ manifestPath(id) {
28
+ if (!isRevisionId(id))
29
+ throw revisionError("Invalid revision ID.");
30
+ return path.join(this.root, "revisions", `${id}.json`);
31
+ }
32
+ put({ documentId, source, semantic, reason = "observation", limitations = [] }) {
33
+ if (typeof documentId !== "string" || !documentId || documentId.length > 200)
34
+ throw revisionError("Invalid document ID.");
35
+ if (typeof reason !== "string" || reason.length > 100)
36
+ throw revisionError("Invalid capture reason.");
37
+ if (!Array.isArray(limitations) || limitations.length > 100 || limitations.some((item) => typeof item !== "string" || item.length > 500)) {
38
+ throw revisionError("Invalid capture limitations.");
39
+ }
40
+ if (!source && !semantic)
41
+ throw revisionError("A revision must contain source or semantic content.");
42
+ const now = Date.now();
43
+ const manifest = { version: REVISION_SCHEMA_VERSION, documentId, reason, limitations: [...limitations] };
44
+ const blobs = [];
45
+ let size = 0;
46
+ if (source) {
47
+ if (typeof source.text !== "string")
48
+ throw revisionError("Invalid source snapshot.");
49
+ const bytes = utf8Bytes(source.text);
50
+ if (bytes > this.limits.sourceBytes)
51
+ throw revisionError("Source snapshot exceeds the size limit.", "SNAPSHOT_TOO_LARGE");
52
+ const mediaType = source.mediaType ?? "text/plain";
53
+ if (typeof mediaType !== "string" || mediaType.length > 100)
54
+ throw revisionError("Invalid source media type.");
55
+ const serialized = JSON.stringify({ text: source.text });
56
+ const blobId = digest(serialized);
57
+ manifest.source = {
58
+ blobId, hash: digest(source.text), bytes, mediaType,
59
+ capturedAt: captureTime(source.capturedAt, now),
60
+ provenance: normalizeCaptureProvenance(source.provenance),
61
+ };
62
+ blobs.push([blobId, serialized]);
63
+ size += bytes;
64
+ }
65
+ if (semantic) {
66
+ const snapshot = normalizeSemanticSnapshot(semantic.snapshot, this.limits);
67
+ const serialized = JSON.stringify(snapshot);
68
+ const bytes = utf8Bytes(serialized);
69
+ const blobId = digest(serialized);
70
+ manifest.semantic = {
71
+ blobId, hash: blobId, bytes, schemaVersion: REVISION_SCHEMA_VERSION,
72
+ capturedAt: captureTime(semantic.capturedAt, now),
73
+ provenance: normalizeCaptureProvenance(semantic.provenance),
74
+ ...(semantic.view !== undefined ? { view: normalizeView(semantic.view) } : {}),
75
+ };
76
+ blobs.push([blobId, serialized]);
77
+ size += bytes;
78
+ }
79
+ if (size > this.limits.totalBytes)
80
+ throw revisionError("Combined snapshot exceeds the size limit.", "SNAPSHOT_TOO_LARGE");
81
+ const serialized = JSON.stringify(manifest);
82
+ const revisionId = `v_${digest(serialized)}`;
83
+ for (const dir of ["blobs", "revisions"])
84
+ fs.mkdirSync(path.join(this.root, dir), { recursive: true, mode: 0o700 });
85
+ for (const [blobId, content] of blobs)
86
+ this.writeImmutable(this.blobPath(blobId), content);
87
+ this.writeImmutable(this.manifestPath(revisionId), serialized);
88
+ return { revisionId, ...manifest };
89
+ }
90
+ writeImmutable(file, content) {
91
+ try {
92
+ const existing = fs.readFileSync(file, "utf8");
93
+ if (existing !== content)
94
+ throw revisionError("Immutable snapshot content disagrees with its ID.", "SNAPSHOT_CORRUPT");
95
+ }
96
+ catch (err) {
97
+ if (err.code !== "ENOENT")
98
+ throw err;
99
+ this.write(file, content);
100
+ }
101
+ }
102
+ get(revisionId) {
103
+ let raw;
104
+ try {
105
+ raw = fs.readFileSync(this.manifestPath(revisionId), "utf8");
106
+ }
107
+ catch (err) {
108
+ if (err.code === "ENOENT")
109
+ return null;
110
+ throw err;
111
+ }
112
+ if (`v_${digest(raw)}` !== revisionId)
113
+ throw revisionError("Revision manifest is corrupt.", "SNAPSHOT_CORRUPT");
114
+ const manifest = JSON.parse(raw);
115
+ if (manifest.version !== REVISION_SCHEMA_VERSION)
116
+ throw revisionError("Unsupported stored revision schema.");
117
+ return { revisionId, ...manifest };
118
+ }
119
+ readBlob(blobId) {
120
+ const raw = fs.readFileSync(this.blobPath(blobId), "utf8");
121
+ if (digest(raw) !== blobId)
122
+ throw revisionError("Revision blob is corrupt.", "SNAPSHOT_CORRUPT");
123
+ return JSON.parse(raw);
124
+ }
125
+ readSource(revisionId) {
126
+ const manifest = this.get(revisionId);
127
+ return manifest?.source ? this.readBlob(manifest.source.blobId).text : null;
128
+ }
129
+ readSemantic(revisionId) {
130
+ const manifest = this.get(revisionId);
131
+ return manifest?.semantic ? this.readBlob(manifest.semantic.blobId) : null;
132
+ }
133
+ read(revisionId) {
134
+ const manifest = this.get(revisionId);
135
+ if (!manifest)
136
+ return null;
137
+ return {
138
+ manifest,
139
+ source: manifest.source ? this.readBlob(manifest.source.blobId).text : null,
140
+ semantic: manifest.semantic ? this.readBlob(manifest.semantic.blobId) : null,
141
+ };
142
+ }
143
+ verify(revisionId, documentId) {
144
+ const manifest = this.get(revisionId);
145
+ if (!manifest || manifest.documentId !== documentId)
146
+ throw revisionError("Revision does not belong to this document.");
147
+ if (manifest.source)
148
+ this.readBlob(manifest.source.blobId);
149
+ if (manifest.semantic)
150
+ this.readBlob(manifest.semantic.blobId);
151
+ return manifest;
152
+ }
153
+ // A grace period protects blobs written before their state transaction commits.
154
+ collectGarbage(referencedIds, { olderThan = Date.now() - 60 * 60 * 1000 } = {}) {
155
+ const keep = new Set(referencedIds);
156
+ const blobs = new Set();
157
+ const manifestsDir = path.join(this.root, "revisions");
158
+ const blobsDir = path.join(this.root, "blobs");
159
+ const removed = { revisions: 0, blobs: 0 };
160
+ const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir) : [];
161
+ for (const name of files(manifestsDir)) {
162
+ const id = name.replace(/\.json$/, "");
163
+ if (!isRevisionId(id))
164
+ continue;
165
+ const file = this.manifestPath(id);
166
+ if (!keep.has(id) && fs.statSync(file).mtimeMs < olderThan) {
167
+ fs.unlinkSync(file);
168
+ removed.revisions += 1;
169
+ }
170
+ else {
171
+ const manifest = this.get(id);
172
+ if (manifest.source)
173
+ blobs.add(manifest.source.blobId);
174
+ if (manifest.semantic)
175
+ blobs.add(manifest.semantic.blobId);
176
+ }
177
+ }
178
+ for (const name of files(blobsDir)) {
179
+ const id = name.replace(/\.json$/, "");
180
+ if (!BLOB_ID.test(id) || blobs.has(id))
181
+ continue;
182
+ const file = this.blobPath(id);
183
+ if (fs.statSync(file).mtimeMs >= olderThan)
184
+ continue;
185
+ fs.unlinkSync(file);
186
+ removed.blobs += 1;
187
+ }
188
+ return removed;
189
+ }
190
+ }