@hyperframes/studio-server 0.8.83 → 0.8.85
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/dist/{chunk-EL3UQI46.js → chunk-2YEGCDJW.js} +21 -8
- package/dist/chunk-2YEGCDJW.js.map +1 -0
- package/dist/{chunk-WQ2WF2YJ.js → chunk-I3DRHG52.js} +7 -81
- package/dist/chunk-I3DRHG52.js.map +1 -0
- package/dist/chunk-IG4DRZI2.js +1571 -0
- package/dist/chunk-IG4DRZI2.js.map +1 -0
- package/dist/chunk-PMXUKOC3.js +22 -0
- package/dist/chunk-PMXUKOC3.js.map +1 -0
- package/dist/chunk-TTWLTYSQ.js +79 -0
- package/dist/chunk-TTWLTYSQ.js.map +1 -0
- package/dist/chunk-WUHW3MWG.js +100 -0
- package/dist/chunk-WUHW3MWG.js.map +1 -0
- package/dist/{chunk-HWBORC2U.js → chunk-YUPEBATT.js} +3 -3
- package/dist/{chunk-HWBORC2U.js.map → chunk-YUPEBATT.js.map} +1 -1
- package/dist/helpers/hfIdPersist.d.ts +23 -0
- package/dist/helpers/hfIdPersist.js +13 -0
- package/dist/helpers/hfIdPersist.js.map +1 -0
- package/dist/helpers/mediaCodecMap.js +2 -1
- package/dist/helpers/mediaProxyPreview.d.ts +2 -1
- package/dist/helpers/mediaProxyPreview.js +4 -3
- package/dist/helpers/proxyTranscoder.d.ts +4 -1
- package/dist/helpers/proxyTranscoder.js +7 -2
- package/dist/helpers/safePath.d.ts +22 -0
- package/dist/helpers/safePath.js +27 -0
- package/dist/helpers/safePath.js.map +1 -0
- package/dist/history/index.d.ts +49 -0
- package/dist/history/index.js +29 -0
- package/dist/history/index.js.map +1 -0
- package/dist/index.d.ts +9 -58
- package/dist/index.js +302 -1897
- package/dist/index.js.map +1 -1
- package/dist/{mediaProxyPreview-BMHd9G3N.d.ts → mediaProxyPreview-CLu9FdIr.d.ts} +2 -139
- package/dist/projectHistory-Cm8Mdmmv.d.ts +139 -0
- package/package.json +15 -3
- package/dist/chunk-EL3UQI46.js.map +0 -1
- package/dist/chunk-WQ2WF2YJ.js.map +0 -1
|
@@ -0,0 +1,1571 @@
|
|
|
1
|
+
import {
|
|
2
|
+
replaceFileAtomically
|
|
3
|
+
} from "./chunk-PMXUKOC3.js";
|
|
4
|
+
import {
|
|
5
|
+
isSafePath,
|
|
6
|
+
mkdirWithinProject,
|
|
7
|
+
realFilePath
|
|
8
|
+
} from "./chunk-TTWLTYSQ.js";
|
|
9
|
+
|
|
10
|
+
// src/history/projectHistory.ts
|
|
11
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
12
|
+
import { readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
|
|
13
|
+
import {
|
|
14
|
+
existsSync as existsSync2,
|
|
15
|
+
lstatSync as lstatSync2,
|
|
16
|
+
statSync as statSync3,
|
|
17
|
+
mkdirSync as mkdirSync4,
|
|
18
|
+
readdirSync as readdirSync3,
|
|
19
|
+
readFileSync as readFileSync6,
|
|
20
|
+
realpathSync,
|
|
21
|
+
rmSync as rmSync2
|
|
22
|
+
} from "fs";
|
|
23
|
+
import { homedir } from "os";
|
|
24
|
+
import { dirname as dirname4, isAbsolute as isAbsolute2, join as join5, relative as relative2, resolve as resolve3, sep as sep2 } from "path";
|
|
25
|
+
|
|
26
|
+
// src/helpers/fileVersion.ts
|
|
27
|
+
import { createHash, randomUUID } from "crypto";
|
|
28
|
+
var RECEIPT_TTL_MS = 1e4;
|
|
29
|
+
var receipts = /* @__PURE__ */ new Map();
|
|
30
|
+
function fileContentVersion(content) {
|
|
31
|
+
return hashVersion(createHash("sha256").update(content).digest("hex"));
|
|
32
|
+
}
|
|
33
|
+
var DELETED_VERSION = '"deleted"';
|
|
34
|
+
function hashVersion(hex) {
|
|
35
|
+
return `"sha256:${hex}"`;
|
|
36
|
+
}
|
|
37
|
+
function hashOfVersion(version) {
|
|
38
|
+
return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
|
|
39
|
+
}
|
|
40
|
+
function settledFileTag(stat2, now = Date.now()) {
|
|
41
|
+
if (now - stat2.ctimeMs < 3e3) return null;
|
|
42
|
+
return [stat2.ino, stat2.ctimeMs, stat2.size].map((n) => n.toString(36)).join("-");
|
|
43
|
+
}
|
|
44
|
+
function createWriteToken(requestToken) {
|
|
45
|
+
const token = requestToken?.trim();
|
|
46
|
+
return token && token.length <= 200 ? token : randomUUID();
|
|
47
|
+
}
|
|
48
|
+
function recordFileWriteReceipt(filePath, receipt) {
|
|
49
|
+
const absPath = realFilePath(filePath);
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
for (const [path, list] of receipts) {
|
|
52
|
+
const live = list.filter((entry) => now - entry.recordedAt < RECEIPT_TTL_MS);
|
|
53
|
+
if (live.length > 0) receipts.set(path, live);
|
|
54
|
+
else receipts.delete(path);
|
|
55
|
+
}
|
|
56
|
+
receipts.set(absPath, [...receipts.get(absPath) ?? [], { ...receipt, recordedAt: now }]);
|
|
57
|
+
}
|
|
58
|
+
function clearFileWriteReceipt(filePath, version, writeToken) {
|
|
59
|
+
const absPath = realFilePath(filePath);
|
|
60
|
+
const current = (receipts.get(absPath) ?? []).filter(
|
|
61
|
+
(entry) => entry.version !== version || entry.writeToken !== writeToken
|
|
62
|
+
);
|
|
63
|
+
if (current.length > 0) receipts.set(absPath, current);
|
|
64
|
+
else receipts.delete(absPath);
|
|
65
|
+
}
|
|
66
|
+
function identifyFileWrite(filePath, expectedVersion) {
|
|
67
|
+
const receipt = newestReceipt(realFilePath(filePath), expectedVersion);
|
|
68
|
+
if (!receipt) return null;
|
|
69
|
+
const { path, version, writeToken } = receipt;
|
|
70
|
+
return { path, version, writeToken };
|
|
71
|
+
}
|
|
72
|
+
function bytesOverwrittenBy(filePath, version) {
|
|
73
|
+
return newestReceipt(realFilePath(filePath), version)?.overwrote;
|
|
74
|
+
}
|
|
75
|
+
function forgetOverwrittenBytes(filePath, versions) {
|
|
76
|
+
for (const receipt of receipts.get(realFilePath(filePath)) ?? [])
|
|
77
|
+
if (versions.has(receipt.version)) delete receipt.overwrote;
|
|
78
|
+
}
|
|
79
|
+
function newestReceipt(absPath, expectedVersion) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const current = (receipts.get(absPath) ?? []).filter(
|
|
82
|
+
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS
|
|
83
|
+
);
|
|
84
|
+
if (current.length > 0) receipts.set(absPath, current);
|
|
85
|
+
else receipts.delete(absPath);
|
|
86
|
+
let receipt;
|
|
87
|
+
for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
|
|
88
|
+
if (current[i]?.version === expectedVersion) receipt = current[i];
|
|
89
|
+
}
|
|
90
|
+
return receipt;
|
|
91
|
+
}
|
|
92
|
+
var consumeFileWriteReceipt = identifyFileWrite;
|
|
93
|
+
|
|
94
|
+
// src/helpers/projectSignature.ts
|
|
95
|
+
import { createHash as createHash2 } from "crypto";
|
|
96
|
+
import { lstatSync, readFileSync, readdirSync } from "fs";
|
|
97
|
+
import { extname, isAbsolute, relative, resolve, sep } from "path";
|
|
98
|
+
var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
99
|
+
".cjs",
|
|
100
|
+
".css",
|
|
101
|
+
".html",
|
|
102
|
+
".js",
|
|
103
|
+
".json",
|
|
104
|
+
".jsx",
|
|
105
|
+
".mjs",
|
|
106
|
+
".svg",
|
|
107
|
+
".ts",
|
|
108
|
+
".tsx"
|
|
109
|
+
]);
|
|
110
|
+
var SIGNATURE_EXCLUDED_DIRS = /* @__PURE__ */ new Set([
|
|
111
|
+
".cache",
|
|
112
|
+
".git",
|
|
113
|
+
".hyperframes",
|
|
114
|
+
".next",
|
|
115
|
+
".thumbnails",
|
|
116
|
+
".transcode-cache",
|
|
117
|
+
".vite",
|
|
118
|
+
".waveform-cache",
|
|
119
|
+
"build",
|
|
120
|
+
"coverage",
|
|
121
|
+
"dist",
|
|
122
|
+
"node_modules",
|
|
123
|
+
"outputs",
|
|
124
|
+
"renders"
|
|
125
|
+
]);
|
|
126
|
+
var MAX_SIGNATURE_TEXT_BYTES = 2e6;
|
|
127
|
+
var STUDIO_SIGNATURE_MANIFEST_PATHS = [
|
|
128
|
+
".hyperframes/studio-manual-edits.json",
|
|
129
|
+
".hyperframes/studio-motion.json"
|
|
130
|
+
];
|
|
131
|
+
function affectsProjectSignature(projectDir, changedPath) {
|
|
132
|
+
const relativePath = relative(resolve(projectDir), resolve(changedPath));
|
|
133
|
+
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const segments = relativePath.split(sep);
|
|
137
|
+
if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
|
|
138
|
+
return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
|
|
139
|
+
}
|
|
140
|
+
var projectSignatureCache = /* @__PURE__ */ new Map();
|
|
141
|
+
function isPathWithin(parentDir, childPath) {
|
|
142
|
+
const childRelativePath = relative(parentDir, childPath);
|
|
143
|
+
return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
|
|
144
|
+
}
|
|
145
|
+
function isTextContentEligible(file, size) {
|
|
146
|
+
return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
|
|
147
|
+
}
|
|
148
|
+
function collectProjectSignatureFiles(projectDir, dir, files) {
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = readdirSync(dir).sort();
|
|
152
|
+
} catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
|
|
157
|
+
const file = resolve(dir, entry);
|
|
158
|
+
if (!isPathWithin(projectDir, file)) continue;
|
|
159
|
+
let stat2;
|
|
160
|
+
try {
|
|
161
|
+
stat2 = lstatSync(file);
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (stat2.isSymbolicLink()) continue;
|
|
166
|
+
if (stat2.isDirectory()) {
|
|
167
|
+
collectProjectSignatureFiles(projectDir, file, files);
|
|
168
|
+
} else if (stat2.isFile()) {
|
|
169
|
+
files.push({
|
|
170
|
+
file,
|
|
171
|
+
mtimeMs: stat2.mtimeMs,
|
|
172
|
+
ctimeMs: stat2.ctimeMs,
|
|
173
|
+
size: stat2.size,
|
|
174
|
+
textContentEligible: isTextContentEligible(file, stat2.size)
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
180
|
+
const seen = new Set(files.map((entry) => entry.file));
|
|
181
|
+
for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
|
|
182
|
+
const file = resolve(projectDir, manifestPath);
|
|
183
|
+
if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
|
|
184
|
+
let stat2;
|
|
185
|
+
try {
|
|
186
|
+
stat2 = lstatSync(file);
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
|
|
191
|
+
files.push({
|
|
192
|
+
file,
|
|
193
|
+
mtimeMs: stat2.mtimeMs,
|
|
194
|
+
ctimeMs: stat2.ctimeMs,
|
|
195
|
+
size: stat2.size,
|
|
196
|
+
textContentEligible: isTextContentEligible(file, stat2.size)
|
|
197
|
+
});
|
|
198
|
+
seen.add(file);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function createProjectFingerprint(projectDir, files) {
|
|
202
|
+
const hash = createHash2("sha256");
|
|
203
|
+
for (const entry of files) {
|
|
204
|
+
hash.update(relative(projectDir, entry.file));
|
|
205
|
+
hash.update("\0");
|
|
206
|
+
hash.update(String(entry.size));
|
|
207
|
+
hash.update("\0");
|
|
208
|
+
hash.update(String(entry.mtimeMs));
|
|
209
|
+
hash.update("\0");
|
|
210
|
+
hash.update(String(entry.ctimeMs));
|
|
211
|
+
hash.update("\0");
|
|
212
|
+
hash.update(entry.textContentEligible ? "text" : "binary");
|
|
213
|
+
hash.update("\0");
|
|
214
|
+
}
|
|
215
|
+
return hash.digest("hex").slice(0, 24);
|
|
216
|
+
}
|
|
217
|
+
function collectProjectFiles(normalizedProjectDir) {
|
|
218
|
+
const collected = [];
|
|
219
|
+
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
|
|
220
|
+
collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
|
|
221
|
+
return collected;
|
|
222
|
+
}
|
|
223
|
+
function listProjectFiles(projectDir) {
|
|
224
|
+
const normalizedProjectDir = resolve(projectDir);
|
|
225
|
+
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
|
|
226
|
+
path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
|
|
227
|
+
size: entry.size,
|
|
228
|
+
mtimeMs: entry.mtimeMs,
|
|
229
|
+
ctimeMs: entry.ctimeMs
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
function resolveProjectSignature(adapter, projectDir) {
|
|
233
|
+
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
|
|
234
|
+
}
|
|
235
|
+
async function resolveProjectAndSignature(adapter, projectId) {
|
|
236
|
+
const project = await adapter.resolveProject(projectId);
|
|
237
|
+
if (!project) return null;
|
|
238
|
+
return { project, signature: resolveProjectSignature(adapter, project.dir) };
|
|
239
|
+
}
|
|
240
|
+
function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set()) {
|
|
241
|
+
const normalizedProjectDir = resolve(projectDir);
|
|
242
|
+
const collected = collectProjectFiles(normalizedProjectDir);
|
|
243
|
+
const files = collected.filter(
|
|
244
|
+
(entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
|
|
245
|
+
);
|
|
246
|
+
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
247
|
+
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
|
248
|
+
const cacheKey = excluding.size ? `${normalizedProjectDir}\0${createHash2("sha256").update([...excluding].sort().join("\0")).digest("hex")}` : normalizedProjectDir;
|
|
249
|
+
const cached = projectSignatureCache.get(cacheKey);
|
|
250
|
+
if (cached?.fingerprint === fingerprint) return cached.signature;
|
|
251
|
+
const hash = createHash2("sha256");
|
|
252
|
+
for (const entry of files) {
|
|
253
|
+
const relativePath = relative(normalizedProjectDir, entry.file);
|
|
254
|
+
hash.update(relativePath);
|
|
255
|
+
hash.update("\0");
|
|
256
|
+
hash.update(String(entry.size));
|
|
257
|
+
hash.update("\0");
|
|
258
|
+
if (entry.textContentEligible) {
|
|
259
|
+
try {
|
|
260
|
+
hash.update(readFileSync(entry.file));
|
|
261
|
+
} catch {
|
|
262
|
+
hash.update(String(entry.mtimeMs));
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
hash.update(String(entry.mtimeMs));
|
|
266
|
+
}
|
|
267
|
+
hash.update("\0");
|
|
268
|
+
}
|
|
269
|
+
const signature = hash.digest("hex").slice(0, 24);
|
|
270
|
+
projectSignatureCache.set(cacheKey, { fingerprint, signature });
|
|
271
|
+
return signature;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/history/blobStore.ts
|
|
275
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
|
|
276
|
+
import { constants, createReadStream, renameSync } from "fs";
|
|
277
|
+
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "fs/promises";
|
|
278
|
+
import { dirname, join } from "path";
|
|
279
|
+
var BLOB_HASH = /^[0-9a-f]{64}$/;
|
|
280
|
+
async function hashFile(path) {
|
|
281
|
+
const hash = createHash3("sha256");
|
|
282
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
283
|
+
return hash.digest("hex");
|
|
284
|
+
}
|
|
285
|
+
async function cloneOrCopy(from, to, beforeReplace) {
|
|
286
|
+
await mkdir(dirname(to), { recursive: true });
|
|
287
|
+
const temp = `${to}.${randomUUID2()}.tmp`;
|
|
288
|
+
try {
|
|
289
|
+
await copyFile(from, temp, constants.COPYFILE_FICLONE);
|
|
290
|
+
beforeReplace?.();
|
|
291
|
+
renameSync(temp, to);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
await rm(temp, { force: true });
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function openBlobStore(dir) {
|
|
298
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
299
|
+
await mkdir(dir, { recursive: true });
|
|
300
|
+
for (const shard of await readdir(dir))
|
|
301
|
+
for (const hash of await readdir(join(dir, shard)).catch(() => []))
|
|
302
|
+
sizes.set(hash, (await stat(join(dir, shard, hash))).size);
|
|
303
|
+
const pathOf = (hash) => {
|
|
304
|
+
if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
|
|
305
|
+
return join(dir, hash.slice(0, 2), hash);
|
|
306
|
+
};
|
|
307
|
+
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
|
|
308
|
+
return {
|
|
309
|
+
async put(absPath) {
|
|
310
|
+
const temp = join(dir, `incoming-${randomUUID2()}`);
|
|
311
|
+
await mkdir(dir, { recursive: true });
|
|
312
|
+
await copyFile(absPath, temp, constants.COPYFILE_FICLONE);
|
|
313
|
+
const hash = await hashFile(temp);
|
|
314
|
+
if (sizes.has(hash)) {
|
|
315
|
+
await rm(temp, { force: true });
|
|
316
|
+
return hash;
|
|
317
|
+
}
|
|
318
|
+
await mkdir(dirname(pathOf(hash)), { recursive: true });
|
|
319
|
+
const size = (await stat(temp)).size;
|
|
320
|
+
await rename(temp, pathOf(hash));
|
|
321
|
+
sizes.set(hash, size);
|
|
322
|
+
total += size;
|
|
323
|
+
return hash;
|
|
324
|
+
},
|
|
325
|
+
has: (hash) => sizes.has(hash),
|
|
326
|
+
read: async (hash) => readFile(pathOf(hash)),
|
|
327
|
+
writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
|
|
328
|
+
bytes: () => total,
|
|
329
|
+
size: (hash) => sizes.get(hash) ?? 0,
|
|
330
|
+
async prune(keep) {
|
|
331
|
+
for (const [hash, size] of sizes) {
|
|
332
|
+
if (keep.has(hash)) continue;
|
|
333
|
+
await rm(pathOf(hash), { force: true });
|
|
334
|
+
sizes.delete(hash);
|
|
335
|
+
total -= size;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/history/pruneHistories.ts
|
|
342
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
343
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
344
|
+
import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
|
|
345
|
+
import { tmpdir } from "os";
|
|
346
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
347
|
+
|
|
348
|
+
// src/history/historyId.ts
|
|
349
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
350
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync2, statSync, writeFileSync } from "fs";
|
|
351
|
+
import { basename, join as join2, resolve as resolve2 } from "path";
|
|
352
|
+
var ID_PATH = join2(".hyperframes", "history-id");
|
|
353
|
+
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
354
|
+
var isHistoryId = (name) => ID_SHAPE.test(name);
|
|
355
|
+
function readId(projectDir) {
|
|
356
|
+
try {
|
|
357
|
+
const id = readFileSync2(join2(projectDir, ID_PATH), "utf-8").trim();
|
|
358
|
+
return ID_SHAPE.test(id) ? id : null;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
364
|
+
function isRecordedFolder(historyDir, folder) {
|
|
365
|
+
const was = readRecord(historyDir);
|
|
366
|
+
if (typeof was?.ino === "number")
|
|
367
|
+
return sameFolder({ ino: was.ino, birthtimeMs: was.born ?? NaN }, folder);
|
|
368
|
+
const unreadableSo0878KeptTheId = typeof was?.dir !== "string";
|
|
369
|
+
if (unreadableSo0878KeptTheId) return true;
|
|
370
|
+
return !isCopyOf0878Folder(was.dir, basename(historyDir), folder);
|
|
371
|
+
}
|
|
372
|
+
function readRecord(historyDir) {
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(readFileSync2(join2(historyDir, "project.json"), "utf-8"));
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function isCopyOf0878Folder(recordedDir, id, folder) {
|
|
380
|
+
let there;
|
|
381
|
+
try {
|
|
382
|
+
there = statSync(recordedDir, { throwIfNoEntry: false });
|
|
383
|
+
} catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
return !!there && !sameFolder(there, folder) && readId(recordedDir) === id;
|
|
387
|
+
}
|
|
388
|
+
function projectHistoryId(projectDir, historyRoot) {
|
|
389
|
+
const dir = resolve2(projectDir);
|
|
390
|
+
const folder = statSync(dir);
|
|
391
|
+
let id = readId(dir);
|
|
392
|
+
if (!id && existsSync(join2(dir, ID_PATH)))
|
|
393
|
+
throw new HistoryIdError(
|
|
394
|
+
`${join2(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
|
|
395
|
+
);
|
|
396
|
+
if (!id || existsSync(join2(historyRoot, id)) && !isRecordedFolder(join2(historyRoot, id), folder)) {
|
|
397
|
+
id = randomUUID3();
|
|
398
|
+
mkdirWithinProject(dir, join2(dir, ".hyperframes"));
|
|
399
|
+
writeFileSync(join2(dir, ID_PATH), `${id}
|
|
400
|
+
`);
|
|
401
|
+
}
|
|
402
|
+
recordProject(join2(historyRoot, id), dir, folder);
|
|
403
|
+
return id;
|
|
404
|
+
}
|
|
405
|
+
var HistoryIdError = class extends Error {
|
|
406
|
+
constructor(message) {
|
|
407
|
+
super(message);
|
|
408
|
+
this.name = "HistoryIdError";
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
function recordProject(historyDir, dir, folder) {
|
|
412
|
+
const record = { dir, ino: folder.ino, born: folder.birthtimeMs, dev: folder.dev };
|
|
413
|
+
mkdirSync(historyDir, { recursive: true });
|
|
414
|
+
replaceFileAtomically(join2(historyDir, "project.json"), JSON.stringify(record), 420);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/history/ownerLock.ts
|
|
418
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
419
|
+
import { linkSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
420
|
+
import { join as join3 } from "path";
|
|
421
|
+
var HistoryBusyError = class extends Error {
|
|
422
|
+
constructor(pid) {
|
|
423
|
+
super(`This project's history is open in another process (pid ${pid}).`);
|
|
424
|
+
this.pid = pid;
|
|
425
|
+
this.name = "HistoryBusyError";
|
|
426
|
+
}
|
|
427
|
+
pid;
|
|
428
|
+
};
|
|
429
|
+
function alive(pid) {
|
|
430
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
431
|
+
try {
|
|
432
|
+
process.kill(pid, 0);
|
|
433
|
+
return true;
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return error.code === "EPERM";
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function ownerOf(file) {
|
|
439
|
+
try {
|
|
440
|
+
const text = readFileSync3(file, "utf-8");
|
|
441
|
+
return /^\d+$/.test(text) ? Number(text) : Number.NaN;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error.code === "ENOENT") return null;
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function claim(file) {
|
|
448
|
+
const draft = `${file}-${randomUUID4()}.tmp`;
|
|
449
|
+
writeFileSync2(draft, String(process.pid));
|
|
450
|
+
try {
|
|
451
|
+
linkSync(draft, file);
|
|
452
|
+
return true;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (error.code !== "EEXIST") throw error;
|
|
455
|
+
return false;
|
|
456
|
+
} finally {
|
|
457
|
+
rmSync(draft, { force: true });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function releaseOwn(file) {
|
|
461
|
+
if (ownerOf(file) === process.pid) rmSync(file, { force: true });
|
|
462
|
+
}
|
|
463
|
+
function evictDeadOwner(file) {
|
|
464
|
+
const evictor = `${file}.evict`;
|
|
465
|
+
if (!claim(evictor)) {
|
|
466
|
+
const holder = ownerOf(evictor);
|
|
467
|
+
if (holder !== null && !alive(holder)) rmSync(evictor, { force: true });
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const pid = ownerOf(file);
|
|
472
|
+
if (pid !== null && !alive(pid)) rmSync(file, { force: true });
|
|
473
|
+
return true;
|
|
474
|
+
} finally {
|
|
475
|
+
releaseOwn(evictor);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function takeHistoryOwnership(home, waitMs) {
|
|
479
|
+
const file = join3(home, "owner.pid");
|
|
480
|
+
const deadline = Date.now() + waitMs;
|
|
481
|
+
mkdirSync2(home, { recursive: true });
|
|
482
|
+
for (; ; ) {
|
|
483
|
+
if (claim(file)) {
|
|
484
|
+
let held = true;
|
|
485
|
+
return () => {
|
|
486
|
+
if (held) releaseOwn(file);
|
|
487
|
+
held = false;
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const pid = ownerOf(file);
|
|
491
|
+
if (pid === null) continue;
|
|
492
|
+
if (!alive(pid) && evictDeadOwner(file)) continue;
|
|
493
|
+
if (Date.now() >= deadline) throw new HistoryBusyError(pid);
|
|
494
|
+
await new Promise((settle) => setTimeout(settle, 50));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/history/pruneHistories.ts
|
|
499
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
500
|
+
var KEEP_GONE_PROJECT_HISTORY_MS = 14 * DAY_MS;
|
|
501
|
+
var PRUNED_PREFIX = ".pruned-";
|
|
502
|
+
var LAST_PRUNE_FILE = ".last-prune";
|
|
503
|
+
function listProjectHistories(historyRoot) {
|
|
504
|
+
return namesIn(historyRoot).filter(isHistoryId).flatMap((id) => {
|
|
505
|
+
const home = join4(historyRoot, id);
|
|
506
|
+
const projectDir = readRecord(home)?.dir;
|
|
507
|
+
if (typeof projectDir !== "string" || readId(projectDir) !== id) return [];
|
|
508
|
+
return [{ id, projectDir, lastUsedMs: lastUsed(home) }];
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
function namesIn(historyRoot) {
|
|
512
|
+
try {
|
|
513
|
+
return readdirSync2(historyRoot);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (error.code === "ENOENT") return [];
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function pruneGoneProjectHistories(historyRoot, options = {}) {
|
|
520
|
+
return (await pruneWithin(historyRoot, options)).pruned;
|
|
521
|
+
}
|
|
522
|
+
async function pruneWithin(historyRoot, {
|
|
523
|
+
now = Date.now(),
|
|
524
|
+
dryRun = false,
|
|
525
|
+
tempDir = tmpdir(),
|
|
526
|
+
onError = (error) => console.warn("[history] could not prune a history:", error),
|
|
527
|
+
budgetMs = Infinity,
|
|
528
|
+
measure = true
|
|
529
|
+
}) {
|
|
530
|
+
const started = performance.now();
|
|
531
|
+
const names = namesIn(historyRoot);
|
|
532
|
+
const pruned = [];
|
|
533
|
+
for (const name of names) {
|
|
534
|
+
if (performance.now() - started >= budgetMs) return { pruned, finished: false };
|
|
535
|
+
try {
|
|
536
|
+
const gone = await pruneEntry(historyRoot, name, { now, dryRun, tempDir, measure });
|
|
537
|
+
if (gone) pruned.push(gone);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
onError(error);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return { pruned, finished: true };
|
|
543
|
+
}
|
|
544
|
+
async function pruneEntry(historyRoot, name, run) {
|
|
545
|
+
if (name.startsWith(PRUNED_PREFIX)) {
|
|
546
|
+
if (!run.dryRun) await rm2(join4(historyRoot, name), { recursive: true, force: true });
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
return isHistoryId(name) ? pruneOne(historyRoot, name, run) : null;
|
|
550
|
+
}
|
|
551
|
+
async function pruneOne(historyRoot, id, run) {
|
|
552
|
+
const home = join4(historyRoot, id);
|
|
553
|
+
if (abandonedProject(home, id, run.now, run.tempDir) === null) return null;
|
|
554
|
+
const release = await takeUnlessBusy(home);
|
|
555
|
+
if (!release) return null;
|
|
556
|
+
let taken;
|
|
557
|
+
try {
|
|
558
|
+
taken = recheckAndMoveAway(historyRoot, id, run);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
release();
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
if (!taken?.trash) release();
|
|
564
|
+
if (!taken) return null;
|
|
565
|
+
const bytes = await clearAway(taken.trash, home, run.measure);
|
|
566
|
+
return { id, projectDir: taken.projectDir, bytes };
|
|
567
|
+
}
|
|
568
|
+
async function clearAway(trash, home, measure) {
|
|
569
|
+
const bytes = measure ? await bytesIn(trash ?? home) : 0;
|
|
570
|
+
if (trash) await rm2(trash, { recursive: true, force: true });
|
|
571
|
+
return bytes;
|
|
572
|
+
}
|
|
573
|
+
async function takeUnlessBusy(home) {
|
|
574
|
+
try {
|
|
575
|
+
return await takeHistoryOwnership(home, 0);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (error instanceof HistoryBusyError) return null;
|
|
578
|
+
throw error;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function recheckAndMoveAway(historyRoot, id, { now, dryRun, tempDir }) {
|
|
582
|
+
const home = join4(historyRoot, id);
|
|
583
|
+
const projectDir = abandonedProject(home, id, now, tempDir);
|
|
584
|
+
if (projectDir === null) return null;
|
|
585
|
+
if (dryRun) return { projectDir, trash: null };
|
|
586
|
+
const trash = join4(historyRoot, `${PRUNED_PREFIX}${id}-${randomUUID5()}`);
|
|
587
|
+
renameSync2(home, trash);
|
|
588
|
+
return { projectDir, trash };
|
|
589
|
+
}
|
|
590
|
+
function abandonedProject(home, id, now, tempDir) {
|
|
591
|
+
const record = readRecord(home);
|
|
592
|
+
const dir = record?.dir;
|
|
593
|
+
if (typeof dir !== "string") return null;
|
|
594
|
+
if (now - lastUsed(home) < KEEP_GONE_PROJECT_HISTORY_MS && !isSafePath(tempDir, dir)) return null;
|
|
595
|
+
return projectGone(dir, id, record?.dev, tempDir) ? dir : null;
|
|
596
|
+
}
|
|
597
|
+
function projectGone(dir, id, dev, tempDir) {
|
|
598
|
+
if (!statSync2(join4(dir, ID_PATH), { throwIfNoEntry: false }))
|
|
599
|
+
return diskStillHere(dir, dev, tempDir);
|
|
600
|
+
const there = readId(dir);
|
|
601
|
+
return there !== null && there !== id;
|
|
602
|
+
}
|
|
603
|
+
function diskStillHere(dir, dev, tempDir) {
|
|
604
|
+
let at = dir;
|
|
605
|
+
let stat2 = statSync2(at, { throwIfNoEntry: false });
|
|
606
|
+
while (!stat2) {
|
|
607
|
+
if (dirname2(at) === at) return false;
|
|
608
|
+
at = dirname2(at);
|
|
609
|
+
stat2 = statSync2(at, { throwIfNoEntry: false });
|
|
610
|
+
}
|
|
611
|
+
if (typeof dev === "number") return stat2.dev === dev;
|
|
612
|
+
return at === dir || isSafePath(tempDir, at);
|
|
613
|
+
}
|
|
614
|
+
function lastUsed(home) {
|
|
615
|
+
const mtime = (file) => statSync2(join4(home, file), { throwIfNoEntry: false })?.mtimeMs ?? 0;
|
|
616
|
+
return Math.max(mtime("project.json"), mtime("log.jsonl"));
|
|
617
|
+
}
|
|
618
|
+
async function bytesIn(dir) {
|
|
619
|
+
let total = 0;
|
|
620
|
+
for (const entry of await readdir2(dir, { withFileTypes: true })) {
|
|
621
|
+
const path = join4(dir, entry.name);
|
|
622
|
+
total += entry.isDirectory() ? await bytesIn(path) : (await lstat(path)).size;
|
|
623
|
+
}
|
|
624
|
+
return total;
|
|
625
|
+
}
|
|
626
|
+
function prunedWithinADay(stamp, now) {
|
|
627
|
+
try {
|
|
628
|
+
const last = Number(readFileSync4(stamp, "utf-8"));
|
|
629
|
+
return last <= now && now - last < DAY_MS;
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
var pruning = /* @__PURE__ */ new Set();
|
|
635
|
+
function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs } = {}) {
|
|
636
|
+
const stamp = join4(historyRoot, LAST_PRUNE_FILE);
|
|
637
|
+
if (pruning.has(historyRoot) || prunedWithinADay(stamp, now)) return;
|
|
638
|
+
pruning.add(historyRoot);
|
|
639
|
+
setImmediate(() => {
|
|
640
|
+
pruneWithin(historyRoot, { now, onError, budgetMs, measure: false }).then(({ finished }) => finished && writeFileSync3(stamp, String(now))).catch((error) => onError?.(error)).finally(() => pruning.delete(historyRoot));
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/history/historyLog.ts
|
|
645
|
+
import { closeSync, constants as constants2, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, writeSync } from "fs";
|
|
646
|
+
import { dirname as dirname3 } from "path";
|
|
647
|
+
var START = "start";
|
|
648
|
+
function parseRecord(line) {
|
|
649
|
+
try {
|
|
650
|
+
return JSON.parse(line);
|
|
651
|
+
} catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function readLog(file, onUnreadable) {
|
|
656
|
+
let text;
|
|
657
|
+
try {
|
|
658
|
+
text = readFileSync5(file, "utf-8");
|
|
659
|
+
} catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
663
|
+
const lines = text.split("\n");
|
|
664
|
+
lines.forEach((line, index) => {
|
|
665
|
+
const record = line ? parseRecord(line) : null;
|
|
666
|
+
if (record) applyRecord(log, record);
|
|
667
|
+
else if (line && index < lines.length - 1) onUnreadable(index + 1);
|
|
668
|
+
});
|
|
669
|
+
return log;
|
|
670
|
+
}
|
|
671
|
+
function applyRecord(log, record) {
|
|
672
|
+
if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
|
|
673
|
+
else if (record.type === "entry") log.entries.push(record.entry);
|
|
674
|
+
else if (record.pinned) log.pins.add(record.id);
|
|
675
|
+
else log.pins.delete(record.id);
|
|
676
|
+
}
|
|
677
|
+
function saveRecord(file, log, record) {
|
|
678
|
+
let fd;
|
|
679
|
+
try {
|
|
680
|
+
fd = openSync(file, constants2.O_WRONLY | constants2.O_APPEND);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (error.code !== "ENOENT") throw error;
|
|
683
|
+
return writeLog(file, log);
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
writeSync(fd, `${JSON.stringify(record)}
|
|
687
|
+
`);
|
|
688
|
+
} finally {
|
|
689
|
+
closeSync(fd);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function writeLog(file, log) {
|
|
693
|
+
const records = [
|
|
694
|
+
{ type: "baseline", files: Object.fromEntries(log.baseline) },
|
|
695
|
+
...log.entries.map((entry) => ({ type: "entry", entry })),
|
|
696
|
+
...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
|
|
697
|
+
];
|
|
698
|
+
mkdirSync3(dirname3(file), { recursive: true });
|
|
699
|
+
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
|
|
700
|
+
`).join(""), 420);
|
|
701
|
+
}
|
|
702
|
+
function applyEntry(manifest, entry, side) {
|
|
703
|
+
for (const file of entry.files) {
|
|
704
|
+
const hash = file[side];
|
|
705
|
+
if (hash === null) manifest.delete(file.path);
|
|
706
|
+
else manifest.set(file.path, hash);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function manifestAt(log, point) {
|
|
710
|
+
return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
|
|
711
|
+
}
|
|
712
|
+
function manifestAround(log, id, side) {
|
|
713
|
+
const manifest = new Map(log.baseline);
|
|
714
|
+
for (const entry of log.entries) {
|
|
715
|
+
applyEntry(manifest, entry, entry.id === id ? side : "after");
|
|
716
|
+
if (entry.id === id) return manifest;
|
|
717
|
+
}
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
function undoneIds(entries) {
|
|
721
|
+
const undone = /* @__PURE__ */ new Set();
|
|
722
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
723
|
+
const entry = entries[i];
|
|
724
|
+
if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
|
|
725
|
+
}
|
|
726
|
+
return undone;
|
|
727
|
+
}
|
|
728
|
+
function stepTarget(entries, direction, mine) {
|
|
729
|
+
const undone = undoneIds(entries);
|
|
730
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
731
|
+
const owns = ownerOf2(entries, byId, mine);
|
|
732
|
+
const newestFirst = [...entries].reverse();
|
|
733
|
+
if (direction === "back")
|
|
734
|
+
return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
|
|
735
|
+
for (const entry of newestFirst) {
|
|
736
|
+
if (!entry.undoes) {
|
|
737
|
+
if (owns(entry)) return void 0;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
const target = byId.get(entry.undoes);
|
|
741
|
+
if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
|
|
742
|
+
}
|
|
743
|
+
return void 0;
|
|
744
|
+
}
|
|
745
|
+
function ownerOf2(entries, byId, mine) {
|
|
746
|
+
const redoneBy = /* @__PURE__ */ new Map();
|
|
747
|
+
for (const entry of entries) {
|
|
748
|
+
const target = entry.undoes ? byId.get(entry.undoes) : void 0;
|
|
749
|
+
if (target?.undoes) redoneBy.set(target.undoes, entry);
|
|
750
|
+
}
|
|
751
|
+
return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
|
|
752
|
+
}
|
|
753
|
+
function foldOldest(log) {
|
|
754
|
+
const oldest = log.entries[0];
|
|
755
|
+
if (!oldest || log.pins.has(oldest.id)) return false;
|
|
756
|
+
applyEntry(log.baseline, oldest, "after");
|
|
757
|
+
log.entries.shift();
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
function referencedHashes(log, current) {
|
|
761
|
+
const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
|
|
762
|
+
for (const entry of log.entries)
|
|
763
|
+
for (const file of entry.files) {
|
|
764
|
+
if (file.before) keep.add(file.before);
|
|
765
|
+
if (file.after) keep.add(file.after);
|
|
766
|
+
}
|
|
767
|
+
return keep;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/history/projectHistory.ts
|
|
771
|
+
var DEFAULT_HISTORY_ROOT = join5(homedir(), ".cache", "hyperframes", "history");
|
|
772
|
+
var MAX_WINDOW_IDLE_MS = 10 * 6e4;
|
|
773
|
+
var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
|
|
774
|
+
var OUTSIDE = { kind: "outside", name: "Outside" };
|
|
775
|
+
var OUTSIDE_LABEL = "Changed outside the app";
|
|
776
|
+
var RACY_MS = 2e3;
|
|
777
|
+
var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
|
|
778
|
+
var sameWho = (a, b) => a.kind === b.kind && a.name === b.name;
|
|
779
|
+
function splitAt(change, at, kept) {
|
|
780
|
+
if (at === change.after) return [change, null];
|
|
781
|
+
if (!at || at === change.before || !kept) return [null, change];
|
|
782
|
+
return [
|
|
783
|
+
{ ...change, after: at },
|
|
784
|
+
{ ...change, before: at }
|
|
785
|
+
];
|
|
786
|
+
}
|
|
787
|
+
var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
|
|
788
|
+
async function removeEmptyFolders(dir) {
|
|
789
|
+
let entries;
|
|
790
|
+
try {
|
|
791
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (isMissingFolder(error)) return;
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
for (const entry of entries)
|
|
797
|
+
if (entry.isDirectory()) await removeEmptyFolders(join5(dir, entry.name));
|
|
798
|
+
await rmdir(dir);
|
|
799
|
+
}
|
|
800
|
+
function fileLeftIn(dir, at, deleted) {
|
|
801
|
+
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
802
|
+
const path = `${at}/${entry.name}`;
|
|
803
|
+
const left = entry.isDirectory() ? fileLeftIn(join5(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
|
|
804
|
+
if (left) return left;
|
|
805
|
+
}
|
|
806
|
+
return void 0;
|
|
807
|
+
}
|
|
808
|
+
function ignoresCase(dir) {
|
|
809
|
+
const self = statSync3(join5(dir, ID_PATH), { throwIfNoEntry: false });
|
|
810
|
+
const other = statSync3(join5(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
|
|
811
|
+
return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
|
|
812
|
+
}
|
|
813
|
+
var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
|
|
814
|
+
var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
|
|
815
|
+
function addChange(group, path, before, after) {
|
|
816
|
+
const earlier = group.changes.get(path);
|
|
817
|
+
const from = earlier ? earlier.before : before;
|
|
818
|
+
if (from === after) group.changes.delete(path);
|
|
819
|
+
else group.changes.set(path, { path, before: from, after });
|
|
820
|
+
}
|
|
821
|
+
var Engine = class {
|
|
822
|
+
constructor(options, projectId, blobs) {
|
|
823
|
+
this.options = options;
|
|
824
|
+
this.projectId = projectId;
|
|
825
|
+
this.blobs = blobs;
|
|
826
|
+
this.dir = resolve3(options.projectDir);
|
|
827
|
+
this.home = join5(options.historyRoot, projectId);
|
|
828
|
+
this.foldsCase = ignoresCase(this.dir);
|
|
829
|
+
const folder = statSync3(this.dir, { throwIfNoEntry: false });
|
|
830
|
+
if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
|
|
831
|
+
throw this.replaced();
|
|
832
|
+
this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
|
|
833
|
+
}
|
|
834
|
+
options;
|
|
835
|
+
projectId;
|
|
836
|
+
blobs;
|
|
837
|
+
dir;
|
|
838
|
+
foldsCase;
|
|
839
|
+
folder;
|
|
840
|
+
home;
|
|
841
|
+
log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
842
|
+
tracked = /* @__PURE__ */ new Map();
|
|
843
|
+
windows = [];
|
|
844
|
+
outside = null;
|
|
845
|
+
/** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
|
|
846
|
+
claimed = null;
|
|
847
|
+
writeToken;
|
|
848
|
+
quietTimer;
|
|
849
|
+
maxTimer;
|
|
850
|
+
notedTimer = null;
|
|
851
|
+
listeners = /* @__PURE__ */ new Set();
|
|
852
|
+
tail = Promise.resolve();
|
|
853
|
+
closed = false;
|
|
854
|
+
closing;
|
|
855
|
+
/** A path as the project's disk compares names: folded where `A` and `a` are one name. */
|
|
856
|
+
nameKey(path) {
|
|
857
|
+
return this.foldsCase ? path.toLowerCase() : path;
|
|
858
|
+
}
|
|
859
|
+
get logFile() {
|
|
860
|
+
return join5(this.home, "log.jsonl");
|
|
861
|
+
}
|
|
862
|
+
now() {
|
|
863
|
+
return (this.options.now ?? Date.now)();
|
|
864
|
+
}
|
|
865
|
+
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
|
866
|
+
queue(task) {
|
|
867
|
+
if (this.closed)
|
|
868
|
+
return Promise.reject(new HistoryClosedError("This project's history is closed."));
|
|
869
|
+
const run = this.tail.then(task);
|
|
870
|
+
this.tail = run.catch(() => void 0);
|
|
871
|
+
return run;
|
|
872
|
+
}
|
|
873
|
+
/** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
|
|
874
|
+
whereFolder() {
|
|
875
|
+
const now = statSync3(this.dir, { throwIfNoEntry: false });
|
|
876
|
+
if (!now) return "gone";
|
|
877
|
+
const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
|
|
878
|
+
return same && readId(this.dir) === this.projectId ? "here" : "replaced";
|
|
879
|
+
}
|
|
880
|
+
assertWritable() {
|
|
881
|
+
const folder = this.whereFolder();
|
|
882
|
+
if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
|
|
883
|
+
if (folder === "replaced") throw this.replaced();
|
|
884
|
+
}
|
|
885
|
+
persistLog(record) {
|
|
886
|
+
if (record) saveRecord(this.logFile, this.log, record);
|
|
887
|
+
else writeLog(this.logFile, this.log);
|
|
888
|
+
if (!existsSync2(join5(this.home, "project.json")))
|
|
889
|
+
recordProject(this.home, this.dir, this.folder);
|
|
890
|
+
}
|
|
891
|
+
replaced() {
|
|
892
|
+
return new HistoryClosedError(`${this.dir} is now another project.`);
|
|
893
|
+
}
|
|
894
|
+
assertOpen() {
|
|
895
|
+
if (this.closed) throw new HistoryClosedError("This project's history is closed.");
|
|
896
|
+
if (this.whereFolder() === "replaced") throw this.replaced();
|
|
897
|
+
}
|
|
898
|
+
async start() {
|
|
899
|
+
const log = readLog(
|
|
900
|
+
this.logFile,
|
|
901
|
+
(line) => this.options.onError?.(
|
|
902
|
+
new Error(`History log line ${line} could not be read and was skipped.`)
|
|
903
|
+
)
|
|
904
|
+
);
|
|
905
|
+
if (!log) return this.firstOpen();
|
|
906
|
+
this.log = log;
|
|
907
|
+
const cache = this.readStatCache();
|
|
908
|
+
const last = this.log.entries.at(-1)?.id ?? START;
|
|
909
|
+
for (const [path, hash] of manifestAt(this.log, last) ?? []) {
|
|
910
|
+
const cached = cache.get(path);
|
|
911
|
+
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
|
912
|
+
}
|
|
913
|
+
this.reopenClosedWindow();
|
|
914
|
+
await this.settleAll();
|
|
915
|
+
}
|
|
916
|
+
reopenClosedWindow() {
|
|
917
|
+
const closed = this.options.closedWindow;
|
|
918
|
+
if (!closed || this.log.entries.some((entry) => entry.id === closed.id)) return;
|
|
919
|
+
const { id, who, label, startedAt, lastWriteAt, idleMs } = closed;
|
|
920
|
+
this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: /* @__PURE__ */ new Map() });
|
|
921
|
+
}
|
|
922
|
+
async firstOpen() {
|
|
923
|
+
const sweptAt = Date.now();
|
|
924
|
+
for (const file of listProjectFiles(this.dir)) {
|
|
925
|
+
const hash = await this.storeIfPresent(file.path);
|
|
926
|
+
if (this.whereFolder() !== "here") throw this.replaced();
|
|
927
|
+
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
|
|
928
|
+
}
|
|
929
|
+
this.log.baseline = this.manifest();
|
|
930
|
+
this.persistLog();
|
|
931
|
+
this.saveStatCache();
|
|
932
|
+
}
|
|
933
|
+
manifest() {
|
|
934
|
+
return new Map([...this.tracked].map(([path, file]) => [path, file.hash]));
|
|
935
|
+
}
|
|
936
|
+
readStatCache() {
|
|
937
|
+
try {
|
|
938
|
+
const files = JSON.parse(readFileSync6(join5(this.home, "stat.json"), "utf-8"));
|
|
939
|
+
return new Map(Object.entries(files));
|
|
940
|
+
} catch {
|
|
941
|
+
return /* @__PURE__ */ new Map();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
saveStatCache() {
|
|
945
|
+
const files = JSON.stringify(Object.fromEntries(this.tracked));
|
|
946
|
+
mkdirSync4(this.home, { recursive: true });
|
|
947
|
+
replaceFileAtomically(join5(this.home, "stat.json"), files, 420);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
|
|
951
|
+
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
|
952
|
+
*/
|
|
953
|
+
async sweep() {
|
|
954
|
+
if (this.whereFolder() !== "here") return;
|
|
955
|
+
const sweptAt = Date.now();
|
|
956
|
+
const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
|
|
957
|
+
const seen = listProjectFiles(this.dir);
|
|
958
|
+
const present = new Set(seen.map((file) => file.path));
|
|
959
|
+
const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
|
|
960
|
+
const standing = this.standingAt(path);
|
|
961
|
+
return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
|
|
962
|
+
});
|
|
963
|
+
const appearedAt = (file) => Math.max(
|
|
964
|
+
changedAt(file),
|
|
965
|
+
...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
|
|
966
|
+
);
|
|
967
|
+
const events = [
|
|
968
|
+
...removed,
|
|
969
|
+
...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
|
|
970
|
+
].sort((a, b) => a.at - b.at);
|
|
971
|
+
let changed = false;
|
|
972
|
+
for (const { at, path, file } of events) {
|
|
973
|
+
if (file) {
|
|
974
|
+
changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
const known = this.tracked.get(path);
|
|
978
|
+
this.tracked.delete(path);
|
|
979
|
+
await this.record(path, known.hash, null, at);
|
|
980
|
+
changed = true;
|
|
981
|
+
}
|
|
982
|
+
if (changed) this.saveStatCache();
|
|
983
|
+
}
|
|
984
|
+
/** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
|
|
985
|
+
async storeIfPresent(path) {
|
|
986
|
+
try {
|
|
987
|
+
return await this.blobs.put(join5(this.dir, path));
|
|
988
|
+
} catch (error) {
|
|
989
|
+
if (error.code === "ENOENT") return null;
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
async observe(path, stat2, at) {
|
|
994
|
+
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
|
995
|
+
if (stat2 && known.stat === stat2) return false;
|
|
996
|
+
const hash = await this.storeIfPresent(path);
|
|
997
|
+
if (hash === null || this.whereFolder() !== "here") return false;
|
|
998
|
+
this.tracked.set(path, { hash, stat: stat2 });
|
|
999
|
+
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
|
|
1000
|
+
return known.hash !== hash || known.stat !== stat2;
|
|
1001
|
+
}
|
|
1002
|
+
async record(path, before, after, at) {
|
|
1003
|
+
const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
|
|
1004
|
+
if (endedBeforeThisWrite.length) await this.commitOutside();
|
|
1005
|
+
for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
|
|
1006
|
+
const window = this.windows.at(-1);
|
|
1007
|
+
if (window) this.touch(window, at);
|
|
1008
|
+
addChange(window ?? this.outsideGroup(), path, before, after);
|
|
1009
|
+
}
|
|
1010
|
+
/** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
|
|
1011
|
+
async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
|
|
1012
|
+
await this.sweep();
|
|
1013
|
+
const taken = await this.takeClaimed(who, paths, overwrote);
|
|
1014
|
+
if (!taken.length) return this.claimNothing(coalesceKey);
|
|
1015
|
+
const held = this.heldFor(coalesceKey, taken);
|
|
1016
|
+
if (!held) await this.commitClaim();
|
|
1017
|
+
await this.commitPending();
|
|
1018
|
+
const group = held ?? this.newGroup(who, label);
|
|
1019
|
+
for (const change of taken) addChange(group, change.path, change.before, change.after);
|
|
1020
|
+
if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
|
|
1021
|
+
const entry = await this.commit(group);
|
|
1022
|
+
return entry && { id: entry.id };
|
|
1023
|
+
}
|
|
1024
|
+
/** A claim that took nothing still ends a held claim under another key. */
|
|
1025
|
+
async claimNothing(coalesceKey) {
|
|
1026
|
+
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
/** Commits every other writer's pending changes, oldest first; open windows stay open. */
|
|
1030
|
+
async commitPending() {
|
|
1031
|
+
await this.commitOutside();
|
|
1032
|
+
for (const window of [...this.windows])
|
|
1033
|
+
if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
|
|
1034
|
+
}
|
|
1035
|
+
pendingElsewhere() {
|
|
1036
|
+
return Boolean(this.outside?.changes.size) || this.windows.some((open) => open.changes.size > 0);
|
|
1037
|
+
}
|
|
1038
|
+
/** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
|
|
1039
|
+
heldFor(key, taken) {
|
|
1040
|
+
const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
|
|
1041
|
+
const after = (path, before) => {
|
|
1042
|
+
const own = group?.changes.get(path);
|
|
1043
|
+
return own ? own.after : before;
|
|
1044
|
+
};
|
|
1045
|
+
const takenPaths = new Set(taken.map((change) => change.path));
|
|
1046
|
+
const untouched = (change) => takenPaths.has(change.path) || (this.tracked.get(change.path)?.hash ?? null) === change.after;
|
|
1047
|
+
return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
|
|
1048
|
+
}
|
|
1049
|
+
logPath(path) {
|
|
1050
|
+
return relative2(this.dir, resolve3(this.dir, path)).split(sep2).join("/");
|
|
1051
|
+
}
|
|
1052
|
+
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
|
|
1053
|
+
async takeClaimed(who, paths, overwrote) {
|
|
1054
|
+
const wanted = new Set(paths.map((path) => this.logPath(path)));
|
|
1055
|
+
const at = new Map(
|
|
1056
|
+
Object.entries(overwrote).map(([path, version]) => [
|
|
1057
|
+
this.logPath(path),
|
|
1058
|
+
hashOfVersion(version)
|
|
1059
|
+
])
|
|
1060
|
+
);
|
|
1061
|
+
const others = this.windows.filter((open) => !sameWho(open.who, who));
|
|
1062
|
+
const groups = this.outside ? [this.outside, ...others] : others;
|
|
1063
|
+
const hits = groups.flatMap(
|
|
1064
|
+
(group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
|
|
1065
|
+
);
|
|
1066
|
+
const cuts = [];
|
|
1067
|
+
const walked = hits.map(() => /* @__PURE__ */ new Set());
|
|
1068
|
+
for (const [i, { change }] of hits.entries()) {
|
|
1069
|
+
const told = at.get(change.path);
|
|
1070
|
+
cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
|
|
1071
|
+
}
|
|
1072
|
+
hits.forEach(
|
|
1073
|
+
({ change }, i) => forgetOverwrittenBytes(
|
|
1074
|
+
join5(this.dir, change.path),
|
|
1075
|
+
new Set([...walked[i]].map(hashVersion))
|
|
1076
|
+
)
|
|
1077
|
+
);
|
|
1078
|
+
return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
|
|
1079
|
+
}
|
|
1080
|
+
/** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
|
|
1081
|
+
async overwrittenBy(change, told, seen) {
|
|
1082
|
+
const absPath = join5(this.dir, change.path);
|
|
1083
|
+
let bytes;
|
|
1084
|
+
for (let hash = change.after; hash; ) {
|
|
1085
|
+
if (seen.has(hash)) return void 0;
|
|
1086
|
+
seen.add(hash);
|
|
1087
|
+
const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
|
|
1088
|
+
if (replaced === void 0) break;
|
|
1089
|
+
bytes = replaced;
|
|
1090
|
+
hash = hashOfVersion(fileContentVersion(replaced));
|
|
1091
|
+
if (hash === change.before || hash === told) return hash;
|
|
1092
|
+
}
|
|
1093
|
+
if (bytes === void 0) return void 0;
|
|
1094
|
+
const staged = join5(this.home, `overwrote-${randomUUID6()}`);
|
|
1095
|
+
await writeFile(staged, bytes);
|
|
1096
|
+
try {
|
|
1097
|
+
return await this.blobs.put(staged);
|
|
1098
|
+
} finally {
|
|
1099
|
+
await rm3(staged, { force: true });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
cutOut(group, change, cut) {
|
|
1103
|
+
const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
|
|
1104
|
+
group.changes.delete(change.path);
|
|
1105
|
+
if (kept) group.changes.set(kept.path, kept);
|
|
1106
|
+
return claimed;
|
|
1107
|
+
}
|
|
1108
|
+
/** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
|
|
1109
|
+
* so the writer's next write to a cut file would otherwise span the claimer's edit. */
|
|
1110
|
+
async commitCut(window, paths) {
|
|
1111
|
+
const part = { ...this.newGroup(window.who, window.label), startedAt: window.startedAt };
|
|
1112
|
+
for (const path of paths) {
|
|
1113
|
+
part.changes.set(path, window.changes.get(path));
|
|
1114
|
+
window.changes.delete(path);
|
|
1115
|
+
}
|
|
1116
|
+
(window.parts ??= /* @__PURE__ */ new Set()).add(part.id);
|
|
1117
|
+
window.entry = await this.commit(part);
|
|
1118
|
+
}
|
|
1119
|
+
holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
|
|
1120
|
+
clearTimeout(this.claimed?.timer);
|
|
1121
|
+
const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
|
|
1122
|
+
timer?.unref?.();
|
|
1123
|
+
this.claimed = { group, key, timer };
|
|
1124
|
+
return group.changes.size ? { id: group.id } : null;
|
|
1125
|
+
}
|
|
1126
|
+
async commitClaim() {
|
|
1127
|
+
const held = this.claimed;
|
|
1128
|
+
this.claimed = null;
|
|
1129
|
+
if (!held) return;
|
|
1130
|
+
clearTimeout(held.timer);
|
|
1131
|
+
await this.commit(held.group);
|
|
1132
|
+
}
|
|
1133
|
+
outsideGroup() {
|
|
1134
|
+
const { quietMs = 2e3, maxGroupMs = 3e4 } = this.options;
|
|
1135
|
+
const closeIn = (ms) => {
|
|
1136
|
+
const timer = setTimeout(() => this.background(() => this.commitOutside()), ms);
|
|
1137
|
+
timer.unref?.();
|
|
1138
|
+
return timer;
|
|
1139
|
+
};
|
|
1140
|
+
if (!this.outside) {
|
|
1141
|
+
this.outside = this.newGroup(OUTSIDE, OUTSIDE_LABEL);
|
|
1142
|
+
this.maxTimer = closeIn(maxGroupMs);
|
|
1143
|
+
}
|
|
1144
|
+
clearTimeout(this.quietTimer);
|
|
1145
|
+
this.quietTimer = closeIn(quietMs);
|
|
1146
|
+
return this.outside;
|
|
1147
|
+
}
|
|
1148
|
+
newGroup(who, label) {
|
|
1149
|
+
return { id: randomUUID6(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
1150
|
+
}
|
|
1151
|
+
async commitOutside() {
|
|
1152
|
+
if (this.outside?.changes.size) await this.commitClaim();
|
|
1153
|
+
clearTimeout(this.quietTimer);
|
|
1154
|
+
clearTimeout(this.maxTimer);
|
|
1155
|
+
const group = this.outside;
|
|
1156
|
+
this.outside = null;
|
|
1157
|
+
if (group) await this.commit(group);
|
|
1158
|
+
}
|
|
1159
|
+
async commit(group, extra = {}) {
|
|
1160
|
+
if (!group.changes.size) return null;
|
|
1161
|
+
const pending = this.pendingEntry(group);
|
|
1162
|
+
const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
|
|
1163
|
+
const entry = { ...pending, endedAt, ...extra };
|
|
1164
|
+
this.log.entries.push(entry);
|
|
1165
|
+
try {
|
|
1166
|
+
this.persistLog({ type: "entry", entry });
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
this.log.entries.pop();
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
for (const listener of this.listeners) listener(entry);
|
|
1172
|
+
await this.keepWithinBudget();
|
|
1173
|
+
return entry;
|
|
1174
|
+
}
|
|
1175
|
+
/** Stored bytes beyond what the current files need: a project larger than the budget still keeps its history. */
|
|
1176
|
+
historyBytes() {
|
|
1177
|
+
let current = 0;
|
|
1178
|
+
for (const hash of new Set([...this.tracked.values()].map((file) => file.hash)))
|
|
1179
|
+
current += this.blobs.size(hash);
|
|
1180
|
+
return this.blobs.bytes() - current;
|
|
1181
|
+
}
|
|
1182
|
+
async keepWithinBudget() {
|
|
1183
|
+
const budget = this.options.budgetBytes ?? 2 * 1024 ** 3;
|
|
1184
|
+
let folded = false;
|
|
1185
|
+
while (this.historyBytes() > budget && foldOldest(this.log)) {
|
|
1186
|
+
folded = true;
|
|
1187
|
+
await this.blobs.prune(
|
|
1188
|
+
/* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
if (folded) this.persistLog();
|
|
1192
|
+
}
|
|
1193
|
+
/** Hashes only pending changes point at yet, such as a claim's stored cut. */
|
|
1194
|
+
pendingHashes() {
|
|
1195
|
+
const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
|
|
1196
|
+
...group?.changes.values() ?? []
|
|
1197
|
+
]);
|
|
1198
|
+
return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
|
|
1199
|
+
}
|
|
1200
|
+
/** Before an operation or a window: every write is filed and every pending change committed, in write order. */
|
|
1201
|
+
async settle() {
|
|
1202
|
+
await this.sweep();
|
|
1203
|
+
await this.commitClaim();
|
|
1204
|
+
await this.commitPending();
|
|
1205
|
+
}
|
|
1206
|
+
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
|
|
1207
|
+
noteChange(path) {
|
|
1208
|
+
if (this.notedTimer || !affectsProjectSignature(this.dir, resolve3(this.dir, path))) return;
|
|
1209
|
+
this.notedTimer = setTimeout(() => {
|
|
1210
|
+
this.notedTimer = null;
|
|
1211
|
+
this.background(() => this.sweep());
|
|
1212
|
+
}, 20);
|
|
1213
|
+
this.notedTimer.unref?.();
|
|
1214
|
+
}
|
|
1215
|
+
background(task) {
|
|
1216
|
+
this.queue(task).catch((error) => {
|
|
1217
|
+
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
beginWindow(who, label, idleMs) {
|
|
1221
|
+
return this.queue(async () => {
|
|
1222
|
+
await this.settle();
|
|
1223
|
+
const window = { ...this.newGroup(who, label), idleMs };
|
|
1224
|
+
this.windows.push(window);
|
|
1225
|
+
this.touch(window, Date.now());
|
|
1226
|
+
const close = () => this.queue(() => this.sweepAndEnd(window));
|
|
1227
|
+
return { id: window.id, startedAt: window.startedAt, close };
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
/** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
|
|
1231
|
+
touch(window, at) {
|
|
1232
|
+
window.lastWriteAt = Math.max(window.lastWriteAt ?? at, at);
|
|
1233
|
+
clearTimeout(window.idleTimer);
|
|
1234
|
+
if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
|
|
1235
|
+
window.idleTimer = setTimeout(
|
|
1236
|
+
() => this.background(() => this.sweepAndEnd(window)),
|
|
1237
|
+
window.idleMs
|
|
1238
|
+
);
|
|
1239
|
+
window.idleTimer.unref?.();
|
|
1240
|
+
}
|
|
1241
|
+
async sweepAndEnd(window) {
|
|
1242
|
+
await this.sweep();
|
|
1243
|
+
return this.endWindow(window);
|
|
1244
|
+
}
|
|
1245
|
+
/** Commits an open window once; ending it again returns the entry it became. */
|
|
1246
|
+
async endWindow(window) {
|
|
1247
|
+
if (!this.windows.includes(window)) return window.entry ?? null;
|
|
1248
|
+
clearTimeout(window.idleTimer);
|
|
1249
|
+
await this.commitClaim();
|
|
1250
|
+
this.windows = this.windows.filter((open) => open !== window);
|
|
1251
|
+
window.entry = await this.commit(window) ?? window.entry ?? null;
|
|
1252
|
+
return window.entry;
|
|
1253
|
+
}
|
|
1254
|
+
/** Every pending write, open window and outside group, committed: for flush and close. */
|
|
1255
|
+
async settleAll() {
|
|
1256
|
+
await this.sweep();
|
|
1257
|
+
await this.commitClaim();
|
|
1258
|
+
for (const window of [...this.windows]) await this.endWindow(window);
|
|
1259
|
+
await this.commitOutside();
|
|
1260
|
+
}
|
|
1261
|
+
/** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
|
|
1262
|
+
standingAt(path) {
|
|
1263
|
+
const parts = path.split("/");
|
|
1264
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
1265
|
+
const at = parts.slice(0, depth).join("/");
|
|
1266
|
+
const stat2 = lstatSync2(join5(this.dir, at), { throwIfNoEntry: false });
|
|
1267
|
+
if (!stat2) return void 0;
|
|
1268
|
+
if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
|
|
1269
|
+
}
|
|
1270
|
+
return void 0;
|
|
1271
|
+
}
|
|
1272
|
+
/** What a write of `path` would have to delete: a file or link at or above it, or a file in a folder at it. */
|
|
1273
|
+
inTheWay(path, deleted) {
|
|
1274
|
+
const standing = this.standingAt(path);
|
|
1275
|
+
if (!standing) return void 0;
|
|
1276
|
+
if (standing.stat.isSymbolicLink()) return standing.at;
|
|
1277
|
+
if (standing.at === path && !standing.stat.isDirectory()) return void 0;
|
|
1278
|
+
const real = realpathSync.native(join5(this.dir, standing.at));
|
|
1279
|
+
const at = relative2(realpathSync.native(this.dir), real).split(sep2).join("/");
|
|
1280
|
+
if (standing.at !== path) return deleted.has(at) ? void 0 : at;
|
|
1281
|
+
return fileLeftIn(real, at, deleted);
|
|
1282
|
+
}
|
|
1283
|
+
/** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
|
|
1284
|
+
async writeAs(who, label, target, extra) {
|
|
1285
|
+
this.assertWritable();
|
|
1286
|
+
const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
|
|
1287
|
+
const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
|
|
1288
|
+
const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
|
|
1289
|
+
if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
|
|
1290
|
+
const group = this.newGroup(who, label);
|
|
1291
|
+
this.windows.push(group);
|
|
1292
|
+
try {
|
|
1293
|
+
for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
|
|
1294
|
+
await this.sweep();
|
|
1295
|
+
} finally {
|
|
1296
|
+
this.windows = this.windows.filter((open) => open !== group);
|
|
1297
|
+
}
|
|
1298
|
+
return this.commit(group, extra);
|
|
1299
|
+
}
|
|
1300
|
+
/** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
|
|
1301
|
+
async writeProjectFile(path, hash) {
|
|
1302
|
+
this.assertWritable();
|
|
1303
|
+
const absPath = join5(this.dir, path);
|
|
1304
|
+
const { writeToken } = this;
|
|
1305
|
+
if (writeToken) {
|
|
1306
|
+
const version = hash === null ? DELETED_VERSION : hashVersion(hash);
|
|
1307
|
+
recordFileWriteReceipt(absPath, { path, version, writeToken });
|
|
1308
|
+
}
|
|
1309
|
+
if (hash === null) {
|
|
1310
|
+
this.assertWritable();
|
|
1311
|
+
rmSync2(absPath, { force: true });
|
|
1312
|
+
} else {
|
|
1313
|
+
await removeEmptyFolders(absPath);
|
|
1314
|
+
await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
entry(id) {
|
|
1318
|
+
const entry = this.log.entries.find((candidate) => candidate.id === id);
|
|
1319
|
+
if (!entry) throw new Error("That change is no longer kept in this project's history.");
|
|
1320
|
+
return entry;
|
|
1321
|
+
}
|
|
1322
|
+
undoLabel(entry) {
|
|
1323
|
+
if (!entry.undoes) return `Undid: ${entry.label}`;
|
|
1324
|
+
const original = this.log.entries.find((candidate) => candidate.id === entry.undoes);
|
|
1325
|
+
return `Redid: ${original?.label ?? entry.label}`;
|
|
1326
|
+
}
|
|
1327
|
+
async undoNow(id, who, mode) {
|
|
1328
|
+
const entry = this.entry(id);
|
|
1329
|
+
const changed = this.movedOn(entry);
|
|
1330
|
+
if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
|
|
1331
|
+
if (mode === "back-to-before") {
|
|
1332
|
+
const index = this.log.entries.indexOf(entry);
|
|
1333
|
+
const point = index > 0 ? this.log.entries[index - 1].id : START;
|
|
1334
|
+
return {
|
|
1335
|
+
ok: true,
|
|
1336
|
+
entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
const target = new Map(
|
|
1340
|
+
entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
|
|
1341
|
+
);
|
|
1342
|
+
return {
|
|
1343
|
+
ok: true,
|
|
1344
|
+
entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
movedOn(entry) {
|
|
1348
|
+
return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
|
|
1349
|
+
}
|
|
1350
|
+
next(direction, who) {
|
|
1351
|
+
const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
|
|
1352
|
+
const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
|
|
1353
|
+
(group) => group?.changes.size && mine(group.who)
|
|
1354
|
+
);
|
|
1355
|
+
if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
|
|
1356
|
+
const everyone = this.options.undoScope === "everyone";
|
|
1357
|
+
const ofOpenTurn = (entry) => this.windows.some((open) => open.parts?.has(entry.id));
|
|
1358
|
+
return stepTarget(
|
|
1359
|
+
this.log.entries,
|
|
1360
|
+
direction,
|
|
1361
|
+
(entry) => mine(entry.who) || everyone && !ofOpenTurn(entry)
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
/** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
|
|
1365
|
+
pendingEntry(group) {
|
|
1366
|
+
const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
1367
|
+
const { id, who, label, startedAt, lastWriteAt } = group;
|
|
1368
|
+
return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
|
|
1369
|
+
}
|
|
1370
|
+
conflict(entry, changed) {
|
|
1371
|
+
const paths = new Set(changed.map((file) => file.path));
|
|
1372
|
+
const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
|
|
1373
|
+
const newer = after.filter((later) => later.files.some((file) => paths.has(file.path)));
|
|
1374
|
+
return { files: [...paths], newer: newer.map((later) => later.id) };
|
|
1375
|
+
}
|
|
1376
|
+
async restoreNow(point, who, label) {
|
|
1377
|
+
const files = manifestAt(this.log, point);
|
|
1378
|
+
if (!files) throw new Error("That point is no longer kept in this project's history.");
|
|
1379
|
+
const target = new Map(
|
|
1380
|
+
[...this.tracked.keys()].map((path) => [path, null])
|
|
1381
|
+
);
|
|
1382
|
+
for (const [path, hash] of files) target.set(path, hash);
|
|
1383
|
+
return this.writeAs(who, label, target, { restoredTo: point });
|
|
1384
|
+
}
|
|
1385
|
+
/** Settles, then runs `task` with its writes labelled `writeToken`. */
|
|
1386
|
+
operation(writeToken, task) {
|
|
1387
|
+
return this.queue(async () => {
|
|
1388
|
+
await this.settle();
|
|
1389
|
+
this.writeToken = writeToken;
|
|
1390
|
+
try {
|
|
1391
|
+
return await task();
|
|
1392
|
+
} finally {
|
|
1393
|
+
this.writeToken = void 0;
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
pointLabel(point) {
|
|
1398
|
+
return point === START ? "the start" : this.entry(point).label;
|
|
1399
|
+
}
|
|
1400
|
+
api() {
|
|
1401
|
+
return {
|
|
1402
|
+
projectId: this.projectId,
|
|
1403
|
+
beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
|
|
1404
|
+
claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
|
|
1405
|
+
noteChange: (path) => {
|
|
1406
|
+
if (!this.closed) this.noteChange(path);
|
|
1407
|
+
},
|
|
1408
|
+
list: () => {
|
|
1409
|
+
this.assertOpen();
|
|
1410
|
+
const undone = undoneIds(this.log.entries);
|
|
1411
|
+
return this.log.entries.map((entry) => ({
|
|
1412
|
+
...entry,
|
|
1413
|
+
pinned: this.log.pins.has(entry.id),
|
|
1414
|
+
undone: undone.has(entry.id)
|
|
1415
|
+
}));
|
|
1416
|
+
},
|
|
1417
|
+
step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
|
|
1418
|
+
const target = this.next(direction, who);
|
|
1419
|
+
return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
|
|
1420
|
+
}),
|
|
1421
|
+
undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
|
|
1422
|
+
restore: (point, who, { writeToken } = {}) => this.operation(
|
|
1423
|
+
writeToken,
|
|
1424
|
+
() => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
|
|
1425
|
+
),
|
|
1426
|
+
peek: (point) => {
|
|
1427
|
+
this.assertOpen();
|
|
1428
|
+
const files = manifestAt(this.log, point);
|
|
1429
|
+
return files && Object.fromEntries(files);
|
|
1430
|
+
},
|
|
1431
|
+
checkout: (entryId, side, emptyDir) => this.queue(async () => {
|
|
1432
|
+
this.entry(entryId);
|
|
1433
|
+
if (!existsSync2(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
|
|
1434
|
+
const dest = resolve3(emptyDir);
|
|
1435
|
+
if (isWithin(this.dir, dest))
|
|
1436
|
+
throw new Error(`Checkout writes outside the project only: ${dest}`);
|
|
1437
|
+
if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
|
|
1438
|
+
throw new Error(`Checkout writes into an empty folder only: ${dest}`);
|
|
1439
|
+
const files = manifestAround(this.log, entryId, side);
|
|
1440
|
+
try {
|
|
1441
|
+
for (const [path, hash] of files) await this.blobs.writeTo(hash, join5(dest, path));
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
for (const name of await readdir3(dest).catch(() => []))
|
|
1444
|
+
await rm3(join5(dest, name), { recursive: true, force: true });
|
|
1445
|
+
throw error;
|
|
1446
|
+
}
|
|
1447
|
+
}),
|
|
1448
|
+
next: (direction, who) => {
|
|
1449
|
+
this.assertOpen();
|
|
1450
|
+
return this.next(direction, who);
|
|
1451
|
+
},
|
|
1452
|
+
readBlob: async (hash) => {
|
|
1453
|
+
this.assertOpen();
|
|
1454
|
+
return this.blobs.read(hash);
|
|
1455
|
+
},
|
|
1456
|
+
pin: (id, pinned) => {
|
|
1457
|
+
this.assertOpen();
|
|
1458
|
+
this.entry(id);
|
|
1459
|
+
if (pinned) this.log.pins.add(id);
|
|
1460
|
+
else this.log.pins.delete(id);
|
|
1461
|
+
this.persistLog({ type: "pin", id, pinned });
|
|
1462
|
+
},
|
|
1463
|
+
onEntry: (listener) => {
|
|
1464
|
+
this.listeners.add(listener);
|
|
1465
|
+
return () => this.listeners.delete(listener);
|
|
1466
|
+
},
|
|
1467
|
+
flush: () => this.queue(() => this.settleAll()),
|
|
1468
|
+
replacedAtPath: () => this.whereFolder() === "replaced",
|
|
1469
|
+
close: () => this.closing ??= (async () => {
|
|
1470
|
+
if (this.notedTimer) clearTimeout(this.notedTimer);
|
|
1471
|
+
const settled = this.queue(() => this.settleAll());
|
|
1472
|
+
this.closed = true;
|
|
1473
|
+
await settled;
|
|
1474
|
+
})()
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
function isWithin(dir, path) {
|
|
1479
|
+
let probe = resolve3(path);
|
|
1480
|
+
while (!existsSync2(probe) && dirname4(probe) !== probe) probe = dirname4(probe);
|
|
1481
|
+
const rel = relative2(realpathSync.native(dir), realpathSync.native(probe));
|
|
1482
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep2}`) && !isAbsolute2(rel);
|
|
1483
|
+
}
|
|
1484
|
+
function missingIsEmpty(error) {
|
|
1485
|
+
if (error.code === "ENOENT") return [];
|
|
1486
|
+
throw error;
|
|
1487
|
+
}
|
|
1488
|
+
var HistoryClosedError = class extends Error {
|
|
1489
|
+
constructor(message) {
|
|
1490
|
+
super(message);
|
|
1491
|
+
this.name = "HistoryClosedError";
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
async function openProjectHistory(options) {
|
|
1495
|
+
if (!existsSync2(options.projectDir))
|
|
1496
|
+
throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
|
|
1497
|
+
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
|
1498
|
+
const release = await takeHistoryOwnership(
|
|
1499
|
+
join5(options.historyRoot, projectId),
|
|
1500
|
+
options.ownerWaitMs ?? 5e3
|
|
1501
|
+
);
|
|
1502
|
+
try {
|
|
1503
|
+
const blobs = await openBlobStore(join5(options.historyRoot, projectId, "blobs"));
|
|
1504
|
+
const engine = new Engine(options, projectId, blobs);
|
|
1505
|
+
await engine.queue(() => engine.start());
|
|
1506
|
+
pruneGoneProjectHistoriesDaily(options.historyRoot, engine.now(), {
|
|
1507
|
+
onError: options.onError,
|
|
1508
|
+
budgetMs: options.pruneGoneProjectsBudgetMs
|
|
1509
|
+
});
|
|
1510
|
+
const api = engine.api();
|
|
1511
|
+
return {
|
|
1512
|
+
...api,
|
|
1513
|
+
close: () => api.close().finally(release)
|
|
1514
|
+
};
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
release();
|
|
1517
|
+
throw error;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// src/history/historyCache.ts
|
|
1522
|
+
function historyCache(open) {
|
|
1523
|
+
const histories = /* @__PURE__ */ new Map();
|
|
1524
|
+
const get = async (projectDir) => {
|
|
1525
|
+
let cached = histories.get(projectDir);
|
|
1526
|
+
if (!cached) histories.set(projectDir, cached = open(projectDir));
|
|
1527
|
+
const opened = await cached;
|
|
1528
|
+
if (!opened?.replacedAtPath()) return opened;
|
|
1529
|
+
const reopen = () => open(projectDir);
|
|
1530
|
+
if (histories.get(projectDir) === cached)
|
|
1531
|
+
histories.set(projectDir, opened.close().then(reopen, reopen));
|
|
1532
|
+
return get(projectDir);
|
|
1533
|
+
};
|
|
1534
|
+
return {
|
|
1535
|
+
get,
|
|
1536
|
+
/** The history if one was opened, without opening it. */
|
|
1537
|
+
peek: (projectDir) => histories.get(projectDir),
|
|
1538
|
+
forget: (projectDir) => histories.delete(projectDir),
|
|
1539
|
+
closeAll: () => Promise.all(
|
|
1540
|
+
[...histories.values()].map((opened) => opened.then((history) => history?.close()))
|
|
1541
|
+
)
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
export {
|
|
1546
|
+
STUDIO_SIGNATURE_MANIFEST_PATHS,
|
|
1547
|
+
affectsProjectSignature,
|
|
1548
|
+
resolveProjectSignature,
|
|
1549
|
+
resolveProjectAndSignature,
|
|
1550
|
+
createProjectSignature,
|
|
1551
|
+
fileContentVersion,
|
|
1552
|
+
DELETED_VERSION,
|
|
1553
|
+
settledFileTag,
|
|
1554
|
+
createWriteToken,
|
|
1555
|
+
recordFileWriteReceipt,
|
|
1556
|
+
clearFileWriteReceipt,
|
|
1557
|
+
identifyFileWrite,
|
|
1558
|
+
consumeFileWriteReceipt,
|
|
1559
|
+
HistoryIdError,
|
|
1560
|
+
HistoryBusyError,
|
|
1561
|
+
listProjectHistories,
|
|
1562
|
+
pruneGoneProjectHistories,
|
|
1563
|
+
START,
|
|
1564
|
+
DEFAULT_HISTORY_ROOT,
|
|
1565
|
+
MAX_WINDOW_IDLE_MS,
|
|
1566
|
+
UNDO_MODES,
|
|
1567
|
+
HistoryClosedError,
|
|
1568
|
+
openProjectHistory,
|
|
1569
|
+
historyCache
|
|
1570
|
+
};
|
|
1571
|
+
//# sourceMappingURL=chunk-IG4DRZI2.js.map
|