@swimmingliu/autovpn 1.6.9 → 1.7.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.
@@ -0,0 +1,602 @@
1
+ import { createRequire } from 'node:module';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { redactText } from '../runtime/redaction.js';
5
+ import { canonicalVmessKey, parseVmessLink } from './dedupe.js';
6
+ const require = createRequire(import.meta.url);
7
+ const { DatabaseSync } = require('node:sqlite');
8
+ const TERMINAL_STATUSES = new Set([
9
+ 'success', 'failed', 'cancelled', 'skipped', 'stopped',
10
+ 'speed_passed', 'speed_failed', 'availability_passed', 'availability_failed'
11
+ ]);
12
+ function parseProviderResults(value) {
13
+ try {
14
+ const parsed = JSON.parse(String(value));
15
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
16
+ ? parsed
17
+ : {};
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ function strictBoolean(value) {
24
+ return value === true || value === 1;
25
+ }
26
+ function readLegacyLines(filePath) {
27
+ if (!fs.existsSync(filePath))
28
+ return [];
29
+ return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
30
+ }
31
+ function readLegacyReport(filePath) {
32
+ if (!fs.existsSync(filePath))
33
+ return [];
34
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf8'));
35
+ if (!Array.isArray(payload) || payload.some((row) => row === null || typeof row !== 'object' || Array.isArray(row))) {
36
+ throw new Error(`Invalid legacy report: ${path.basename(filePath)}`);
37
+ }
38
+ return payload;
39
+ }
40
+ export function readRunStatus(dbPath) {
41
+ if (!fs.existsSync(dbPath))
42
+ return undefined;
43
+ let db;
44
+ try {
45
+ db = new DatabaseSync(dbPath);
46
+ const row = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
47
+ const status = String(row?.status ?? '').trim();
48
+ return status || undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ finally {
54
+ try {
55
+ db?.close();
56
+ }
57
+ catch { /* malformed or locked databases are ignored */ }
58
+ }
59
+ }
60
+ export function readLatestStageStatuses(dbPath) {
61
+ if (!fs.existsSync(dbPath))
62
+ return {};
63
+ let db;
64
+ try {
65
+ db = new DatabaseSync(dbPath);
66
+ const rows = db.prepare('SELECT stage_name, status FROM stage_events ORDER BY rowid').all();
67
+ return Object.fromEntries(rows.map((row) => [String(row.stage_name ?? ''), String(row.status ?? '')]).filter(([name]) => name));
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ finally {
73
+ try {
74
+ db?.close();
75
+ }
76
+ catch { /* malformed or locked databases are ignored */ }
77
+ }
78
+ }
79
+ export class RunStore {
80
+ db;
81
+ statements = new Map();
82
+ runId;
83
+ closed = false;
84
+ constructor(db) {
85
+ this.db = db;
86
+ this.db.exec(`
87
+ PRAGMA journal_mode=WAL;
88
+ PRAGMA foreign_keys=ON;
89
+ PRAGMA busy_timeout=5000;
90
+ CREATE TABLE IF NOT EXISTS runs (
91
+ run_id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ status TEXT NOT NULL CHECK (status IN ('pending','running','success','failed','cancelled','stopped')),
93
+ error TEXT NOT NULL DEFAULT ''
94
+ );
95
+ CREATE TABLE IF NOT EXISTS stage_events (
96
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
97
+ stage_name TEXT NOT NULL,
98
+ status TEXT NOT NULL CHECK (status IN ('pending','running','success','failed','cancelled','skipped','stopped')),
99
+ error TEXT NOT NULL DEFAULT ''
100
+ );
101
+ CREATE TABLE IF NOT EXISTS source_progress (
102
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
103
+ source TEXT NOT NULL,
104
+ processed INTEGER NOT NULL CHECK (processed >= 0 AND processed <= total),
105
+ total INTEGER NOT NULL CHECK (total >= 0),
106
+ status TEXT NOT NULL CHECK (status IN ('pending','running','success','failed','cancelled','skipped')),
107
+ error TEXT NOT NULL DEFAULT '',
108
+ PRIMARY KEY (run_id, source)
109
+ );
110
+ CREATE TABLE IF NOT EXISTS raw_observations (
111
+ observation_id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
113
+ source TEXT NOT NULL,
114
+ link TEXT NOT NULL
115
+ );
116
+ CREATE TABLE IF NOT EXISTS pipeline_nodes (
117
+ node_id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
119
+ canonical_key TEXT NOT NULL,
120
+ link TEXT NOT NULL,
121
+ sequence INTEGER NOT NULL,
122
+ UNIQUE (run_id, canonical_key),
123
+ UNIQUE (run_id, sequence)
124
+ );
125
+ CREATE TABLE IF NOT EXISTS probe_results (
126
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
127
+ canonical_key TEXT NOT NULL,
128
+ reachable INTEGER NOT NULL,
129
+ latency_ms REAL NOT NULL,
130
+ error TEXT NOT NULL DEFAULT '',
131
+ PRIMARY KEY (run_id, canonical_key)
132
+ );
133
+ CREATE TABLE IF NOT EXISTS speed_results (
134
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
135
+ canonical_key TEXT NOT NULL,
136
+ status TEXT NOT NULL CHECK (status IN ('pending','running','speed_passed','speed_failed','cancelled','skipped')),
137
+ reachable INTEGER NOT NULL DEFAULT 0,
138
+ average_download_mb_s REAL NOT NULL DEFAULT 0,
139
+ latency_ms REAL NOT NULL DEFAULT 0,
140
+ error TEXT NOT NULL DEFAULT '',
141
+ PRIMARY KEY (run_id, canonical_key)
142
+ );
143
+ CREATE TABLE IF NOT EXISTS availability_results (
144
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
145
+ canonical_key TEXT NOT NULL,
146
+ status TEXT NOT NULL CHECK (status IN ('pending','running','availability_passed','availability_failed','cancelled','skipped')),
147
+ all_passed INTEGER NOT NULL DEFAULT 0,
148
+ provider_results TEXT NOT NULL DEFAULT '{}',
149
+ error TEXT NOT NULL DEFAULT '',
150
+ PRIMARY KEY (run_id, canonical_key)
151
+ );
152
+ `);
153
+ this.migrateSchema();
154
+ const latest = this.db.prepare('SELECT run_id FROM runs ORDER BY run_id DESC LIMIT 1').get();
155
+ this.runId = latest?.run_id;
156
+ }
157
+ static open(filePath) {
158
+ return new RunStore(new DatabaseSync(filePath));
159
+ }
160
+ static openOrImport(artifactDir) {
161
+ const dbPath = path.join(artifactDir, 'run.db');
162
+ if (fs.existsSync(dbPath)) {
163
+ const store = RunStore.open(dbPath);
164
+ try {
165
+ const hasLegacyLinks = readLegacyLines(path.join(artifactDir, 'vpn_node_raw.txt')).length > 0
166
+ || readLegacyLines(path.join(artifactDir, 'vpn_node_deduped.txt')).length > 0;
167
+ if (store.counts().deduped === 0 && hasLegacyLinks)
168
+ store.importLegacyArtifacts(artifactDir);
169
+ return store;
170
+ }
171
+ catch (error) {
172
+ store.close();
173
+ throw error;
174
+ }
175
+ }
176
+ const store = RunStore.open(dbPath);
177
+ try {
178
+ store.initializeRun('running');
179
+ store.importLegacyArtifacts(artifactDir);
180
+ return store;
181
+ }
182
+ catch (error) {
183
+ store.close();
184
+ try {
185
+ fs.rmSync(dbPath, { force: true });
186
+ }
187
+ catch { /* preserve the original import error */ }
188
+ throw error;
189
+ }
190
+ }
191
+ static seedRetry(sourceArtifactDir, destinationArtifactDir, boundary) {
192
+ const source = RunStore.openOrImport(sourceArtifactDir);
193
+ const dbPath = path.join(destinationArtifactDir, 'run.db');
194
+ let destination;
195
+ try {
196
+ destination = RunStore.open(dbPath);
197
+ destination.transaction(() => {
198
+ const inserted = destination.statement('INSERT INTO runs(status) VALUES (?)').run('running');
199
+ destination.runId = Number(inserted.lastInsertRowid);
200
+ const runId = destination.currentRunId();
201
+ for (const link of source.rawLinks()) {
202
+ destination.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, 'retry-seed', link);
203
+ }
204
+ source.dedupedLinks().forEach((link, index) => {
205
+ destination.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence) VALUES (?, ?, ?, ?)')
206
+ .run(runId, canonicalVmessKey(parseVmessLink(link)), link, index + 1);
207
+ });
208
+ const preserveSpeed = boundary !== 'speedtest';
209
+ const preserveAvailability = !['speedtest', 'availability'].includes(boundary);
210
+ if (preserveSpeed) {
211
+ for (const probe of source.probeResults())
212
+ destination.recordProbe(probe);
213
+ for (const result of source.speedResults().filter((row) => row.status === 'speed_passed' || row.status === 'speed_failed')) {
214
+ destination.recordSpeedResult(result, result.status === 'speed_passed');
215
+ }
216
+ }
217
+ if (preserveAvailability) {
218
+ for (const result of source.availabilityResults().filter((row) => row.status === 'availability_passed' || row.status === 'availability_failed')) {
219
+ destination.recordAvailabilityResult(result);
220
+ }
221
+ }
222
+ });
223
+ return destination;
224
+ }
225
+ catch (error) {
226
+ destination?.close();
227
+ for (const suffix of ['', '-wal', '-shm']) {
228
+ try {
229
+ fs.rmSync(`${dbPath}${suffix}`, { force: true });
230
+ }
231
+ catch { /* preserve seed failure */ }
232
+ }
233
+ throw error;
234
+ }
235
+ finally {
236
+ source.close();
237
+ }
238
+ }
239
+ close() {
240
+ if (this.closed)
241
+ return;
242
+ this.closed = true;
243
+ this.db.close();
244
+ }
245
+ busyTimeout() {
246
+ return Number(this.statement('PRAGMA busy_timeout').get().timeout);
247
+ }
248
+ initializeRun(status = 'running') {
249
+ const result = this.statement('INSERT INTO runs(status) VALUES (?)').run(status);
250
+ this.runId = Number(result.lastInsertRowid);
251
+ return this.runId;
252
+ }
253
+ setRunStatus(status, error = '') {
254
+ const runId = this.currentRunId();
255
+ try {
256
+ this.statement(`UPDATE runs SET status = ?, error = ? WHERE run_id = ?
257
+ AND status NOT IN ('success','failed','cancelled')`).run(status, redactText(error), runId);
258
+ }
259
+ catch (failure) {
260
+ if (!(failure instanceof Error) || !/no such column: error/.test(failure.message))
261
+ throw failure;
262
+ this.statement(`UPDATE runs SET status = ? WHERE run_id = ?
263
+ AND status NOT IN ('success','failed','cancelled')`).run(status, runId);
264
+ }
265
+ }
266
+ reopenForResume() {
267
+ const runId = this.currentRunId();
268
+ this.transaction(() => {
269
+ this.statement("UPDATE runs SET status='running', error='' WHERE run_id=? AND status IN ('failed','cancelled','stopped')").run(runId);
270
+ const failedStages = this.statement(`SELECT stage_name FROM stage_events e WHERE run_id=? AND status IN ('failed','stopped')
271
+ AND rowid=(SELECT MAX(rowid) FROM stage_events x WHERE x.run_id=e.run_id AND x.stage_name=e.stage_name)`).all(runId);
272
+ for (const row of failedStages) {
273
+ this.statement("INSERT INTO stage_events(run_id, stage_name, status, error) VALUES (?, ?, 'running', '')").run(runId, row.stage_name);
274
+ }
275
+ });
276
+ }
277
+ reopenSourcesForResume(sourceNames) {
278
+ const runId = this.currentRunId();
279
+ this.transaction(() => {
280
+ if (sourceNames && sourceNames.length === 0)
281
+ return;
282
+ if (sourceNames) {
283
+ const placeholders = sourceNames.map(() => '?').join(',');
284
+ this.statement(`UPDATE source_progress SET status='pending', error='' WHERE run_id=?
285
+ AND source IN (${placeholders}) AND status IN ('failed','cancelled')`).run(runId, ...sourceNames);
286
+ }
287
+ else {
288
+ this.statement("UPDATE source_progress SET status='pending', error='' WHERE run_id=? AND status IN ('failed','cancelled')").run(runId);
289
+ }
290
+ });
291
+ }
292
+ resetSourceForRerun(source, total = 0) {
293
+ if (!Number.isInteger(total) || total < 0)
294
+ throw new RangeError('source rerun total must be a non-negative integer');
295
+ return this.transaction(() => Number(this.statement(`UPDATE source_progress SET processed=0, total=?, status='pending', error=''
296
+ WHERE run_id=? AND source=? AND status NOT IN ('success','skipped')`).run(total, this.currentRunId(), source).changes) > 0);
297
+ }
298
+ stopForResume(error = 'Stopped by user') {
299
+ const runId = this.currentRunId();
300
+ return this.transaction(() => {
301
+ const changed = Number(this.statement("UPDATE runs SET status='stopped', error=? WHERE run_id=? AND status IN ('pending','running')")
302
+ .run(redactText(error), runId).changes) > 0;
303
+ if (!changed)
304
+ return false;
305
+ const activeStages = this.statement(`SELECT stage_name FROM stage_events e WHERE run_id=? AND status='running'
306
+ AND rowid=(SELECT MAX(rowid) FROM stage_events x WHERE x.run_id=e.run_id AND x.stage_name=e.stage_name)`).all(runId);
307
+ for (const { stage_name } of activeStages) {
308
+ this.statement("INSERT INTO stage_events(run_id, stage_name, status, error) VALUES (?, ?, 'stopped', ?)")
309
+ .run(runId, stage_name, redactText(error));
310
+ }
311
+ this.statement("UPDATE speed_results SET status='pending', error='' WHERE run_id=? AND status='running'").run(runId);
312
+ this.statement("UPDATE availability_results SET status='pending', error='' WHERE run_id=? AND status='running'").run(runId);
313
+ this.statement("UPDATE source_progress SET status='pending', error='' WHERE run_id=? AND status='running'").run(runId);
314
+ return true;
315
+ });
316
+ }
317
+ setStageStatus(stageName, status, error = '') {
318
+ const runId = this.currentRunId();
319
+ try {
320
+ this.transaction(() => {
321
+ const latest = this.statement('SELECT status FROM stage_events WHERE run_id = ? AND stage_name = ? ORDER BY rowid DESC LIMIT 1')
322
+ .get(runId, stageName);
323
+ if (!latest || !TERMINAL_STATUSES.has(latest.status)) {
324
+ this.statement('INSERT INTO stage_events(run_id, stage_name, status, error) VALUES (?, ?, ?, ?)')
325
+ .run(runId, stageName, status, redactText(error));
326
+ }
327
+ });
328
+ }
329
+ catch (failure) {
330
+ if (!(failure instanceof Error) || !/no such column: run_id/.test(failure.message))
331
+ throw failure;
332
+ const latest = this.statement('SELECT status FROM stage_events WHERE stage_name = ? ORDER BY rowid DESC LIMIT 1')
333
+ .get(stageName);
334
+ if (!latest || !TERMINAL_STATUSES.has(latest.status)) {
335
+ this.statement('INSERT INTO stage_events(stage_name, status) VALUES (?, ?)').run(stageName, status);
336
+ }
337
+ }
338
+ }
339
+ recordSourceProgress(source, progress) {
340
+ if (!Number.isInteger(progress.processed) || !Number.isInteger(progress.total) || progress.processed < 0 || progress.total < 0 || progress.processed > progress.total) {
341
+ throw new RangeError('source progress requires integer values with 0 <= processed <= total');
342
+ }
343
+ this.statement(`INSERT INTO source_progress(run_id, source, processed, total, status, error)
344
+ VALUES (?, ?, ?, ?, ?, ?)
345
+ ON CONFLICT(run_id, source) DO UPDATE SET
346
+ processed=excluded.processed, total=excluded.total, status=excluded.status, error=excluded.error
347
+ WHERE excluded.processed >= source_progress.processed
348
+ AND excluded.total >= source_progress.total
349
+ AND source_progress.status NOT IN ('success','failed','cancelled','skipped')`)
350
+ .run(this.currentRunId(), source, progress.processed, progress.total, progress.status, redactText(progress.error ?? ''));
351
+ }
352
+ sourceProgress() {
353
+ const rows = this.statement('SELECT source, processed, total, status, error FROM source_progress WHERE run_id = ? ORDER BY rowid')
354
+ .all(this.currentRunId());
355
+ return rows.map((row) => ({ source: String(row.source), processed: Number(row.processed), total: Number(row.total), status: String(row.status), error: String(row.error) }));
356
+ }
357
+ incompleteSourceProgress() {
358
+ return this.sourceProgress().filter((row) => !['success', 'skipped'].includes(row.status));
359
+ }
360
+ recordExtractedNode(source, link) {
361
+ const runId = this.currentRunId();
362
+ const key = canonicalVmessKey(parseVmessLink(link));
363
+ return this.transaction(() => {
364
+ this.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, source, link);
365
+ const existing = this.statement('SELECT sequence FROM pipeline_nodes WHERE run_id = ? AND canonical_key = ?')
366
+ .get(runId, key);
367
+ if (existing)
368
+ return { inserted: false, sequence: existing.sequence };
369
+ const row = this.statement('SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM pipeline_nodes WHERE run_id = ?')
370
+ .get(runId);
371
+ this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence) VALUES (?, ?, ?, ?)')
372
+ .run(runId, key, link, row.sequence);
373
+ return { inserted: true, sequence: row.sequence };
374
+ });
375
+ }
376
+ rawLinks() {
377
+ return this.statement('SELECT link FROM raw_observations WHERE run_id = ? ORDER BY observation_id').all(this.currentRunId()).map((row) => row.link);
378
+ }
379
+ rawLinksForSource(source) {
380
+ return this.statement('SELECT link FROM raw_observations WHERE run_id = ? AND source = ? ORDER BY observation_id')
381
+ .all(this.currentRunId(), source).map((row) => row.link);
382
+ }
383
+ dedupedLinks() {
384
+ return this.statement('SELECT link FROM pipeline_nodes WHERE run_id = ? ORDER BY sequence').all(this.currentRunId()).map((row) => row.link);
385
+ }
386
+ markSpeedRunning(link) {
387
+ const { runId, key } = this.nodeIdentity(link);
388
+ this.statement(`INSERT INTO speed_results(run_id, canonical_key, status) VALUES (?, ?, 'running')
389
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status='running'
390
+ WHERE speed_results.status NOT IN ('success','failed','speed_passed','speed_failed','cancelled','skipped')`).run(runId, key);
391
+ }
392
+ recordProbe(result) {
393
+ const { runId, key } = this.nodeIdentity(result.link);
394
+ this.statement(`INSERT INTO probe_results(run_id, canonical_key, reachable, latency_ms, error) VALUES (?, ?, ?, ?, ?)
395
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET reachable=excluded.reachable, latency_ms=excluded.latency_ms, error=excluded.error`)
396
+ .run(runId, key, Number(result.reachable), result.latency_ms, redactText(result.error ?? ''));
397
+ }
398
+ recordSpeedResult(result, passed) {
399
+ const { runId, key } = this.nodeIdentity(result.link);
400
+ this.statement(`INSERT INTO speed_results(run_id, canonical_key, status, reachable, average_download_mb_s, latency_ms, error)
401
+ VALUES (?, ?, ?, ?, ?, ?, ?)
402
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status=excluded.status, reachable=excluded.reachable,
403
+ average_download_mb_s=excluded.average_download_mb_s, latency_ms=excluded.latency_ms, error=excluded.error
404
+ WHERE speed_results.status NOT IN ('speed_passed','speed_failed','cancelled','skipped')`)
405
+ .run(runId, key, passed ? 'speed_passed' : 'speed_failed', Number(result.reachable), result.average_download_mb_s, result.latency_ms, redactText(result.error ?? ''));
406
+ }
407
+ probeResults() {
408
+ const rows = this.statement(`SELECT n.link, p.reachable, p.latency_ms, p.error FROM probe_results p
409
+ JOIN pipeline_nodes n ON n.run_id=p.run_id AND n.canonical_key=p.canonical_key
410
+ WHERE p.run_id=? ORDER BY n.sequence`).all(this.currentRunId());
411
+ return rows.map((row) => ({ link: String(row.link), reachable: Boolean(row.reachable), latency_ms: Number(row.latency_ms), error: String(row.error) }));
412
+ }
413
+ speedResults() {
414
+ const rows = this.statement(`SELECT n.link, s.status, s.reachable, s.average_download_mb_s, s.latency_ms, s.error FROM speed_results s
415
+ JOIN pipeline_nodes n ON n.run_id=s.run_id AND n.canonical_key=s.canonical_key
416
+ WHERE s.run_id=? ORDER BY n.sequence`).all(this.currentRunId());
417
+ return rows.map((row) => ({ link: String(row.link), reachable: Boolean(row.reachable), average_download_mb_s: Number(row.average_download_mb_s), latency_ms: Number(row.latency_ms), error: String(row.error), status: String(row.status) }));
418
+ }
419
+ speedLinksNeedingWork() {
420
+ return this.statement(`SELECT n.link FROM pipeline_nodes n
421
+ LEFT JOIN speed_results s ON s.run_id=n.run_id AND s.canonical_key=n.canonical_key
422
+ WHERE n.run_id=? AND (s.status IS NULL OR s.status IN ('pending','running')) ORDER BY n.sequence`)
423
+ .all(this.currentRunId()).map((row) => row.link);
424
+ }
425
+ markAvailabilityRunning(link) {
426
+ const { runId, key } = this.nodeIdentity(link);
427
+ this.statement(`INSERT INTO availability_results(run_id, canonical_key, status) VALUES (?, ?, 'running')
428
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status='running'
429
+ WHERE availability_results.status NOT IN ('success','failed','availability_passed','availability_failed','cancelled','skipped')`).run(runId, key);
430
+ }
431
+ recordAvailabilityResult(result) {
432
+ const { runId, key } = this.nodeIdentity(result.link);
433
+ const error = redactText(result.error ?? '');
434
+ const passed = strictBoolean(result.all_passed) && !error;
435
+ const status = passed ? 'availability_passed' : 'availability_failed';
436
+ this.statement(`INSERT INTO availability_results(run_id, canonical_key, status, all_passed, provider_results, error)
437
+ VALUES (?, ?, ?, ?, ?, ?)
438
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status=excluded.status, all_passed=excluded.all_passed,
439
+ provider_results=excluded.provider_results, error=excluded.error
440
+ WHERE availability_results.status NOT IN ('availability_passed','availability_failed','cancelled','skipped')`)
441
+ .run(runId, key, status, Number(passed), JSON.stringify(result.provider_results), error);
442
+ }
443
+ availabilityResults() {
444
+ const rows = this.statement(`SELECT n.link, a.status, a.all_passed, a.provider_results, a.error FROM availability_results a
445
+ JOIN pipeline_nodes n ON n.run_id=a.run_id AND n.canonical_key=a.canonical_key
446
+ WHERE a.run_id=? ORDER BY n.sequence`).all(this.currentRunId());
447
+ return rows.map((row) => ({ link: String(row.link), all_passed: Boolean(row.all_passed), provider_results: parseProviderResults(row.provider_results), error: String(row.error), status: String(row.status) }));
448
+ }
449
+ availabilityLinksNeedingWork() {
450
+ return this.statement(`SELECT n.link FROM pipeline_nodes n
451
+ JOIN speed_results s ON s.run_id=n.run_id AND s.canonical_key=n.canonical_key AND s.status='speed_passed'
452
+ LEFT JOIN availability_results a ON a.run_id=n.run_id AND a.canonical_key=n.canonical_key
453
+ WHERE n.run_id=? AND (a.status IS NULL OR a.status IN ('pending','running')) ORDER BY n.sequence`)
454
+ .all(this.currentRunId()).map((row) => row.link);
455
+ }
456
+ counts() {
457
+ const runId = this.currentRunId();
458
+ const count = (table) => Number(this.statement(`SELECT COUNT(*) AS count FROM ${table} WHERE run_id = ?`).get(runId).count);
459
+ return { raw: count('raw_observations'), deduped: count('pipeline_nodes'), probes: count('probe_results'), speed: count('speed_results'), availability: count('availability_results') };
460
+ }
461
+ resetInterruptedRunning() {
462
+ const runId = this.currentRunId();
463
+ return this.transaction(() => {
464
+ const links = (table) => this.statement(`SELECT n.link FROM ${table} r JOIN pipeline_nodes n ON n.run_id=r.run_id AND n.canonical_key=r.canonical_key WHERE r.run_id=? AND r.status='running' ORDER BY n.sequence`).all(runId).map((row) => row.link);
465
+ const speed = links('speed_results');
466
+ const availability = links('availability_results');
467
+ this.statement("UPDATE speed_results SET status='pending' WHERE run_id=? AND status='running'").run(runId);
468
+ this.statement("UPDATE availability_results SET status='pending' WHERE run_id=? AND status='running'").run(runId);
469
+ return { speed, availability };
470
+ });
471
+ }
472
+ classifySpeedResults(minDownloadMbS) {
473
+ this.statement(`UPDATE speed_results SET status = CASE
474
+ WHEN reachable = 1 AND average_download_mb_s >= ? AND error = '' THEN 'speed_passed'
475
+ ELSE 'speed_failed' END
476
+ WHERE run_id = ? AND status IN ('speed_passed','speed_failed')`)
477
+ .run(minDownloadMbS, this.currentRunId());
478
+ }
479
+ importLegacyArtifacts(artifactDir) {
480
+ const runId = this.currentRunId();
481
+ const rawLinks = readLegacyLines(path.join(artifactDir, 'vpn_node_raw.txt'));
482
+ const dedupedLinks = readLegacyLines(path.join(artifactDir, 'vpn_node_deduped.txt'));
483
+ const speedRows = readLegacyReport(path.join(artifactDir, 'vpn_node_speedtest_report.json'));
484
+ const availabilityRows = readLegacyReport(path.join(artifactDir, 'vpn_node_availability_report.json'));
485
+ this.transaction(() => {
486
+ for (const link of rawLinks) {
487
+ parseVmessLink(link);
488
+ this.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, 'legacy', link);
489
+ }
490
+ const nodeLinks = dedupedLinks.length > 0 ? dedupedLinks : rawLinks;
491
+ const seen = new Set();
492
+ for (const link of nodeLinks) {
493
+ const key = canonicalVmessKey(parseVmessLink(link));
494
+ if (seen.has(key))
495
+ continue;
496
+ seen.add(key);
497
+ this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence) VALUES (?, ?, ?, ?)')
498
+ .run(runId, key, link, seen.size);
499
+ }
500
+ for (const row of speedRows) {
501
+ const link = String(row.link ?? '');
502
+ const key = canonicalVmessKey(parseVmessLink(link));
503
+ if (!seen.has(key))
504
+ throw new Error('Legacy speed report references an unknown link');
505
+ const reachable = Boolean(row.reachable);
506
+ this.statement(`INSERT INTO speed_results(run_id, canonical_key, status, reachable, average_download_mb_s, latency_ms, error)
507
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
508
+ .run(runId, key, reachable && Number(row.average_download_mb_s ?? 0) > 0 ? 'speed_passed' : 'speed_failed', Number(reachable), Number(row.average_download_mb_s ?? 0), Number(row.latency_ms ?? 0), redactText(String(row.error ?? '')));
509
+ }
510
+ for (const row of availabilityRows) {
511
+ const link = String(row.link ?? '');
512
+ const key = canonicalVmessKey(parseVmessLink(link));
513
+ if (!seen.has(key))
514
+ throw new Error('Legacy availability report references an unknown link');
515
+ const error = redactText(String(row.error ?? ''));
516
+ const allPassed = strictBoolean(row.all_passed) && !error;
517
+ this.statement(`INSERT INTO availability_results(run_id, canonical_key, status, all_passed, provider_results, error)
518
+ VALUES (?, ?, ?, ?, ?, ?)`)
519
+ .run(runId, key, allPassed ? 'availability_passed' : 'availability_failed', Number(allPassed), JSON.stringify(row.provider_results ?? {}), error);
520
+ }
521
+ });
522
+ }
523
+ nodeIdentity(link) {
524
+ const runId = this.currentRunId();
525
+ const key = canonicalVmessKey(parseVmessLink(link));
526
+ const exists = this.statement('SELECT 1 AS found FROM pipeline_nodes WHERE run_id = ? AND canonical_key = ?').get(runId, key);
527
+ if (!exists)
528
+ throw new Error('unknown pipeline node');
529
+ return { runId, key };
530
+ }
531
+ migrateSchema() {
532
+ const columns = (table) => new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
533
+ this.db.exec('PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;');
534
+ this.db.exec('BEGIN IMMEDIATE');
535
+ try {
536
+ if (!columns('runs').has('error'))
537
+ this.db.exec("ALTER TABLE runs ADD COLUMN error TEXT NOT NULL DEFAULT ''");
538
+ const stageColumns = columns('stage_events');
539
+ if (!stageColumns.has('run_id')) {
540
+ this.db.exec('ALTER TABLE stage_events ADD COLUMN run_id INTEGER');
541
+ this.db.exec('UPDATE stage_events SET run_id=(SELECT run_id FROM runs ORDER BY run_id DESC LIMIT 1) WHERE run_id IS NULL');
542
+ }
543
+ if (!stageColumns.has('error'))
544
+ this.db.exec("ALTER TABLE stage_events ADD COLUMN error TEXT NOT NULL DEFAULT ''");
545
+ const schemaRows = this.db.prepare("SELECT name, sql FROM sqlite_schema WHERE type='table' AND name IN ('runs','stage_events')").all();
546
+ const needsStoppedMigration = schemaRows.some((row) => !row.sql.includes("'stopped'"));
547
+ if (needsStoppedMigration) {
548
+ this.db.exec(`
549
+ ALTER TABLE stage_events RENAME TO stage_events_v1;
550
+ CREATE TABLE stage_events (
551
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
552
+ stage_name TEXT NOT NULL,
553
+ status TEXT NOT NULL CHECK (status IN ('pending','running','success','failed','cancelled','skipped','stopped')),
554
+ error TEXT NOT NULL DEFAULT ''
555
+ );
556
+ INSERT INTO stage_events(run_id,stage_name,status,error) SELECT run_id,stage_name,status,error FROM stage_events_v1;
557
+ DROP TABLE stage_events_v1;
558
+ ALTER TABLE runs RENAME TO runs_v1;
559
+ CREATE TABLE runs (
560
+ run_id INTEGER PRIMARY KEY AUTOINCREMENT,
561
+ status TEXT NOT NULL CHECK (status IN ('pending','running','success','failed','cancelled','stopped')),
562
+ error TEXT NOT NULL DEFAULT ''
563
+ );
564
+ INSERT INTO runs(run_id,status,error) SELECT run_id,status,error FROM runs_v1;
565
+ DROP TABLE runs_v1;
566
+ `);
567
+ }
568
+ this.db.exec('PRAGMA user_version=2; COMMIT');
569
+ this.db.exec('PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;');
570
+ }
571
+ catch (error) {
572
+ this.db.exec('ROLLBACK');
573
+ this.db.exec('PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;');
574
+ throw error;
575
+ }
576
+ }
577
+ currentRunId() {
578
+ if (this.runId === undefined)
579
+ throw new Error('RunStore.initializeRun() must be called first');
580
+ return this.runId;
581
+ }
582
+ statement(sql) {
583
+ let statement = this.statements.get(sql);
584
+ if (!statement) {
585
+ statement = this.db.prepare(sql);
586
+ this.statements.set(sql, statement);
587
+ }
588
+ return statement;
589
+ }
590
+ transaction(operation) {
591
+ this.db.exec('BEGIN IMMEDIATE');
592
+ try {
593
+ const result = operation();
594
+ this.db.exec('COMMIT');
595
+ return result;
596
+ }
597
+ catch (error) {
598
+ this.db.exec('ROLLBACK');
599
+ throw error;
600
+ }
601
+ }
602
+ }