@teepin/sdk 0.2.1 → 0.3.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -36
- package/dist/cjs/client.js +42 -4
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/direct/bytes.js +50 -0
- package/dist/cjs/direct/bytes.js.map +1 -0
- package/dist/cjs/direct/common.js +167 -0
- package/dist/cjs/direct/common.js.map +1 -0
- package/dist/cjs/direct/downloads-plan.js +127 -0
- package/dist/cjs/direct/downloads-plan.js.map +1 -0
- package/dist/cjs/direct/engine.js +405 -0
- package/dist/cjs/direct/engine.js.map +1 -0
- package/dist/cjs/direct/formats.js +44 -0
- package/dist/cjs/direct/formats.js.map +1 -0
- package/dist/cjs/direct/hpke.js +275 -0
- package/dist/cjs/direct/hpke.js.map +1 -0
- package/dist/cjs/direct/journal.js +80 -0
- package/dist/cjs/direct/journal.js.map +1 -0
- package/dist/cjs/direct/large.js +850 -0
- package/dist/cjs/direct/large.js.map +1 -0
- package/dist/cjs/direct/reads.js +1314 -0
- package/dist/cjs/direct/reads.js.map +1 -0
- package/dist/cjs/direct/sha256.js +122 -0
- package/dist/cjs/direct/sha256.js.map +1 -0
- package/dist/cjs/direct/tws1.js +480 -0
- package/dist/cjs/direct/tws1.js.map +1 -0
- package/dist/cjs/direct/uploads.js +1706 -0
- package/dist/cjs/direct/uploads.js.map +1 -0
- package/dist/cjs/errors.js +70 -13
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/links.js +195 -0
- package/dist/cjs/links.js.map +1 -0
- package/dist/cjs/node/downloads.js +835 -0
- package/dist/cjs/node/downloads.js.map +1 -0
- package/dist/cjs/node/journal.js +841 -0
- package/dist/cjs/node/journal.js.map +1 -0
- package/dist/cjs/node/uploads.js +219 -0
- package/dist/cjs/node/uploads.js.map +1 -0
- package/dist/cjs/node.js +26 -39
- package/dist/cjs/node.js.map +1 -1
- package/dist/cjs/storage.js +214 -184
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/transfer.js +18 -351
- package/dist/cjs/transfer.js.map +1 -1
- package/dist/esm/client.js +42 -4
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/direct/bytes.js +42 -0
- package/dist/esm/direct/bytes.js.map +1 -0
- package/dist/esm/direct/common.js +155 -0
- package/dist/esm/direct/common.js.map +1 -0
- package/dist/esm/direct/downloads-plan.js +117 -0
- package/dist/esm/direct/downloads-plan.js.map +1 -0
- package/dist/esm/direct/engine.js +385 -0
- package/dist/esm/direct/engine.js.map +1 -0
- package/dist/esm/direct/formats.js +40 -0
- package/dist/esm/direct/formats.js.map +1 -0
- package/dist/esm/direct/hpke.js +262 -0
- package/dist/esm/direct/hpke.js.map +1 -0
- package/dist/esm/direct/journal.js +75 -0
- package/dist/esm/direct/journal.js.map +1 -0
- package/dist/esm/direct/large.js +816 -0
- package/dist/esm/direct/large.js.map +1 -0
- package/dist/esm/direct/reads.js +1275 -0
- package/dist/esm/direct/reads.js.map +1 -0
- package/dist/esm/direct/sha256.js +118 -0
- package/dist/esm/direct/sha256.js.map +1 -0
- package/dist/esm/direct/tws1.js +460 -0
- package/dist/esm/direct/tws1.js.map +1 -0
- package/dist/esm/direct/uploads.js +1660 -0
- package/dist/esm/direct/uploads.js.map +1 -0
- package/dist/esm/errors.js +64 -11
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/links.js +189 -0
- package/dist/esm/links.js.map +1 -0
- package/dist/esm/node/downloads.js +829 -0
- package/dist/esm/node/downloads.js.map +1 -0
- package/dist/esm/node/journal.js +824 -0
- package/dist/esm/node/journal.js.map +1 -0
- package/dist/esm/node/uploads.js +187 -0
- package/dist/esm/node/uploads.js.map +1 -0
- package/dist/esm/node.js +17 -14
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/storage.js +214 -184
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/transfer.js +19 -349
- package/dist/esm/transfer.js.map +1 -1
- package/dist/types/client.d.ts +45 -4
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/direct/bytes.d.ts +11 -0
- package/dist/types/direct/bytes.d.ts.map +1 -0
- package/dist/types/direct/common.d.ts +47 -0
- package/dist/types/direct/common.d.ts.map +1 -0
- package/dist/types/direct/downloads-plan.d.ts +75 -0
- package/dist/types/direct/downloads-plan.d.ts.map +1 -0
- package/dist/types/direct/engine.d.ts +191 -0
- package/dist/types/direct/engine.d.ts.map +1 -0
- package/dist/types/direct/formats.d.ts +9 -0
- package/dist/types/direct/formats.d.ts.map +1 -0
- package/dist/types/direct/hpke.d.ts +55 -0
- package/dist/types/direct/hpke.d.ts.map +1 -0
- package/dist/types/direct/journal.d.ts +55 -0
- package/dist/types/direct/journal.d.ts.map +1 -0
- package/dist/types/direct/large.d.ts +170 -0
- package/dist/types/direct/large.d.ts.map +1 -0
- package/dist/types/direct/reads.d.ts +306 -0
- package/dist/types/direct/reads.d.ts.map +1 -0
- package/dist/types/direct/sha256.d.ts +16 -0
- package/dist/types/direct/sha256.d.ts.map +1 -0
- package/dist/types/direct/tws1.d.ts +157 -0
- package/dist/types/direct/tws1.d.ts.map +1 -0
- package/dist/types/direct/uploads.d.ts +461 -0
- package/dist/types/direct/uploads.d.ts.map +1 -0
- package/dist/types/errors.d.ts +47 -8
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/links.d.ts +86 -0
- package/dist/types/links.d.ts.map +1 -0
- package/dist/types/node/downloads.d.ts +73 -0
- package/dist/types/node/downloads.d.ts.map +1 -0
- package/dist/types/node/journal.d.ts +162 -0
- package/dist/types/node/journal.d.ts.map +1 -0
- package/dist/types/node/uploads.d.ts +86 -0
- package/dist/types/node/uploads.d.ts.map +1 -0
- package/dist/types/node.d.ts +4 -2
- package/dist/types/node.d.ts.map +1 -1
- package/dist/types/storage.d.ts +107 -64
- package/dist/types/storage.d.ts.map +1 -1
- package/dist/types/transfer.d.ts +14 -23
- package/dist/types/transfer.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
// Copyright 2026 TEEPIN Project
|
|
2
|
+
// Licensed under the Apache License, Version 2.0
|
|
3
|
+
// Local state for direct uploads in the node entry (TEEPIN-STORAGE-DESIGN.md sections 17.2, 22.6 and 24.4): where it
|
|
4
|
+
// lives, the journal each run keeps, and the lock that keeps two processes off one run.
|
|
5
|
+
//
|
|
6
|
+
// - The state directory is per OS: $XDG_STATE_HOME/teepin (by default ~/.local/state/teepin), ~/Library/Application
|
|
7
|
+
// Support/teepin on macOS, %LOCALAPPDATA%\teepin on Windows, or TEEPIN_STATE_DIR. It is created 0700 and its files
|
|
8
|
+
// 0600, since journals hold file paths and wrapped keys. On Windows the profile directory's user-only ACL is
|
|
9
|
+
// inherited.
|
|
10
|
+
// - Each run has <state>/<kind>/<run-id>/ (kind: uploads or downloads) with run.json, an append-only events.jsonl, a
|
|
11
|
+
// lock file, and for uploads packs/ and keys/ (a large upload's data key, E3). Run ids are UUIDv7, so they sort by
|
|
12
|
+
// time. The layout isn't shared with the Python SDK: each resumes only its own runs.
|
|
13
|
+
// - The journal: every write is one line (a batch of rows, so it lands whole or not at all), written and fsynced
|
|
14
|
+
// before the action that needs it (section 17.1, rule 3). A torn last line (a crash mid-write) is ignored; a bad line
|
|
15
|
+
// anywhere else is an error. When a run is reopened, its journal is compacted: rewritten as one line in a temporary
|
|
16
|
+
// file, fsynced, renamed over the old one, and the directory fsynced where the OS allows.
|
|
17
|
+
// - The lock: a file made with O_EXCL holding the host, the PID, the process's start time and a nonce. A lock whose
|
|
18
|
+
// process is gone (a dead PID on this host, or a PID reused: its start time differs) is taken over atomically: a
|
|
19
|
+
// taker first claims that one stale lock with an O_EXCL claim file named by its nonce (only one claim wins), checks
|
|
20
|
+
// the lock is still that stale one, then replaces it whole (a temporary file renamed over it), so there is never a
|
|
21
|
+
// moment without a lock and a live lock is never moved. Within a process, locks are kept in a registry, so two runs
|
|
22
|
+
// in one process never share one either, and an interrupted run is released in the same process (S7-2).
|
|
23
|
+
import { execFile } from 'node:child_process';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { chmod, mkdir, open, readdir, readFile, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
26
|
+
import { homedir, hostname } from 'node:os';
|
|
27
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
import { uuid7 } from '../direct/common.js';
|
|
29
|
+
import { Rows } from '../direct/journal.js';
|
|
30
|
+
export const STATE_ENV = 'TEEPIN_STATE_DIR';
|
|
31
|
+
export const RUN_FILE = 'run.json';
|
|
32
|
+
export const EVENTS_FILE = 'events.jsonl';
|
|
33
|
+
export const LOCK_FILE = 'lock';
|
|
34
|
+
export const PACKS_DIR = 'packs';
|
|
35
|
+
export const KEYS_DIR = 'keys';
|
|
36
|
+
export const HISTORY_FILE = 'history.log';
|
|
37
|
+
/** The journal's own format version, in run.json. */
|
|
38
|
+
export const JOURNAL_FORMAT = 1;
|
|
39
|
+
/** A lock file whose content can't be read yet is being written: held for this long, then stale. */
|
|
40
|
+
export const LOCK_GRACE_MS = 10000;
|
|
41
|
+
const POSIX = process.platform !== 'win32';
|
|
42
|
+
/** The state directory for this OS (section 19.12), or TEEPIN_STATE_DIR. */
|
|
43
|
+
export function defaultStateDir() {
|
|
44
|
+
const override = process.env[STATE_ENV];
|
|
45
|
+
if (override)
|
|
46
|
+
return override;
|
|
47
|
+
if (process.platform === 'win32')
|
|
48
|
+
return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'teepin');
|
|
49
|
+
if (process.platform === 'darwin')
|
|
50
|
+
return join(homedir(), 'Library', 'Application Support', 'teepin');
|
|
51
|
+
return join(process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'teepin');
|
|
52
|
+
}
|
|
53
|
+
function code(err) {
|
|
54
|
+
return typeof err === 'object' && err !== null && 'code' in err ? String(err.code) : '';
|
|
55
|
+
}
|
|
56
|
+
/** Creates path (and its parents) readable by its owner only. */
|
|
57
|
+
export async function privateDir(path) {
|
|
58
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
59
|
+
if (POSIX)
|
|
60
|
+
await chmod(path, 0o700);
|
|
61
|
+
return path;
|
|
62
|
+
}
|
|
63
|
+
/** fsyncs a directory, so a rename or a new file in it survives a crash; nothing where the OS can't (Windows). */
|
|
64
|
+
export async function fsyncDir(path) {
|
|
65
|
+
if (!POSIX)
|
|
66
|
+
return;
|
|
67
|
+
let fh;
|
|
68
|
+
try {
|
|
69
|
+
fh = await open(path, 'r');
|
|
70
|
+
await fh.sync();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// a file system that can't: the rename is still atomic
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await fh?.close().catch(() => undefined);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** How long a rename over a file another process has open is tried again on Windows (tests shorten it), and how often. */
|
|
80
|
+
export const sharing = { retryMs: 2000 };
|
|
81
|
+
const SHARING_RETRY_STEP_MS = 20;
|
|
82
|
+
/**
|
|
83
|
+
* Renames from over to, trying again for up to sharing.retryMs on Windows while another process holds to open
|
|
84
|
+
* (EPERM, EACCES or EBUSY: a reader of a lock, a history file being appended to). Elsewhere a rename replaces an open
|
|
85
|
+
* file, so it is tried once (profile 1's W-1; Go's journal does the same).
|
|
86
|
+
*/
|
|
87
|
+
export async function renameOver(from, to, retryMs = sharing.retryMs) {
|
|
88
|
+
const until = Date.now() + retryMs;
|
|
89
|
+
for (;;) {
|
|
90
|
+
try {
|
|
91
|
+
await rename(from, to);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const c = code(err);
|
|
96
|
+
if (process.platform !== 'win32' || !(c === 'EPERM' || c === 'EACCES' || c === 'EBUSY') || Date.now() >= until)
|
|
97
|
+
throw err;
|
|
98
|
+
await new Promise((r) => setTimeout(r, SHARING_RETRY_STEP_MS));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Writes data to path all at once: a temporary file (0600), fsynced, then renamed over it, and the directory fsynced. */
|
|
103
|
+
export async function writeAtomic(path, data) {
|
|
104
|
+
const tmp = `${path}.${randomUUID().replace(/-/g, '')}.tmp`;
|
|
105
|
+
const fh = await open(tmp, 'wx', 0o600);
|
|
106
|
+
try {
|
|
107
|
+
try {
|
|
108
|
+
await fh.writeFile(data);
|
|
109
|
+
await fh.sync();
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await fh.close();
|
|
113
|
+
}
|
|
114
|
+
await renameOver(tmp, path);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
await unlink(tmp).catch(() => undefined);
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
await fsyncDir(dirname(path));
|
|
121
|
+
}
|
|
122
|
+
/** A file's bytes, or undefined when it isn't there. */
|
|
123
|
+
async function readIfThere(path) {
|
|
124
|
+
try {
|
|
125
|
+
return new Uint8Array(await readFile(path));
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (code(err) === 'ENOENT')
|
|
129
|
+
return undefined;
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// --- the journal ------------------------------------------------------------------------------------------------
|
|
134
|
+
const TABLES = new Set(['run', 'files', 'packs', 'uploads', 'parts']);
|
|
135
|
+
function asEntries(v) {
|
|
136
|
+
if (!Array.isArray(v))
|
|
137
|
+
return undefined;
|
|
138
|
+
for (const e of v) {
|
|
139
|
+
if (typeof e !== 'object' || e === null)
|
|
140
|
+
return undefined;
|
|
141
|
+
const x = e;
|
|
142
|
+
if (typeof x.table !== 'string' || !TABLES.has(x.table) || typeof x.id !== 'string')
|
|
143
|
+
return undefined;
|
|
144
|
+
if (x.remove !== true && (typeof x.row !== 'object' || x.row === null))
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return v;
|
|
148
|
+
}
|
|
149
|
+
/** A run's rows, replayed from its events; whether its last line was torn, and how many lines it took. */
|
|
150
|
+
async function replay(dir, rows) {
|
|
151
|
+
const meta = await readIfThere(join(dir, RUN_FILE));
|
|
152
|
+
if (!meta)
|
|
153
|
+
throw new Error(`${dir} is not a run: it has no ${RUN_FILE}`);
|
|
154
|
+
let format;
|
|
155
|
+
try {
|
|
156
|
+
format = JSON.parse(new TextDecoder().decode(meta)).format;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new Error(`${join(dir, RUN_FILE)} is damaged`);
|
|
160
|
+
}
|
|
161
|
+
if (typeof format !== 'number' || format > JOURNAL_FORMAT) {
|
|
162
|
+
throw new Error(`this run was written by a newer SDK (journal format ${String(format)}); upgrade to resume it`);
|
|
163
|
+
}
|
|
164
|
+
const raw = await readIfThere(join(dir, EVENTS_FILE));
|
|
165
|
+
if (!raw?.length)
|
|
166
|
+
return { lines: 0, torn: false };
|
|
167
|
+
const text = new TextDecoder().decode(raw);
|
|
168
|
+
const parts = text.split('\n');
|
|
169
|
+
// A file that doesn't end in a newline ends in a torn line: its write never finished, so its action never happened.
|
|
170
|
+
let torn = parts[parts.length - 1] !== '';
|
|
171
|
+
const lines = parts.slice(0, -1);
|
|
172
|
+
for (let i = 0; i < lines.length; i++) {
|
|
173
|
+
let entries;
|
|
174
|
+
try {
|
|
175
|
+
entries = asEntries(JSON.parse(lines[i]));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
entries = undefined;
|
|
179
|
+
}
|
|
180
|
+
if (!entries) {
|
|
181
|
+
if (i === lines.length - 1 && !torn) {
|
|
182
|
+
torn = true; // the last line, damaged (a crash can leave zeros where it was being written): ignored
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`${join(dir, EVENTS_FILE)} is damaged at line ${i + 1}`);
|
|
186
|
+
}
|
|
187
|
+
for (const e of entries)
|
|
188
|
+
rows.apply(e);
|
|
189
|
+
}
|
|
190
|
+
return { lines: lines.length, torn };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* One run's journal on disk: run.json, and events.jsonl appended a line per write. Writes resolve once they are
|
|
194
|
+
* fsynced; a batch is one line, so it lands whole or not at all. Only the run's lock holder opens it to write.
|
|
195
|
+
*/
|
|
196
|
+
export class FileJournal {
|
|
197
|
+
dir;
|
|
198
|
+
durable = true;
|
|
199
|
+
data = new Rows();
|
|
200
|
+
fh;
|
|
201
|
+
chain = Promise.resolve();
|
|
202
|
+
closed = false;
|
|
203
|
+
broken;
|
|
204
|
+
constructor(dir) {
|
|
205
|
+
this.dir = dir;
|
|
206
|
+
}
|
|
207
|
+
/** Opens a run's journal to write: replayed, then compacted when it has more than one line or a torn one. */
|
|
208
|
+
static async open(dir) {
|
|
209
|
+
const j = new FileJournal(dir);
|
|
210
|
+
const { lines, torn } = await replay(dir, j.data);
|
|
211
|
+
if (torn || lines > 1)
|
|
212
|
+
await j.rewrite();
|
|
213
|
+
j.fh = await open(join(dir, EVENTS_FILE), 'a', 0o600);
|
|
214
|
+
if (POSIX)
|
|
215
|
+
await chmod(join(dir, EVENTS_FILE), 0o600);
|
|
216
|
+
await fsyncDir(dir);
|
|
217
|
+
return j;
|
|
218
|
+
}
|
|
219
|
+
/** A run's rows, read without writing anything (a run another process may hold). */
|
|
220
|
+
static async read(dir) {
|
|
221
|
+
const rows = new Rows();
|
|
222
|
+
await replay(dir, rows);
|
|
223
|
+
return { get: (t, id) => rows.get(t, id), rows: (t) => rows.rows(t) };
|
|
224
|
+
}
|
|
225
|
+
async put(table, id, row) {
|
|
226
|
+
return this.write([{ table, id, row }]);
|
|
227
|
+
}
|
|
228
|
+
write(entries) {
|
|
229
|
+
this.check();
|
|
230
|
+
const line = JSON.stringify(entries) + '\n';
|
|
231
|
+
const run = this.chain.then(async () => {
|
|
232
|
+
this.check();
|
|
233
|
+
const fh = this.fh;
|
|
234
|
+
if (!fh)
|
|
235
|
+
throw new Error('journal: not open');
|
|
236
|
+
try {
|
|
237
|
+
await fh.write(line);
|
|
238
|
+
await fh.datasync();
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
// A line that may be half written: nothing more is appended after it, so on reopen it is the torn last line.
|
|
242
|
+
this.broken = err;
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
for (const e of entries)
|
|
246
|
+
this.data.apply(e);
|
|
247
|
+
});
|
|
248
|
+
this.chain = run.catch(() => undefined);
|
|
249
|
+
return run;
|
|
250
|
+
}
|
|
251
|
+
get(table, id) {
|
|
252
|
+
return this.data.get(table, id);
|
|
253
|
+
}
|
|
254
|
+
rows(table) {
|
|
255
|
+
return this.data.rows(table);
|
|
256
|
+
}
|
|
257
|
+
nextId(table) {
|
|
258
|
+
return this.data.nextId(table);
|
|
259
|
+
}
|
|
260
|
+
/** Rewrites the journal as one line, atomically. */
|
|
261
|
+
async compact() {
|
|
262
|
+
this.check();
|
|
263
|
+
const run = this.chain.then(async () => {
|
|
264
|
+
await this.fh?.close();
|
|
265
|
+
this.fh = undefined;
|
|
266
|
+
await this.rewrite();
|
|
267
|
+
this.fh = await open(join(this.dir, EVENTS_FILE), 'a', 0o600);
|
|
268
|
+
});
|
|
269
|
+
this.chain = run.catch(() => undefined);
|
|
270
|
+
return run;
|
|
271
|
+
}
|
|
272
|
+
async rewrite() {
|
|
273
|
+
const entries = this.data.entries();
|
|
274
|
+
await writeAtomic(join(this.dir, EVENTS_FILE), entries.length ? JSON.stringify(entries) + '\n' : '');
|
|
275
|
+
}
|
|
276
|
+
async close() {
|
|
277
|
+
if (this.closed)
|
|
278
|
+
return;
|
|
279
|
+
this.closed = true;
|
|
280
|
+
await this.chain;
|
|
281
|
+
await this.fh?.close().catch(() => undefined);
|
|
282
|
+
this.fh = undefined;
|
|
283
|
+
}
|
|
284
|
+
check() {
|
|
285
|
+
if (this.closed)
|
|
286
|
+
throw new Error('journal: written after it was closed');
|
|
287
|
+
if (this.broken !== undefined)
|
|
288
|
+
throw new Error('journal: a write failed; reopen the run to go on', { cause: this.broken });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Locks this process holds (or is taking), by path: one registry for every copy of this module (ESM and CommonJS). */
|
|
292
|
+
function registry() {
|
|
293
|
+
const g = globalThis;
|
|
294
|
+
const k = Symbol.for('teepin.runLocks');
|
|
295
|
+
return (g[k] ??= new Set());
|
|
296
|
+
}
|
|
297
|
+
const HOST = hostname();
|
|
298
|
+
/** Whether a process is alive (one of another user's counts: it can't be signalled, but it is there). */
|
|
299
|
+
function alive(pid) {
|
|
300
|
+
try {
|
|
301
|
+
process.kill(pid, 0);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
return code(err) === 'EPERM';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function run(file, args, env) {
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
execFile(file, args, { timeout: 15000, windowsHide: true, env }, (err, stdout) => resolve(err ? undefined : String(stdout).trim() || undefined));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* When process pid started, as this OS tells it: a token that is the same for the same process every time it's asked,
|
|
315
|
+
* and differs for a process that reused its PID. undefined when the OS can't tell (then a live PID counts as the
|
|
316
|
+
* holder). Linux: /proc's start time in ticks since boot, with the boot's id; macOS and the BSDs: ps's start time;
|
|
317
|
+
* Windows: the process's creation time, from PowerShell.
|
|
318
|
+
*/
|
|
319
|
+
export async function processStart(pid) {
|
|
320
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
321
|
+
return undefined;
|
|
322
|
+
switch (process.platform) {
|
|
323
|
+
case 'linux':
|
|
324
|
+
case 'android': {
|
|
325
|
+
try {
|
|
326
|
+
const stat = await readFile(`/proc/${pid}/stat`, 'utf8');
|
|
327
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
328
|
+
const boot = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8').catch(() => '')).trim();
|
|
329
|
+
return fields[19] ? `linux:${boot}:${fields[19]}` : undefined; // field 22, starttime
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
case 'darwin':
|
|
336
|
+
case 'freebsd':
|
|
337
|
+
case 'openbsd':
|
|
338
|
+
case 'netbsd':
|
|
339
|
+
case 'sunos':
|
|
340
|
+
case 'aix': {
|
|
341
|
+
const out = await run('ps', ['-o', 'lstart=', '-p', String(pid)], { ...process.env, TZ: 'UTC', LC_ALL: 'C' });
|
|
342
|
+
return out ? `ps:${out}` : undefined;
|
|
343
|
+
}
|
|
344
|
+
case 'win32': {
|
|
345
|
+
const out = await run('powershell.exe', [
|
|
346
|
+
'-NoProfile',
|
|
347
|
+
'-NonInteractive',
|
|
348
|
+
'-Command',
|
|
349
|
+
`[Console]::Out.Write((Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToFileTimeUtc())`,
|
|
350
|
+
]);
|
|
351
|
+
return out && /^\d+$/.test(out) ? `win:${out}` : undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
let ownStart;
|
|
357
|
+
/** This process's start token (asked once). */
|
|
358
|
+
function selfStart() {
|
|
359
|
+
return (ownStart ??= processStart(process.pid));
|
|
360
|
+
}
|
|
361
|
+
async function readLock(path) {
|
|
362
|
+
let text;
|
|
363
|
+
let mtimeMs;
|
|
364
|
+
let size;
|
|
365
|
+
try {
|
|
366
|
+
[text, { mtimeMs, size }] = await Promise.all([readFile(path, 'utf8'), stat(path)]);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
if (code(err) === 'ENOENT')
|
|
370
|
+
return undefined;
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
const d = JSON.parse(text);
|
|
375
|
+
if (typeof d.nonce !== 'string' || typeof d.host !== 'string' || typeof d.pid !== 'number')
|
|
376
|
+
throw new Error('not a lock');
|
|
377
|
+
return { id: d.nonce, host: d.host, pid: d.pid, start: typeof d.start === 'string' ? d.start : undefined, garbled: false, mtimeMs };
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return { id: `g${Math.round(mtimeMs)}-${size}`, garbled: true, mtimeMs };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** Creates path with text only if nothing is there (O_EXCL), fsynced. */
|
|
384
|
+
async function createExclusive(path, text) {
|
|
385
|
+
const fh = await open(path, 'wx', 0o600);
|
|
386
|
+
try {
|
|
387
|
+
await fh.writeFile(text);
|
|
388
|
+
await fh.sync();
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
await fh.close();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* An exclusive lock on a run, held while it is open, so two processes (or two runs in one process) never work one run
|
|
396
|
+
* (section 17.2). A process that ends without releasing it leaves it stale, and the next taker takes it over.
|
|
397
|
+
*/
|
|
398
|
+
export class RunLock {
|
|
399
|
+
path;
|
|
400
|
+
/** Our nonce while we hold it. */
|
|
401
|
+
mine;
|
|
402
|
+
constructor(path) {
|
|
403
|
+
this.path = path;
|
|
404
|
+
}
|
|
405
|
+
get held() {
|
|
406
|
+
return this.mine !== undefined;
|
|
407
|
+
}
|
|
408
|
+
get key() {
|
|
409
|
+
return resolve(this.path);
|
|
410
|
+
}
|
|
411
|
+
/** Takes the lock if no live process (this one included) holds it. */
|
|
412
|
+
async acquire() {
|
|
413
|
+
if (this.mine)
|
|
414
|
+
return true;
|
|
415
|
+
const reg = registry();
|
|
416
|
+
const key = this.key;
|
|
417
|
+
if (reg.has(key))
|
|
418
|
+
return false; // this process holds it, or is taking it
|
|
419
|
+
reg.add(key);
|
|
420
|
+
try {
|
|
421
|
+
const nonce = randomUUID();
|
|
422
|
+
const text = JSON.stringify({ host: HOST, pid: process.pid, start: await selfStart(), nonce });
|
|
423
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
424
|
+
try {
|
|
425
|
+
await createExclusive(this.path, text);
|
|
426
|
+
this.mine = nonce;
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
if (code(err) !== 'EEXIST')
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
const cur = await readLock(this.path);
|
|
434
|
+
if (!cur)
|
|
435
|
+
continue; // released meanwhile: try again
|
|
436
|
+
if (!(await this.stale(cur, true)))
|
|
437
|
+
return false;
|
|
438
|
+
if (await this.takeOver(cur, text)) {
|
|
439
|
+
this.mine = nonce;
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
if (!this.mine)
|
|
447
|
+
reg.delete(key);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Whether a lock is stale: its process is gone (a dead PID on this host, or a reused one, whose start time differs),
|
|
452
|
+
* or it is this process's and no run here holds it (taking, when this lock object is the one taking it). A lock from
|
|
453
|
+
* another host is never stale: its PID means nothing here.
|
|
454
|
+
*/
|
|
455
|
+
async stale(cur, taking) {
|
|
456
|
+
if (cur.garbled)
|
|
457
|
+
return Date.now() - cur.mtimeMs > LOCK_GRACE_MS;
|
|
458
|
+
if (cur.host !== HOST || cur.pid === undefined)
|
|
459
|
+
return false;
|
|
460
|
+
if (cur.pid === process.pid)
|
|
461
|
+
return taking || !registry().has(this.key);
|
|
462
|
+
if (!alive(cur.pid))
|
|
463
|
+
return true;
|
|
464
|
+
if (cur.start) {
|
|
465
|
+
const now = await processStart(cur.pid);
|
|
466
|
+
if (now !== undefined && now !== cur.start)
|
|
467
|
+
return true; // the PID was reused
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Takes over a stale lock: only one taker's claim (an O_EXCL file named by the stale lock's identity) wins, and the
|
|
473
|
+
* winner replaces the lock whole only if it is still that stale one.
|
|
474
|
+
*/
|
|
475
|
+
async takeOver(cur, text) {
|
|
476
|
+
const claim = `${this.path}.take-${cur.id.replace(/[^A-Za-z0-9-]/g, '')}`;
|
|
477
|
+
try {
|
|
478
|
+
await createExclusive(claim, text);
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
if (code(err) !== 'EEXIST')
|
|
482
|
+
throw err;
|
|
483
|
+
// Another taker's claim. One whose taker died is cleared, so a later try can go on.
|
|
484
|
+
const c = await readLock(claim);
|
|
485
|
+
if (c && (await this.staleClaim(c)))
|
|
486
|
+
await unlink(claim).catch(() => undefined);
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const again = await readLock(this.path);
|
|
491
|
+
if (!again || again.id !== cur.id)
|
|
492
|
+
return false; // replaced meanwhile
|
|
493
|
+
try {
|
|
494
|
+
await writeAtomic(this.path, text); // never a moment without a lock
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
// Still held open by another reader after the retries (Windows): not taken this time; acquire goes round.
|
|
498
|
+
const c = code(err);
|
|
499
|
+
if (c === 'EPERM' || c === 'EACCES' || c === 'EBUSY')
|
|
500
|
+
return false;
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
await unlink(claim).catch(() => undefined);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async staleClaim(c) {
|
|
510
|
+
if (c.garbled)
|
|
511
|
+
return Date.now() - c.mtimeMs > LOCK_GRACE_MS;
|
|
512
|
+
if (c.host !== HOST || c.pid === undefined)
|
|
513
|
+
return false;
|
|
514
|
+
if (c.pid === process.pid)
|
|
515
|
+
return false; // a taker in this process, at work now
|
|
516
|
+
if (!alive(c.pid))
|
|
517
|
+
return true;
|
|
518
|
+
if (c.start) {
|
|
519
|
+
const now = await processStart(c.pid);
|
|
520
|
+
if (now !== undefined && now !== c.start)
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
/** Lets the lock go (deleting its file, if it is still ours). Safe to call twice. */
|
|
526
|
+
async release() {
|
|
527
|
+
const nonce = this.mine;
|
|
528
|
+
if (!nonce)
|
|
529
|
+
return;
|
|
530
|
+
this.mine = undefined;
|
|
531
|
+
try {
|
|
532
|
+
const cur = await readLock(this.path).catch(() => undefined);
|
|
533
|
+
if (cur && cur.id === nonce)
|
|
534
|
+
await unlink(this.path).catch(() => undefined);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
registry().delete(this.key);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** Whether a live process (this one included, through another lock object) holds the lock. */
|
|
541
|
+
async heldElsewhere() {
|
|
542
|
+
if (this.mine)
|
|
543
|
+
return false;
|
|
544
|
+
const cur = await readLock(this.path);
|
|
545
|
+
if (!cur)
|
|
546
|
+
return false;
|
|
547
|
+
if (registry().has(this.key))
|
|
548
|
+
return true;
|
|
549
|
+
return !(await this.stale(cur, false));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// --- runs ----------------------------------------------------------------------------------------------------------
|
|
553
|
+
/** Every run of a kind in the state directory, oldest first. */
|
|
554
|
+
export async function runIds(stateDir, kind = 'uploads') {
|
|
555
|
+
const root = join(stateDir, kind);
|
|
556
|
+
let names;
|
|
557
|
+
try {
|
|
558
|
+
names = await readdir(root);
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
if (code(err) === 'ENOENT')
|
|
562
|
+
return [];
|
|
563
|
+
throw err;
|
|
564
|
+
}
|
|
565
|
+
const out = [];
|
|
566
|
+
for (const n of names) {
|
|
567
|
+
if (await stat(join(root, n, RUN_FILE)).then((s) => s.isFile(), () => false))
|
|
568
|
+
out.push(n);
|
|
569
|
+
}
|
|
570
|
+
return out.sort();
|
|
571
|
+
}
|
|
572
|
+
/** A run's directory in the state directory, with its lock and journal: a RunPlace for the upload engine. */
|
|
573
|
+
export class RunDir {
|
|
574
|
+
id;
|
|
575
|
+
kind;
|
|
576
|
+
path;
|
|
577
|
+
lock;
|
|
578
|
+
j;
|
|
579
|
+
released = false;
|
|
580
|
+
constructor(stateDir, id, kind) {
|
|
581
|
+
this.id = id;
|
|
582
|
+
this.kind = kind;
|
|
583
|
+
this.path = join(stateDir, kind, id);
|
|
584
|
+
this.lock = new RunLock(join(this.path, LOCK_FILE));
|
|
585
|
+
}
|
|
586
|
+
get journal() {
|
|
587
|
+
if (!this.j)
|
|
588
|
+
throw new Error(`run ${this.id}: its journal isn't open`);
|
|
589
|
+
return this.j;
|
|
590
|
+
}
|
|
591
|
+
/** A new run, locked by this process, its journal open. */
|
|
592
|
+
static async create(stateDir, kind = 'uploads') {
|
|
593
|
+
await privateDir(join(stateDir, kind));
|
|
594
|
+
const rd = new RunDir(stateDir, uuid7(), kind);
|
|
595
|
+
await privateDir(rd.path);
|
|
596
|
+
if (!(await rd.lock.acquire()))
|
|
597
|
+
throw new Error(`run ${rd.id} is locked`);
|
|
598
|
+
try {
|
|
599
|
+
if (kind === 'uploads') {
|
|
600
|
+
await privateDir(join(rd.path, PACKS_DIR));
|
|
601
|
+
await privateDir(join(rd.path, KEYS_DIR));
|
|
602
|
+
}
|
|
603
|
+
await writeAtomic(join(rd.path, RUN_FILE), JSON.stringify({ format: JOURNAL_FORMAT, id: rd.id, kind, created: Date.now() }) + '\n');
|
|
604
|
+
rd.j = await FileJournal.open(rd.path);
|
|
605
|
+
await fsyncDir(join(stateDir, kind));
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
await rd.lock.release();
|
|
609
|
+
await rm(rd.path, { recursive: true, force: true }).catch(() => undefined);
|
|
610
|
+
throw err;
|
|
611
|
+
}
|
|
612
|
+
return rd;
|
|
613
|
+
}
|
|
614
|
+
/** A run, locked for this caller with its journal open; undefined when a live process (or another run here) holds it. */
|
|
615
|
+
static async take(stateDir, id, kind = 'uploads') {
|
|
616
|
+
const rd = new RunDir(stateDir, id, kind);
|
|
617
|
+
if (!(await rd.lock.acquire()))
|
|
618
|
+
return undefined;
|
|
619
|
+
try {
|
|
620
|
+
rd.j = await FileJournal.open(rd.path);
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
await rd.lock.release();
|
|
624
|
+
throw err;
|
|
625
|
+
}
|
|
626
|
+
return rd;
|
|
627
|
+
}
|
|
628
|
+
/** A run's journal read without taking it, and whether a live process holds it. */
|
|
629
|
+
static async peek(stateDir, id, kind = 'uploads') {
|
|
630
|
+
const rd = new RunDir(stateDir, id, kind);
|
|
631
|
+
const inUse = await rd.lock.heldElsewhere();
|
|
632
|
+
return { journal: await FileJournal.read(rd.path), inUse };
|
|
633
|
+
}
|
|
634
|
+
packPath(name) {
|
|
635
|
+
return join(this.path, PACKS_DIR, name);
|
|
636
|
+
}
|
|
637
|
+
keyPath(id) {
|
|
638
|
+
return join(this.path, KEYS_DIR, `${id}.key`);
|
|
639
|
+
}
|
|
640
|
+
async writePack(name, data) {
|
|
641
|
+
await privateDir(join(this.path, PACKS_DIR));
|
|
642
|
+
await writeAtomic(this.packPath(name), data);
|
|
643
|
+
}
|
|
644
|
+
readPack(name) {
|
|
645
|
+
return readIfThere(this.packPath(name)).catch(() => undefined);
|
|
646
|
+
}
|
|
647
|
+
async removePack(name) {
|
|
648
|
+
await rm(this.packPath(name), { force: true });
|
|
649
|
+
}
|
|
650
|
+
async packNames() {
|
|
651
|
+
return readdir(join(this.path, PACKS_DIR)).catch(() => []);
|
|
652
|
+
}
|
|
653
|
+
async writeKey(id, key) {
|
|
654
|
+
await privateDir(join(this.path, KEYS_DIR));
|
|
655
|
+
await writeAtomic(this.keyPath(id), key);
|
|
656
|
+
}
|
|
657
|
+
readKey(id) {
|
|
658
|
+
return readIfThere(this.keyPath(id));
|
|
659
|
+
}
|
|
660
|
+
/** Overwrites the key's file with zeros (fsynced), then deletes it. */
|
|
661
|
+
async removeKey(id) {
|
|
662
|
+
const path = this.keyPath(id);
|
|
663
|
+
try {
|
|
664
|
+
const fh = await open(path, 'r+');
|
|
665
|
+
try {
|
|
666
|
+
const { size } = await fh.stat();
|
|
667
|
+
await fh.write(new Uint8Array(size), 0, size, 0);
|
|
668
|
+
await fh.sync();
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
await fh.close();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
if (code(err) === 'ENOENT')
|
|
676
|
+
return;
|
|
677
|
+
throw err;
|
|
678
|
+
}
|
|
679
|
+
await rm(path, { force: true });
|
|
680
|
+
}
|
|
681
|
+
/** The ids of the keys kept; leftovers of a write cut short are deleted. */
|
|
682
|
+
async keyNames() {
|
|
683
|
+
const dir = join(this.path, KEYS_DIR);
|
|
684
|
+
const out = [];
|
|
685
|
+
for (const n of await readdir(dir).catch(() => [])) {
|
|
686
|
+
if (n.endsWith('.key'))
|
|
687
|
+
out.push(n.slice(0, -'.key'.length));
|
|
688
|
+
else
|
|
689
|
+
await rm(join(dir, n), { force: true }).catch(() => undefined);
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
async release() {
|
|
694
|
+
if (this.released)
|
|
695
|
+
return;
|
|
696
|
+
this.released = true;
|
|
697
|
+
await this.j?.close();
|
|
698
|
+
await this.lock.release();
|
|
699
|
+
}
|
|
700
|
+
/** Deletes the run while it still holds the lock (run.json first, so it's no longer listed), then lets the lock go. */
|
|
701
|
+
async remove() {
|
|
702
|
+
await this.j?.close();
|
|
703
|
+
await rm(join(this.path, RUN_FILE), { force: true });
|
|
704
|
+
// A data key still kept (an upload that never ended) is zeroed before it goes, as one is when its upload ends.
|
|
705
|
+
for (const id of await this.keyNames())
|
|
706
|
+
await this.removeKey(id).catch(() => undefined);
|
|
707
|
+
for (const n of await readdir(this.path).catch(() => [])) {
|
|
708
|
+
if (n !== LOCK_FILE)
|
|
709
|
+
await rm(join(this.path, n), { recursive: true, force: true });
|
|
710
|
+
}
|
|
711
|
+
this.released = true;
|
|
712
|
+
await this.lock.release();
|
|
713
|
+
await rm(this.path, { recursive: true, force: true });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** An append that finds the history past this many bytes trims it to 7 days. */
|
|
717
|
+
export const HISTORY_TRIM_BYTES = 1 << 20;
|
|
718
|
+
/**
|
|
719
|
+
* One line about a finished run (section 17.12). Every SDK on this machine shares the file, so a line is added with
|
|
720
|
+
* one append (O_APPEND) and the file is never rewritten here (profile 1's G-3). It's trimmed to 7 days by
|
|
721
|
+
* trimHistory: by cleanUploads, or here once it passes HISTORY_TRIM_BYTES.
|
|
722
|
+
*/
|
|
723
|
+
export async function appendHistory(stateDir, line, kind = 'uploads', trimAt = HISTORY_TRIM_BYTES) {
|
|
724
|
+
const root = await privateDir(join(stateDir, kind));
|
|
725
|
+
const fh = await open(join(root, HISTORY_FILE), 'a', 0o600);
|
|
726
|
+
let size;
|
|
727
|
+
try {
|
|
728
|
+
await fh.write(`${Math.round(Date.now() / 1000)} ${line}\n`);
|
|
729
|
+
size = (await fh.stat()).size;
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
await fh.close();
|
|
733
|
+
}
|
|
734
|
+
if (size > trimAt)
|
|
735
|
+
await trimHistory(stateDir, kind);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Keeps the history's last 7 days, in one atomic rewrite. A line another SDK appends during the rewrite can be lost,
|
|
739
|
+
* so this runs only at cleanUploads or past the size cap, never on each run.
|
|
740
|
+
*/
|
|
741
|
+
export async function trimHistory(stateDir, kind = 'uploads') {
|
|
742
|
+
const path = join(stateDir, kind, HISTORY_FILE);
|
|
743
|
+
const old = await readIfThere(path);
|
|
744
|
+
if (!old)
|
|
745
|
+
return;
|
|
746
|
+
const week = Date.now() / 1000 - 7 * 86400;
|
|
747
|
+
const kept = [];
|
|
748
|
+
for (const l of new TextDecoder().decode(old).split('\n')) {
|
|
749
|
+
const t = Number(l.split(' ', 1)[0]);
|
|
750
|
+
if (l && Number.isFinite(t) && t >= week)
|
|
751
|
+
kept.push(l);
|
|
752
|
+
}
|
|
753
|
+
await writeAtomic(path, kept.map((l) => l + '\n').join(''));
|
|
754
|
+
}
|
|
755
|
+
/** The bytes under path. */
|
|
756
|
+
export async function directorySize(path) {
|
|
757
|
+
let total = 0;
|
|
758
|
+
let names;
|
|
759
|
+
try {
|
|
760
|
+
names = await readdir(path);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
765
|
+
for (const n of names) {
|
|
766
|
+
const p = join(path, n);
|
|
767
|
+
const s = await stat(p).catch(() => undefined);
|
|
768
|
+
if (!s)
|
|
769
|
+
continue;
|
|
770
|
+
total += s.isDirectory() ? await directorySize(p) : s.size;
|
|
771
|
+
}
|
|
772
|
+
return total;
|
|
773
|
+
}
|
|
774
|
+
// --- the account key pin ---------------------------------------------------------------------------------------------
|
|
775
|
+
/**
|
|
776
|
+
* The first public key seen for each account and key version (section 11.4), kept in the state directory's keys.json
|
|
777
|
+
* (and in memory when it can't be written): a different key under the same version is refused.
|
|
778
|
+
*/
|
|
779
|
+
export class FilePins {
|
|
780
|
+
path;
|
|
781
|
+
mem = {};
|
|
782
|
+
chain = Promise.resolve();
|
|
783
|
+
constructor(stateDir) {
|
|
784
|
+
this.path = join(stateDir, 'keys.json');
|
|
785
|
+
}
|
|
786
|
+
check(accountId, version, publicKey) {
|
|
787
|
+
const run = this.chain.then(() => this.checkNow(accountId, version, publicKey));
|
|
788
|
+
this.chain = run.catch(() => undefined);
|
|
789
|
+
return run;
|
|
790
|
+
}
|
|
791
|
+
async checkNow(accountId, version, publicKey) {
|
|
792
|
+
const encoded = Buffer.from(publicKey).toString('base64');
|
|
793
|
+
const pins = await this.load();
|
|
794
|
+
const have = pins[accountId]?.[String(version)];
|
|
795
|
+
if (have !== undefined)
|
|
796
|
+
return have === encoded;
|
|
797
|
+
(pins[accountId] ??= {})[String(version)] = encoded;
|
|
798
|
+
this.mem = pins;
|
|
799
|
+
try {
|
|
800
|
+
await privateDir(dirname(this.path));
|
|
801
|
+
await writeAtomic(this.path, JSON.stringify(pins));
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
// a read-only state directory: the pin stays in memory
|
|
805
|
+
}
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
async load() {
|
|
809
|
+
try {
|
|
810
|
+
const data = JSON.parse(await readFile(this.path, 'utf8'));
|
|
811
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
812
|
+
const merged = { ...data };
|
|
813
|
+
for (const [acct, versions] of Object.entries(this.mem))
|
|
814
|
+
merged[acct] = { ...(merged[acct] ?? {}), ...versions };
|
|
815
|
+
return merged;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch {
|
|
819
|
+
// none yet, or unreadable: memory's
|
|
820
|
+
}
|
|
821
|
+
return { ...this.mem };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
//# sourceMappingURL=journal.js.map
|