@rome-os/node-core 0.1.1 → 0.1.3
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 +59 -1
- package/dist/actions.d.ts +4 -0
- package/dist/client.d.ts +4 -1
- package/dist/client.js +30 -1
- package/dist/connect.js +2 -3
- package/dist/connector.d.ts +10 -1
- package/dist/connector.js +129 -7
- package/dist/console.d.ts +12 -0
- package/dist/console.js +101 -0
- package/dist/daemon-client.d.ts +10 -4
- package/dist/daemon-client.js +87 -2
- package/dist/daemon-control.js +13 -2
- package/dist/daemon-protocol.d.ts +22 -1
- package/dist/daemon-protocol.js +22 -1
- package/dist/daemon-rpc.d.ts +7 -2
- package/dist/daemon-rpc.js +49 -17
- package/dist/daemon.js +345 -29
- package/dist/executor.d.ts +4 -1
- package/dist/executor.js +180 -66
- package/dist/frame.d.ts +24 -0
- package/dist/frame.js +64 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/observe-protocol.d.ts +100 -0
- package/dist/observe-protocol.js +13 -0
- package/dist/observe.d.ts +67 -0
- package/dist/observe.js +457 -0
- package/dist/transfer.d.ts +102 -0
- package/dist/transfer.js +518 -0
- package/package.json +1 -1
package/dist/observe.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { ACTION_RESULTS, OUTCOMES, } from "./observe-protocol.js";
|
|
5
|
+
const SCHEMA_VERSION = 1;
|
|
6
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
export const MAX_ROW_CHARS = 16 * 1024;
|
|
8
|
+
const MAX_RESULT_BYTES = 768 * 1024;
|
|
9
|
+
const MAX_LIMIT = 500;
|
|
10
|
+
const MAX_BUCKETS = 1000;
|
|
11
|
+
const RESULT_SQL = `CASE
|
|
12
|
+
WHEN a.ended_at IS NULL THEN 'running'
|
|
13
|
+
WHEN a.outcome = 'ok' AND coalesce(a.exit_code, 0) = 0 AND a.signal IS NULL THEN 'succeeded'
|
|
14
|
+
WHEN a.outcome IN ('ok', 'error') THEN 'failed'
|
|
15
|
+
ELSE 'no_result' END`;
|
|
16
|
+
export class QueryError extends Error {
|
|
17
|
+
}
|
|
18
|
+
class CorruptDatabaseError extends Error {
|
|
19
|
+
}
|
|
20
|
+
class SchemaVersionError extends Error {
|
|
21
|
+
}
|
|
22
|
+
function isCorruption(error) {
|
|
23
|
+
if (error instanceof CorruptDatabaseError)
|
|
24
|
+
return true;
|
|
25
|
+
const code = error?.errcode;
|
|
26
|
+
return typeof code === "number" && ((code & 0xff) === 11 || (code & 0xff) === 26);
|
|
27
|
+
}
|
|
28
|
+
const SCHEMA = `
|
|
29
|
+
CREATE TABLE actions (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
direction TEXT NOT NULL,
|
|
32
|
+
peer TEXT,
|
|
33
|
+
action TEXT NOT NULL,
|
|
34
|
+
mode TEXT NOT NULL,
|
|
35
|
+
command TEXT,
|
|
36
|
+
args TEXT,
|
|
37
|
+
cwd TEXT,
|
|
38
|
+
started_at INTEGER NOT NULL,
|
|
39
|
+
ended_at INTEGER,
|
|
40
|
+
outcome TEXT,
|
|
41
|
+
error_code TEXT,
|
|
42
|
+
exit_code INTEGER,
|
|
43
|
+
signal TEXT,
|
|
44
|
+
stdin_bytes INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
stdout_bytes INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
stderr_bytes INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
transfer_direction TEXT,
|
|
48
|
+
local_path TEXT,
|
|
49
|
+
remote_path TEXT,
|
|
50
|
+
transfer_bytes INTEGER,
|
|
51
|
+
sha256 TEXT
|
|
52
|
+
);
|
|
53
|
+
CREATE INDEX actions_started_at ON actions(started_at);
|
|
54
|
+
CREATE INDEX actions_direction ON actions(direction, id);
|
|
55
|
+
CREATE INDEX actions_peer ON actions(peer, id);
|
|
56
|
+
CREATE INDEX actions_outcome ON actions(outcome, id);
|
|
57
|
+
CREATE INDEX actions_open ON actions(id) WHERE ended_at IS NULL;
|
|
58
|
+
CREATE VIRTUAL TABLE actions_fts USING fts5(
|
|
59
|
+
command, args, cwd, content='actions', content_rowid='id', tokenize='trigram'
|
|
60
|
+
);
|
|
61
|
+
CREATE TRIGGER actions_fts_insert AFTER INSERT ON actions BEGIN
|
|
62
|
+
INSERT INTO actions_fts(rowid, command, args, cwd)
|
|
63
|
+
VALUES (new.id, coalesce(new.command, ''), coalesce(new.args, ''), coalesce(new.cwd, ''));
|
|
64
|
+
END;
|
|
65
|
+
CREATE TRIGGER actions_fts_delete AFTER DELETE ON actions BEGIN
|
|
66
|
+
INSERT INTO actions_fts(actions_fts, rowid, command, args, cwd)
|
|
67
|
+
VALUES ('delete', old.id, coalesce(old.command, ''), coalesce(old.args, ''), coalesce(old.cwd, ''));
|
|
68
|
+
END;
|
|
69
|
+
`;
|
|
70
|
+
function cut(value, budget) {
|
|
71
|
+
if (value === null)
|
|
72
|
+
return value;
|
|
73
|
+
if (value.length <= budget.remaining) {
|
|
74
|
+
budget.remaining -= value.length;
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
budget.truncated = true;
|
|
78
|
+
const kept = value.slice(0, budget.remaining);
|
|
79
|
+
budget.remaining = 0;
|
|
80
|
+
return kept;
|
|
81
|
+
}
|
|
82
|
+
function toActionRow(row) {
|
|
83
|
+
const state = { remaining: MAX_ROW_CHARS, truncated: false };
|
|
84
|
+
const command = cut(row.command, state);
|
|
85
|
+
const cwd = cut(row.cwd, state);
|
|
86
|
+
let args = null;
|
|
87
|
+
if (typeof row.args === "string") {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = row.command === null ? undefined : JSON.parse(row.args);
|
|
91
|
+
}
|
|
92
|
+
catch { }
|
|
93
|
+
if (Array.isArray(parsed)) {
|
|
94
|
+
args = [];
|
|
95
|
+
for (const arg of parsed) {
|
|
96
|
+
if (!state.remaining) {
|
|
97
|
+
state.truncated = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
args.push(cut(String(arg), state) ?? "");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
args = cut(row.args, state);
|
|
105
|
+
}
|
|
106
|
+
const value = {
|
|
107
|
+
id: Number(row.id),
|
|
108
|
+
direction: row.direction,
|
|
109
|
+
peer: row.peer,
|
|
110
|
+
action: row.action,
|
|
111
|
+
mode: row.mode,
|
|
112
|
+
command,
|
|
113
|
+
args,
|
|
114
|
+
cwd,
|
|
115
|
+
startedAt: Number(row.started_at),
|
|
116
|
+
endedAt: row.ended_at === null ? null : Number(row.ended_at),
|
|
117
|
+
outcome: row.outcome,
|
|
118
|
+
errorCode: row.error_code,
|
|
119
|
+
exitCode: row.exit_code === null ? null : Number(row.exit_code),
|
|
120
|
+
signal: row.signal,
|
|
121
|
+
stdinBytes: Number(row.stdin_bytes),
|
|
122
|
+
stdoutBytes: Number(row.stdout_bytes),
|
|
123
|
+
stderrBytes: Number(row.stderr_bytes),
|
|
124
|
+
transfer: row.transfer_direction === null
|
|
125
|
+
? null
|
|
126
|
+
: {
|
|
127
|
+
direction: row.transfer_direction,
|
|
128
|
+
localPath: cut(row.local_path, state) ?? "",
|
|
129
|
+
remotePath: cut(row.remote_path, state) ?? "",
|
|
130
|
+
bytes: row.transfer_bytes === null ? null : Number(row.transfer_bytes),
|
|
131
|
+
sha256: row.sha256,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
if (state.truncated)
|
|
135
|
+
value.truncated = true;
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
const optionalId = (value, name) => {
|
|
139
|
+
if (value === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
142
|
+
throw new QueryError(`${name} must be a non-negative integer.`);
|
|
143
|
+
return value;
|
|
144
|
+
};
|
|
145
|
+
function commonFilters(query, where, params) {
|
|
146
|
+
if (query.direction !== undefined) {
|
|
147
|
+
if (query.direction !== "in" && query.direction !== "out")
|
|
148
|
+
throw new QueryError("direction must be in or out.");
|
|
149
|
+
where.push("a.direction = ?");
|
|
150
|
+
params.push(query.direction);
|
|
151
|
+
}
|
|
152
|
+
if (query.peer !== undefined) {
|
|
153
|
+
if (typeof query.peer !== "string")
|
|
154
|
+
throw new QueryError("peer must be a string.");
|
|
155
|
+
where.push("a.peer = ?");
|
|
156
|
+
params.push(query.peer);
|
|
157
|
+
}
|
|
158
|
+
const since = optionalId(query.since, "since");
|
|
159
|
+
const until = optionalId(query.until, "until");
|
|
160
|
+
if (since !== undefined)
|
|
161
|
+
where.push("a.started_at >= ?"), params.push(since);
|
|
162
|
+
if (until !== undefined)
|
|
163
|
+
where.push("a.started_at < ?"), params.push(until);
|
|
164
|
+
}
|
|
165
|
+
export class ObserveStore {
|
|
166
|
+
options;
|
|
167
|
+
db;
|
|
168
|
+
statements = new Map();
|
|
169
|
+
secrets;
|
|
170
|
+
retentionMs;
|
|
171
|
+
maxBytes;
|
|
172
|
+
now;
|
|
173
|
+
constructor(options) {
|
|
174
|
+
this.options = options;
|
|
175
|
+
this.secrets = (options.secrets ?? []).filter(Boolean);
|
|
176
|
+
this.retentionMs = options.retentionMs ?? 30 * DAY_MS;
|
|
177
|
+
this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
|
|
178
|
+
this.now = options.now ?? Date.now;
|
|
179
|
+
}
|
|
180
|
+
open() {
|
|
181
|
+
this.db = this.attach();
|
|
182
|
+
if (!this.db)
|
|
183
|
+
return;
|
|
184
|
+
this.guard(() => {
|
|
185
|
+
this.run("UPDATE actions SET ended_at = ?, outcome = 'interrupted' WHERE ended_at IS NULL", this.now());
|
|
186
|
+
});
|
|
187
|
+
this.prune();
|
|
188
|
+
}
|
|
189
|
+
attach() {
|
|
190
|
+
try {
|
|
191
|
+
return this.connect();
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error instanceof SchemaVersionError) {
|
|
195
|
+
if (!this.discard(false))
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
else if (isCorruption(error)) {
|
|
199
|
+
if (!this.discard(true))
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return this.connect();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
connect() {
|
|
213
|
+
const { path } = this.options;
|
|
214
|
+
if (!existsSync(path))
|
|
215
|
+
closeSync(openSync(path, "a", 0o600));
|
|
216
|
+
const db = new DatabaseSync(path);
|
|
217
|
+
try {
|
|
218
|
+
db.exec("PRAGMA busy_timeout = 1000");
|
|
219
|
+
const check = db.prepare("PRAGMA quick_check").get();
|
|
220
|
+
if (check?.quick_check !== "ok")
|
|
221
|
+
throw new CorruptDatabaseError();
|
|
222
|
+
const version = Number(db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
|
|
223
|
+
if (version === 0) {
|
|
224
|
+
db.exec("PRAGMA auto_vacuum = INCREMENTAL");
|
|
225
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
226
|
+
db.exec(`BEGIN; ${SCHEMA} PRAGMA user_version = ${SCHEMA_VERSION}; COMMIT;`);
|
|
227
|
+
}
|
|
228
|
+
else if (version !== SCHEMA_VERSION) {
|
|
229
|
+
throw new SchemaVersionError();
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
233
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
234
|
+
return db;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
db.close();
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
discard(keepCopy) {
|
|
242
|
+
const { path } = this.options;
|
|
243
|
+
this.statements.clear();
|
|
244
|
+
try {
|
|
245
|
+
this.db?.close();
|
|
246
|
+
}
|
|
247
|
+
catch { }
|
|
248
|
+
this.db = undefined;
|
|
249
|
+
try {
|
|
250
|
+
if (keepCopy) {
|
|
251
|
+
const prefix = `${basename(path)}.corrupt-`;
|
|
252
|
+
for (const name of readdirSync(dirname(path)))
|
|
253
|
+
if (name.startsWith(prefix))
|
|
254
|
+
rmSync(join(dirname(path), name), { force: true });
|
|
255
|
+
if (existsSync(path))
|
|
256
|
+
renameSync(path, `${path}.corrupt-${this.now()}`);
|
|
257
|
+
}
|
|
258
|
+
else
|
|
259
|
+
rmSync(path, { force: true });
|
|
260
|
+
for (const suffix of ["-wal", "-shm"])
|
|
261
|
+
rmSync(`${path}${suffix}`, { force: true });
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
guard(work, fallback) {
|
|
269
|
+
if (!this.db)
|
|
270
|
+
return fallback;
|
|
271
|
+
try {
|
|
272
|
+
return work();
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (isCorruption(error))
|
|
276
|
+
this.db = this.discard(true) ? this.attach() : undefined;
|
|
277
|
+
return fallback;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
statement(sql) {
|
|
281
|
+
let statement = this.statements.get(sql);
|
|
282
|
+
if (!statement) {
|
|
283
|
+
statement = this.db.prepare(sql);
|
|
284
|
+
this.statements.set(sql, statement);
|
|
285
|
+
}
|
|
286
|
+
return statement;
|
|
287
|
+
}
|
|
288
|
+
run(sql, ...params) {
|
|
289
|
+
return this.statement(sql).run(...params);
|
|
290
|
+
}
|
|
291
|
+
all(sql, ...params) {
|
|
292
|
+
return this.statement(sql).all(...params);
|
|
293
|
+
}
|
|
294
|
+
mask(value) {
|
|
295
|
+
if (value === null || value === undefined)
|
|
296
|
+
return null;
|
|
297
|
+
let masked = value;
|
|
298
|
+
for (const secret of this.secrets)
|
|
299
|
+
masked = masked.split(secret).join("*".repeat(secret.length));
|
|
300
|
+
return masked;
|
|
301
|
+
}
|
|
302
|
+
startAction(start) {
|
|
303
|
+
return (this.guard(() => {
|
|
304
|
+
const args = start.args === undefined || start.args === null
|
|
305
|
+
? null
|
|
306
|
+
: typeof start.args === "string"
|
|
307
|
+
? this.mask(start.args)
|
|
308
|
+
: JSON.stringify(start.args.map((arg) => this.mask(arg)));
|
|
309
|
+
const result = this.run(`INSERT INTO actions (direction, peer, action, mode, command, args, cwd, started_at,
|
|
310
|
+
stdin_bytes, transfer_direction, local_path, remote_path)
|
|
311
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, start.direction, this.mask(start.peer), start.action, start.mode, this.mask(start.command), args, this.mask(start.cwd), this.now(), start.stdinBytes ?? 0, start.transfer?.direction ?? null, this.mask(start.transfer?.localPath), this.mask(start.transfer?.remotePath));
|
|
312
|
+
return this.action(Number(result.lastInsertRowid));
|
|
313
|
+
}, null) ?? null);
|
|
314
|
+
}
|
|
315
|
+
endAction(id, end) {
|
|
316
|
+
return (this.guard(() => {
|
|
317
|
+
this.run(`UPDATE actions SET ended_at = ?, outcome = ?, error_code = ?, exit_code = ?, signal = ?,
|
|
318
|
+
stdout_bytes = ?, stderr_bytes = ?, transfer_bytes = ?, sha256 = ?
|
|
319
|
+
WHERE id = ? AND ended_at IS NULL`, this.now(), end.outcome, end.errorCode ?? null, end.exitCode ?? null, end.signal ?? null, end.stdoutBytes ?? 0, end.stderrBytes ?? 0, end.transferBytes ?? null, end.sha256 ?? null, id);
|
|
320
|
+
return this.action(id);
|
|
321
|
+
}, null) ?? null);
|
|
322
|
+
}
|
|
323
|
+
action(id) {
|
|
324
|
+
const row = this.all("SELECT * FROM actions WHERE id = ?", id)[0];
|
|
325
|
+
return row ? toActionRow(row) : null;
|
|
326
|
+
}
|
|
327
|
+
running() {
|
|
328
|
+
return (this.guard(() => this.all("SELECT * FROM actions WHERE ended_at IS NULL ORDER BY id LIMIT ?", MAX_LIMIT).map(toActionRow), []) ?? []);
|
|
329
|
+
}
|
|
330
|
+
query(query) {
|
|
331
|
+
if (query.kind !== "actions")
|
|
332
|
+
throw new QueryError("Unknown query kind.");
|
|
333
|
+
return this.queryActions(query);
|
|
334
|
+
}
|
|
335
|
+
limit(value, fallback) {
|
|
336
|
+
if (value === undefined)
|
|
337
|
+
return fallback;
|
|
338
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1)
|
|
339
|
+
throw new QueryError("limit must be a positive integer.");
|
|
340
|
+
return Math.min(value, MAX_LIMIT);
|
|
341
|
+
}
|
|
342
|
+
queryActions(query) {
|
|
343
|
+
const where = [];
|
|
344
|
+
const params = [];
|
|
345
|
+
const before = optionalId(query.before, "before");
|
|
346
|
+
const limit = this.limit(query.limit, 100);
|
|
347
|
+
if (before !== undefined)
|
|
348
|
+
where.push("a.id < ?"), params.push(before);
|
|
349
|
+
commonFilters(query, where, params);
|
|
350
|
+
if (query.outcome !== undefined) {
|
|
351
|
+
if (!Array.isArray(query.outcome) ||
|
|
352
|
+
!query.outcome.length ||
|
|
353
|
+
!query.outcome.every((outcome) => OUTCOMES.includes(outcome)))
|
|
354
|
+
throw new QueryError("outcome must list known outcomes.");
|
|
355
|
+
where.push(`a.outcome IN (${query.outcome.map(() => "?").join(", ")})`);
|
|
356
|
+
params.push(...query.outcome);
|
|
357
|
+
}
|
|
358
|
+
if (query.result !== undefined) {
|
|
359
|
+
if (!Array.isArray(query.result) ||
|
|
360
|
+
!query.result.length ||
|
|
361
|
+
!query.result.every((result) => ACTION_RESULTS.includes(result)))
|
|
362
|
+
throw new QueryError("result must list known results.");
|
|
363
|
+
where.push(`${RESULT_SQL} IN (${query.result.map(() => "?").join(", ")})`);
|
|
364
|
+
params.push(...query.result);
|
|
365
|
+
}
|
|
366
|
+
let join = "";
|
|
367
|
+
if (query.text !== undefined) {
|
|
368
|
+
if (typeof query.text !== "string" || [...query.text].length < 3)
|
|
369
|
+
throw new QueryError("text must have at least three characters.");
|
|
370
|
+
join = "JOIN actions_fts ON actions_fts.rowid = a.id";
|
|
371
|
+
where.push("actions_fts MATCH ?");
|
|
372
|
+
params.push(`"${query.text.replaceAll('"', '""')}"`);
|
|
373
|
+
}
|
|
374
|
+
const sql = `SELECT a.* FROM actions a ${join} ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY a.id DESC LIMIT ?`;
|
|
375
|
+
return (this.guard(() => {
|
|
376
|
+
const raw = this.all(sql, ...params, limit + 1);
|
|
377
|
+
const rows = [];
|
|
378
|
+
let bytes = 0;
|
|
379
|
+
let hasMore = raw.length > limit;
|
|
380
|
+
for (const item of raw.slice(0, limit)) {
|
|
381
|
+
const row = toActionRow(item);
|
|
382
|
+
bytes += Buffer.byteLength(JSON.stringify(row));
|
|
383
|
+
if (rows.length && bytes > MAX_RESULT_BYTES) {
|
|
384
|
+
hasMore = true;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
rows.push(row);
|
|
388
|
+
}
|
|
389
|
+
return { rows, hasMore };
|
|
390
|
+
}, { rows: [], hasMore: false }) ?? { rows: [], hasMore: false });
|
|
391
|
+
}
|
|
392
|
+
stats(query) {
|
|
393
|
+
const since = optionalId(query.since, "since");
|
|
394
|
+
if (since === undefined)
|
|
395
|
+
throw new QueryError("since is required.");
|
|
396
|
+
const until = optionalId(query.until, "until") ?? this.now();
|
|
397
|
+
const bucketMs = query.bucketMs;
|
|
398
|
+
if (typeof bucketMs !== "number" || !Number.isSafeInteger(bucketMs) || bucketMs < 1000)
|
|
399
|
+
throw new QueryError("bucketMs must be an integer of at least 1000.");
|
|
400
|
+
if (until <= since || Math.ceil((until - since) / bucketMs) > MAX_BUCKETS)
|
|
401
|
+
throw new QueryError(`The range must hold between 1 and ${MAX_BUCKETS} buckets.`);
|
|
402
|
+
const where = [];
|
|
403
|
+
const params = [];
|
|
404
|
+
commonFilters({ ...query, since, until }, where, params);
|
|
405
|
+
const sql = `SELECT a.peer AS peer, CAST((a.started_at - ?) / ? AS INTEGER) AS bucket,
|
|
406
|
+
${RESULT_SQL} AS result, count(*) AS count
|
|
407
|
+
FROM actions a WHERE ${where.join(" AND ")}
|
|
408
|
+
GROUP BY peer, bucket, result ORDER BY bucket`;
|
|
409
|
+
const groups = this.guard(() => this.all(sql, since, bucketMs, ...params).map((row) => ({
|
|
410
|
+
peer: row.peer,
|
|
411
|
+
bucket: Number(row.bucket),
|
|
412
|
+
result: row.result,
|
|
413
|
+
count: Number(row.count),
|
|
414
|
+
})), []) ?? [];
|
|
415
|
+
return { since, until, bucketMs, groups };
|
|
416
|
+
}
|
|
417
|
+
prune() {
|
|
418
|
+
this.guard(() => {
|
|
419
|
+
const cutoff = this.now() - this.retentionMs;
|
|
420
|
+
let deleted = Number(this.run("DELETE FROM actions WHERE started_at < ? AND ended_at IS NOT NULL", cutoff)
|
|
421
|
+
.changes);
|
|
422
|
+
if (this.usedBytes() > this.maxBytes) {
|
|
423
|
+
this.compact();
|
|
424
|
+
for (let round = 0; round < 64 && this.usedBytes() > this.maxBytes; round++) {
|
|
425
|
+
const count = Number(this.all("SELECT count(*) AS n FROM actions")[0]?.n ?? 0);
|
|
426
|
+
if (!count)
|
|
427
|
+
break;
|
|
428
|
+
deleted += Number(this.run(`DELETE FROM actions WHERE id IN (SELECT id FROM actions WHERE ended_at IS NOT NULL
|
|
429
|
+
ORDER BY id LIMIT ?)`, Math.max(1, Math.ceil(count / 10))).changes);
|
|
430
|
+
this.compact();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (!deleted)
|
|
434
|
+
return;
|
|
435
|
+
this.db.exec("PRAGMA incremental_vacuum");
|
|
436
|
+
this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
compact() {
|
|
440
|
+
this.db.exec("INSERT INTO actions_fts(actions_fts) VALUES ('optimize')");
|
|
441
|
+
this.db.exec("PRAGMA incremental_vacuum");
|
|
442
|
+
}
|
|
443
|
+
usedBytes() {
|
|
444
|
+
const pages = Number(this.all("PRAGMA page_count")[0]?.page_count ?? 0);
|
|
445
|
+
const free = Number(this.all("PRAGMA freelist_count")[0]?.freelist_count ?? 0);
|
|
446
|
+
const size = Number(this.all("PRAGMA page_size")[0]?.page_size ?? 4096);
|
|
447
|
+
return (pages - free) * size;
|
|
448
|
+
}
|
|
449
|
+
close() {
|
|
450
|
+
this.statements.clear();
|
|
451
|
+
try {
|
|
452
|
+
this.db?.close();
|
|
453
|
+
}
|
|
454
|
+
catch { }
|
|
455
|
+
this.db = undefined;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { type FileHandle } from "node:fs/promises";
|
|
2
|
+
import { type ActionResponse } from "./actions.js";
|
|
3
|
+
import type { CopyProgress, CopyRequest } from "./daemon-protocol.js";
|
|
4
|
+
export declare const TRANSFER_VERSION = 1;
|
|
5
|
+
export declare const TRANSFER_CHUNK_BYTES: number;
|
|
6
|
+
export declare const TRANSFER_WINDOW_BYTES: number;
|
|
7
|
+
export declare const TRANSFER_IDLE_MS = 60000;
|
|
8
|
+
export declare const MAX_HOST_TRANSFERS = 4;
|
|
9
|
+
export declare const PART_SUFFIX = ".rome-part";
|
|
10
|
+
export type TransferMessage = {
|
|
11
|
+
type: "transfer";
|
|
12
|
+
kind: "data";
|
|
13
|
+
offset: number;
|
|
14
|
+
} | {
|
|
15
|
+
type: "transfer";
|
|
16
|
+
kind: "ack";
|
|
17
|
+
offset: number;
|
|
18
|
+
} | {
|
|
19
|
+
type: "transfer";
|
|
20
|
+
kind: "end";
|
|
21
|
+
size: number;
|
|
22
|
+
sha256: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: "transfer";
|
|
25
|
+
kind: "done";
|
|
26
|
+
size: number;
|
|
27
|
+
sha256: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: "transfer";
|
|
30
|
+
kind: "abort";
|
|
31
|
+
code: string;
|
|
32
|
+
message: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function parseTransferMessage(value: unknown): TransferMessage | null;
|
|
35
|
+
export declare class TransferError extends Error {
|
|
36
|
+
readonly code: string;
|
|
37
|
+
constructor(code: string, message: string);
|
|
38
|
+
}
|
|
39
|
+
export type TransferSend = (meta: unknown, body?: Uint8Array) => boolean;
|
|
40
|
+
export interface TransferOptions {
|
|
41
|
+
chunkBytes?: number;
|
|
42
|
+
windowBytes?: number;
|
|
43
|
+
idleMs?: number;
|
|
44
|
+
onProgress?(bytes: number): void;
|
|
45
|
+
}
|
|
46
|
+
export interface TransferSummary {
|
|
47
|
+
size: number;
|
|
48
|
+
sha256: string;
|
|
49
|
+
}
|
|
50
|
+
export interface TransferEndpoint {
|
|
51
|
+
readonly result: Promise<TransferSummary>;
|
|
52
|
+
receive(message: TransferMessage, body: Uint8Array): void;
|
|
53
|
+
abort(code: string, message: string, notify?: boolean): void;
|
|
54
|
+
}
|
|
55
|
+
export interface ReceiverEndpoint extends TransferEndpoint {
|
|
56
|
+
expect(size: number): void;
|
|
57
|
+
}
|
|
58
|
+
export interface Source {
|
|
59
|
+
size: number;
|
|
60
|
+
start(send: TransferSend, options?: TransferOptions): TransferEndpoint;
|
|
61
|
+
close(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export declare function openSource(path: string): Promise<Source>;
|
|
64
|
+
export interface Destination {
|
|
65
|
+
part: string;
|
|
66
|
+
start(send: TransferSend, options?: TransferOptions & {
|
|
67
|
+
size?: number;
|
|
68
|
+
}): ReceiverEndpoint;
|
|
69
|
+
cancel(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
export declare function createPartFile(path: string, id?: string): Promise<{
|
|
72
|
+
part: string;
|
|
73
|
+
handle: FileHandle;
|
|
74
|
+
}>;
|
|
75
|
+
export declare function openDestination(path: string): Promise<Destination>;
|
|
76
|
+
export declare class TransferHost {
|
|
77
|
+
private options;
|
|
78
|
+
private transfers;
|
|
79
|
+
constructor(options?: TransferOptions & {
|
|
80
|
+
limit?: number;
|
|
81
|
+
});
|
|
82
|
+
open(id: string, peer: string, args: unknown, send: TransferSend): Promise<void>;
|
|
83
|
+
receive(id: string, peer: string, message: TransferMessage, body: Uint8Array): void;
|
|
84
|
+
abortAll(): void;
|
|
85
|
+
}
|
|
86
|
+
export interface TransferChannel {
|
|
87
|
+
send: TransferSend;
|
|
88
|
+
close(): void;
|
|
89
|
+
}
|
|
90
|
+
export interface ChannelEvents {
|
|
91
|
+
frame(meta: unknown, body: Uint8Array): void;
|
|
92
|
+
lost(): void;
|
|
93
|
+
}
|
|
94
|
+
export interface CopyOptions extends Omit<TransferOptions, "onProgress"> {
|
|
95
|
+
signal?: AbortSignal;
|
|
96
|
+
onProgress?(progress: CopyProgress): void;
|
|
97
|
+
}
|
|
98
|
+
export declare function copyWithDevice(openChannel: (events: ChannelEvents) => Promise<TransferChannel | ActionResponse>, request: CopyRequest, options?: CopyOptions): Promise<{
|
|
99
|
+
bytes: number;
|
|
100
|
+
sha256: string;
|
|
101
|
+
ms: number;
|
|
102
|
+
}>;
|