tledger 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -155
- package/bin/token-ledger-cache-data.mjs +492 -0
- package/bin/token-ledger-cache-image.mjs +7 -1147
- package/bin/token-ledger-cache-sections.mjs +848 -0
- package/bin/token-ledger-cost-terminal.mjs +234 -0
- package/bin/token-ledger-image-layout.mjs +20 -0
- package/bin/token-ledger-image-primitives.mjs +192 -0
- package/bin/token-ledger-report-data.mjs +1159 -0
- package/bin/token-ledger-source-status.mjs +31 -0
- package/bin/token-ledger-terminal.mjs +245 -69
- package/bin/token-ledger-trend-image.mjs +2039 -1599
- package/bin/token-ledger-trend-terminal.mjs +273 -150
- package/bin/token-ledger-trend.mjs +262 -210
- package/bin/token-ledger-tui.mjs +180 -51
- package/bin/token-ledger.mjs +747 -194
- package/docs/durable-ledger-operations.md +198 -0
- package/docs/release-notes-0.4.0.md +41 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-calendar.mjs +225 -0
- package/lib/token-ledger-collection.mjs +100 -0
- package/lib/token-ledger-importer.mjs +3334 -480
- package/lib/token-ledger-labels.mjs +66 -0
- package/lib/token-ledger-ledger.mjs +6056 -0
- package/lib/token-ledger-quota-contract.mjs +38 -0
- package/lib/token-ledger-range-analysis.mjs +120 -0
- package/lib/token-ledger-rates.mjs +330 -0
- package/lib/token-ledger-snapshot.mjs +336 -35
- package/lib/token-ledger-terminal-text.mjs +11 -0
- package/lib/token-ledger-usage.mjs +339 -33
- package/package.json +13 -10
- package/bin/token-ledger-rates.mjs +0 -65
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
+
lstat,
|
|
4
5
|
mkdir,
|
|
5
|
-
|
|
6
|
+
open,
|
|
7
|
+
readdir,
|
|
6
8
|
rename,
|
|
7
9
|
rm,
|
|
8
|
-
stat,
|
|
9
|
-
writeFile,
|
|
10
10
|
} from "node:fs/promises";
|
|
11
|
-
import {
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import {
|
|
13
|
+
basename,
|
|
14
|
+
dirname,
|
|
15
|
+
isAbsolute,
|
|
16
|
+
relative,
|
|
17
|
+
resolve,
|
|
18
|
+
} from "node:path";
|
|
12
19
|
import { promisify } from "node:util";
|
|
13
20
|
import {
|
|
14
21
|
gunzip as gunzipCallback,
|
|
@@ -23,12 +30,34 @@ import {
|
|
|
23
30
|
usageBuckets,
|
|
24
31
|
} from "./token-ledger-usage.mjs";
|
|
25
32
|
|
|
33
|
+
const DURABLE_LEDGER_BASENAME = "token-ledger-ledger.sqlite";
|
|
34
|
+
const DURABLE_LEDGER_PATH_SUFFIXES = Object.freeze([
|
|
35
|
+
DURABLE_LEDGER_BASENAME,
|
|
36
|
+
`${DURABLE_LEDGER_BASENAME}.writer-lock.sqlite`,
|
|
37
|
+
`${DURABLE_LEDGER_BASENAME}-journal`,
|
|
38
|
+
`${DURABLE_LEDGER_BASENAME}-wal`,
|
|
39
|
+
`${DURABLE_LEDGER_BASENAME}-shm`,
|
|
40
|
+
]);
|
|
41
|
+
const PRIVATE_STATE_DIRECTORY = resolve(homedir(), ".token-ledger");
|
|
42
|
+
const TEST_PRIVATE_STATE_DIRECTORY =
|
|
43
|
+
process.env.NODE_TEST_CONTEXT && process.env.TOKEN_LEDGER_TEST_STATE_ROOT
|
|
44
|
+
? resolve(process.env.TOKEN_LEDGER_TEST_STATE_ROOT)
|
|
45
|
+
: null;
|
|
46
|
+
const PRIVATE_STATE_DIRECTORIES = Object.freeze([
|
|
47
|
+
PRIVATE_STATE_DIRECTORY,
|
|
48
|
+
...(TEST_PRIVATE_STATE_DIRECTORY ? [TEST_PRIVATE_STATE_DIRECTORY] : []),
|
|
49
|
+
]);
|
|
50
|
+
|
|
26
51
|
export const DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
|
|
27
52
|
export const DEFAULT_SNAPSHOT_TARGET_BYTES = 12 * 1024 * 1024;
|
|
28
53
|
export const DEFAULT_SNAPSHOT_MAX_JSON_BYTES = 64 * 1024 * 1024;
|
|
29
54
|
export const DEFAULT_SNAPSHOT_TARGET_JSON_BYTES = 48 * 1024 * 1024;
|
|
30
55
|
|
|
31
56
|
const PRECOMPACT_BUCKET_COUNT = 50_000;
|
|
57
|
+
const SNAPSHOT_READ_CHUNK_BYTES = 64 * 1024;
|
|
58
|
+
const SNAPSHOT_TEMP_HASH_LENGTH = 16;
|
|
59
|
+
const SNAPSHOT_TEMP_UUID_PATTERN =
|
|
60
|
+
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
32
61
|
|
|
33
62
|
const gzip = promisify(gzipCallback);
|
|
34
63
|
const gunzip = promisify(gunzipCallback);
|
|
@@ -37,6 +66,123 @@ function snapshotEncoding(path) {
|
|
|
37
66
|
return path.toLowerCase().endsWith(".gz") ? "gzip" : "json";
|
|
38
67
|
}
|
|
39
68
|
|
|
69
|
+
function snapshotDestinationHash(destination) {
|
|
70
|
+
return createHash("sha256")
|
|
71
|
+
.update(destination)
|
|
72
|
+
.digest("hex")
|
|
73
|
+
.slice(0, SNAPSHOT_TEMP_HASH_LENGTH);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function snapshotTemporaryName(destination) {
|
|
77
|
+
return `.token-ledger-${snapshotDestinationHash(destination)}-${process.pid}-${randomUUID()}.tmp`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function snapshotDestinationError(destination) {
|
|
81
|
+
const error = new Error(
|
|
82
|
+
`Snapshot destination is reserved for durable ledger state: ${destination}`,
|
|
83
|
+
);
|
|
84
|
+
error.code = "ERR_SNAPSHOT_RESERVED_PATH";
|
|
85
|
+
return error;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pathIsInside(directory, destination) {
|
|
89
|
+
const child = relative(directory, destination);
|
|
90
|
+
return child === "" || (!child.startsWith("..") && !isAbsolute(child));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assertSnapshotDestination(destination, reservedPaths) {
|
|
94
|
+
const explicitlyReserved = new Set(
|
|
95
|
+
reservedPaths.map((path) => resolve(path)),
|
|
96
|
+
);
|
|
97
|
+
if (
|
|
98
|
+
explicitlyReserved.has(destination) ||
|
|
99
|
+
(
|
|
100
|
+
PRIVATE_STATE_DIRECTORIES.some((directory) =>
|
|
101
|
+
pathIsInside(directory, destination),
|
|
102
|
+
) &&
|
|
103
|
+
DURABLE_LEDGER_PATH_SUFFIXES.includes(basename(destination))
|
|
104
|
+
)
|
|
105
|
+
) {
|
|
106
|
+
throw snapshotDestinationError(destination);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function processIsDemonstrablyGone(pid) {
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return false;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return error?.code === "ESRCH";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function removeOrphanedSnapshotCandidates(destination) {
|
|
120
|
+
const directory = dirname(destination);
|
|
121
|
+
const destinationHash = snapshotDestinationHash(destination);
|
|
122
|
+
const currentUid = process.getuid instanceof Function
|
|
123
|
+
? process.getuid()
|
|
124
|
+
: null;
|
|
125
|
+
const candidatePattern = new RegExp(
|
|
126
|
+
`^\\.token-ledger-${destinationHash}-([1-9][0-9]*)-${SNAPSHOT_TEMP_UUID_PATTERN}\\.tmp$`,
|
|
127
|
+
);
|
|
128
|
+
let names;
|
|
129
|
+
try {
|
|
130
|
+
names = await readdir(directory);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (["ENOENT", "ENOTDIR"].includes(error?.code)) return;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
for (const name of names) {
|
|
136
|
+
const match = name.match(candidatePattern);
|
|
137
|
+
if (!match) continue;
|
|
138
|
+
const ownerPid = Number(match[1]);
|
|
139
|
+
if (
|
|
140
|
+
!Number.isSafeInteger(ownerPid) ||
|
|
141
|
+
ownerPid === process.pid ||
|
|
142
|
+
!processIsDemonstrablyGone(ownerPid)
|
|
143
|
+
) continue;
|
|
144
|
+
const candidate = resolve(directory, name);
|
|
145
|
+
let candidateStat;
|
|
146
|
+
try {
|
|
147
|
+
candidateStat = await lstat(candidate);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (["ENOENT", "ENOTDIR"].includes(error?.code)) continue;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
// Only unlink an ordinary, single-link candidate. Symlinks, directories,
|
|
153
|
+
// hard links, and targets that race away are left untouched.
|
|
154
|
+
if (
|
|
155
|
+
!candidateStat.isFile() ||
|
|
156
|
+
Number(candidateStat.nlink) !== 1 ||
|
|
157
|
+
(currentUid !== null && Number(candidateStat.uid) !== currentUid)
|
|
158
|
+
) continue;
|
|
159
|
+
let confirmedStat;
|
|
160
|
+
try {
|
|
161
|
+
confirmedStat = await lstat(candidate);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (["ENOENT", "ENOTDIR"].includes(error?.code)) continue;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
!confirmedStat.isFile() ||
|
|
168
|
+
Number(confirmedStat.nlink) !== 1 ||
|
|
169
|
+
Number(confirmedStat.dev) !== Number(candidateStat.dev) ||
|
|
170
|
+
Number(confirmedStat.ino) !== Number(candidateStat.ino) ||
|
|
171
|
+
(currentUid !== null && Number(confirmedStat.uid) !== currentUid)
|
|
172
|
+
) continue;
|
|
173
|
+
try {
|
|
174
|
+
await rm(candidate, { force: true });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (
|
|
177
|
+
["ENOENT", "ENOTDIR", "EISDIR", "EACCES", "EPERM"].includes(
|
|
178
|
+
error?.code,
|
|
179
|
+
)
|
|
180
|
+
) continue;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
40
186
|
function formatMebibytes(bytes) {
|
|
41
187
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
42
188
|
}
|
|
@@ -118,6 +264,90 @@ async function boundedEncoding(
|
|
|
118
264
|
};
|
|
119
265
|
}
|
|
120
266
|
|
|
267
|
+
function snapshotSizeLimitError(message) {
|
|
268
|
+
const error = new Error(message);
|
|
269
|
+
error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
|
|
270
|
+
return error;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function snapshotNotRegularFileError() {
|
|
274
|
+
const error = new Error("Snapshot input must be a regular file.");
|
|
275
|
+
error.code = "ERR_SNAPSHOT_NOT_REGULAR";
|
|
276
|
+
return error;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function snapshotCandidateChangedError() {
|
|
280
|
+
const error = new Error(
|
|
281
|
+
"Snapshot candidate changed before it could be published.",
|
|
282
|
+
);
|
|
283
|
+
error.code = "ERR_SNAPSHOT_CANDIDATE_CHANGED";
|
|
284
|
+
return error;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isSameSnapshotCandidate(candidate, pinned) {
|
|
288
|
+
return Boolean(
|
|
289
|
+
candidate?.isFile() &&
|
|
290
|
+
Number(candidate.nlink) === 1 &&
|
|
291
|
+
Number(candidate.dev) === Number(pinned.dev) &&
|
|
292
|
+
Number(candidate.ino) === Number(pinned.ino),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function readBoundedSnapshot(source, sourceLimit, encoding) {
|
|
297
|
+
const handle = await open(
|
|
298
|
+
source,
|
|
299
|
+
fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0),
|
|
300
|
+
);
|
|
301
|
+
try {
|
|
302
|
+
const sourceStats = await handle.stat();
|
|
303
|
+
if (!sourceStats.isFile()) {
|
|
304
|
+
throw snapshotNotRegularFileError();
|
|
305
|
+
}
|
|
306
|
+
if (sourceStats.size > sourceLimit) {
|
|
307
|
+
throw snapshotSizeLimitError(
|
|
308
|
+
`Snapshot input is ${formatMebibytes(sourceStats.size)}, exceeding the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const chunks = [];
|
|
313
|
+
let bytesRead = 0;
|
|
314
|
+
while (bytesRead < sourceLimit) {
|
|
315
|
+
const chunkLength = Math.min(
|
|
316
|
+
SNAPSHOT_READ_CHUNK_BYTES,
|
|
317
|
+
sourceLimit - bytesRead,
|
|
318
|
+
);
|
|
319
|
+
const chunk = Buffer.allocUnsafe(chunkLength);
|
|
320
|
+
const result = await handle.read(
|
|
321
|
+
chunk,
|
|
322
|
+
0,
|
|
323
|
+
chunkLength,
|
|
324
|
+
bytesRead,
|
|
325
|
+
);
|
|
326
|
+
if (result.bytesRead === 0) break;
|
|
327
|
+
chunks.push(chunk.subarray(0, result.bytesRead));
|
|
328
|
+
bytesRead += result.bytesRead;
|
|
329
|
+
}
|
|
330
|
+
if (bytesRead >= sourceLimit) {
|
|
331
|
+
const extra = Buffer.allocUnsafe(1);
|
|
332
|
+
const result = await handle.read(extra, 0, 1, bytesRead);
|
|
333
|
+
if (result.bytesRead > 0) {
|
|
334
|
+
throw snapshotSizeLimitError(
|
|
335
|
+
`Snapshot input exceeds the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const finalStats = await handle.stat();
|
|
340
|
+
if (finalStats.size > sourceLimit) {
|
|
341
|
+
throw snapshotSizeLimitError(
|
|
342
|
+
`Snapshot input grew beyond the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return Buffer.concat(chunks, bytesRead);
|
|
346
|
+
} finally {
|
|
347
|
+
await handle.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
121
351
|
export async function readPrivateSnapshot(
|
|
122
352
|
input,
|
|
123
353
|
{
|
|
@@ -134,22 +364,7 @@ export async function readPrivateSnapshot(
|
|
|
134
364
|
}
|
|
135
365
|
const encoding = snapshotEncoding(source);
|
|
136
366
|
const sourceLimit = encoding === "gzip" ? maxBytes : maxJsonBytes;
|
|
137
|
-
const
|
|
138
|
-
if (sourceStats.size > sourceLimit) {
|
|
139
|
-
const error = new Error(
|
|
140
|
-
`Snapshot input is ${formatMebibytes(sourceStats.size)}, exceeding the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
|
|
141
|
-
);
|
|
142
|
-
error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
|
|
143
|
-
throw error;
|
|
144
|
-
}
|
|
145
|
-
const encoded = await readFile(source);
|
|
146
|
-
if (encoded.byteLength > sourceLimit) {
|
|
147
|
-
const error = new Error(
|
|
148
|
-
`Snapshot input grew beyond the ${formatMebibytes(sourceLimit)} ${encoding === "gzip" ? "compressed" : "JSON"} read limit.`,
|
|
149
|
-
);
|
|
150
|
-
error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
|
|
151
|
-
throw error;
|
|
152
|
-
}
|
|
367
|
+
const encoded = await readBoundedSnapshot(source, sourceLimit, encoding);
|
|
153
368
|
let decoded;
|
|
154
369
|
if (encoding === "gzip") {
|
|
155
370
|
try {
|
|
@@ -167,16 +382,14 @@ export async function readPrivateSnapshot(
|
|
|
167
382
|
decoded = encoded;
|
|
168
383
|
}
|
|
169
384
|
if (decoded.byteLength > maxJsonBytes) {
|
|
170
|
-
|
|
385
|
+
throw snapshotSizeLimitError(
|
|
171
386
|
`Snapshot JSON representation is ${formatMebibytes(decoded.byteLength)}, exceeding the ${formatMebibytes(maxJsonBytes)} read limit.`,
|
|
172
387
|
);
|
|
173
|
-
error.code = "ERR_SNAPSHOT_SIZE_LIMIT";
|
|
174
|
-
throw error;
|
|
175
388
|
}
|
|
176
389
|
return JSON.parse(decoded.toString("utf8"));
|
|
177
390
|
}
|
|
178
391
|
|
|
179
|
-
export async function
|
|
392
|
+
export async function stagePrivateSnapshot(
|
|
180
393
|
output,
|
|
181
394
|
snapshot,
|
|
182
395
|
{
|
|
@@ -187,13 +400,15 @@ export async function writePrivateSnapshot(
|
|
|
187
400
|
DEFAULT_SNAPSHOT_TARGET_JSON_BYTES,
|
|
188
401
|
maxJsonBytes,
|
|
189
402
|
),
|
|
403
|
+
reservedPaths = [],
|
|
190
404
|
} = {},
|
|
191
405
|
) {
|
|
192
406
|
const destination = resolve(output);
|
|
407
|
+
assertSnapshotDestination(destination, reservedPaths);
|
|
193
408
|
const directory = dirname(destination);
|
|
194
409
|
const temporary = resolve(
|
|
195
410
|
directory,
|
|
196
|
-
|
|
411
|
+
snapshotTemporaryName(destination),
|
|
197
412
|
);
|
|
198
413
|
const encoding = snapshotEncoding(destination);
|
|
199
414
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
@@ -241,19 +456,85 @@ export async function writePrivateSnapshot(
|
|
|
241
456
|
}
|
|
242
457
|
|
|
243
458
|
await mkdir(directory, { recursive: true });
|
|
459
|
+
await removeOrphanedSnapshotCandidates(destination);
|
|
460
|
+
let candidateHandle = null;
|
|
461
|
+
let candidateIdentity = null;
|
|
244
462
|
try {
|
|
245
|
-
await
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
463
|
+
candidateHandle = await open(
|
|
464
|
+
temporary,
|
|
465
|
+
fsConstants.O_WRONLY |
|
|
466
|
+
fsConstants.O_CREAT |
|
|
467
|
+
fsConstants.O_EXCL |
|
|
468
|
+
(fsConstants.O_NOFOLLOW ?? 0),
|
|
469
|
+
0o600,
|
|
470
|
+
);
|
|
471
|
+
await candidateHandle.writeFile(encoded);
|
|
472
|
+
await candidateHandle.chmod(0o600);
|
|
473
|
+
await candidateHandle.sync();
|
|
474
|
+
candidateIdentity = await candidateHandle.stat();
|
|
475
|
+
if (!candidateIdentity.isFile() || Number(candidateIdentity.nlink) !== 1) {
|
|
476
|
+
throw snapshotCandidateChangedError();
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
await candidateHandle?.close();
|
|
253
480
|
await rm(temporary, { force: true });
|
|
481
|
+
throw error;
|
|
254
482
|
}
|
|
255
483
|
|
|
484
|
+
let published = false;
|
|
485
|
+
let discarded = false;
|
|
486
|
+
const publish = async () => {
|
|
487
|
+
if (published) return;
|
|
488
|
+
if (discarded) {
|
|
489
|
+
throw new Error("Cannot publish a discarded snapshot candidate.");
|
|
490
|
+
}
|
|
491
|
+
let currentIdentity;
|
|
492
|
+
try {
|
|
493
|
+
currentIdentity = await lstat(temporary);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (["ENOENT", "ENOTDIR"].includes(error?.code)) {
|
|
496
|
+
throw snapshotCandidateChangedError();
|
|
497
|
+
}
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
if (!isSameSnapshotCandidate(currentIdentity, candidateIdentity)) {
|
|
501
|
+
throw snapshotCandidateChangedError();
|
|
502
|
+
}
|
|
503
|
+
await rename(temporary, destination);
|
|
504
|
+
const publishedIdentity = await lstat(destination);
|
|
505
|
+
if (!isSameSnapshotCandidate(publishedIdentity, candidateIdentity)) {
|
|
506
|
+
// A competing writer may have replaced the destination between the
|
|
507
|
+
// rename and this verification. Never remove an inode we do not own;
|
|
508
|
+
// the candidate handle still pins our original file and can be closed
|
|
509
|
+
// without touching the competing replacement.
|
|
510
|
+
await candidateHandle?.close();
|
|
511
|
+
candidateHandle = null;
|
|
512
|
+
throw snapshotCandidateChangedError();
|
|
513
|
+
}
|
|
514
|
+
await candidateHandle.close();
|
|
515
|
+
candidateHandle = null;
|
|
516
|
+
published = true;
|
|
517
|
+
};
|
|
518
|
+
const discard = async () => {
|
|
519
|
+
if (published || discarded) return;
|
|
520
|
+
discarded = true;
|
|
521
|
+
await candidateHandle?.close();
|
|
522
|
+
candidateHandle = null;
|
|
523
|
+
let currentIdentity;
|
|
524
|
+
try {
|
|
525
|
+
currentIdentity = await lstat(temporary);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (["ENOENT", "ENOTDIR"].includes(error?.code)) return;
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
if (isSameSnapshotCandidate(currentIdentity, candidateIdentity)) {
|
|
531
|
+
await rm(temporary, { force: true });
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
256
535
|
return {
|
|
536
|
+
publish,
|
|
537
|
+
discard,
|
|
257
538
|
encoding,
|
|
258
539
|
bytesWritten: encoded.byteLength,
|
|
259
540
|
jsonBytes: serialized.byteLength,
|
|
@@ -265,3 +546,23 @@ export async function writePrivateSnapshot(
|
|
|
265
546
|
snapshot: storedSnapshot,
|
|
266
547
|
};
|
|
267
548
|
}
|
|
549
|
+
|
|
550
|
+
export async function writePrivateSnapshot(output, snapshot, options = {}) {
|
|
551
|
+
const staged = await stagePrivateSnapshot(output, snapshot, options);
|
|
552
|
+
try {
|
|
553
|
+
await staged.publish();
|
|
554
|
+
return {
|
|
555
|
+
encoding: staged.encoding,
|
|
556
|
+
bytesWritten: staged.bytesWritten,
|
|
557
|
+
jsonBytes: staged.jsonBytes,
|
|
558
|
+
maxBytes: staged.maxBytes,
|
|
559
|
+
targetBytes: staged.targetBytes,
|
|
560
|
+
maxJsonBytes: staged.maxJsonBytes,
|
|
561
|
+
targetJsonBytes: staged.targetJsonBytes,
|
|
562
|
+
adaptiveResolutionSeconds: staged.adaptiveResolutionSeconds,
|
|
563
|
+
snapshot: staged.snapshot,
|
|
564
|
+
};
|
|
565
|
+
} finally {
|
|
566
|
+
await staged.discard();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const OSC_SEQUENCE =
|
|
2
|
+
/(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/g;
|
|
3
|
+
const CSI_SEQUENCE = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
|
|
4
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
5
|
+
|
|
6
|
+
export function sanitizeTerminalText(value) {
|
|
7
|
+
return String(value ?? "")
|
|
8
|
+
.replace(OSC_SEQUENCE, "")
|
|
9
|
+
.replace(CSI_SEQUENCE, "")
|
|
10
|
+
.replace(CONTROL_CHARACTERS, " ");
|
|
11
|
+
}
|