@swimmingliu/autovpn 1.6.9 → 1.8.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,654 @@
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
+ first_source TEXT,
123
+ UNIQUE (run_id, canonical_key),
124
+ UNIQUE (run_id, sequence)
125
+ );
126
+ CREATE TABLE IF NOT EXISTS probe_results (
127
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
128
+ canonical_key TEXT NOT NULL,
129
+ reachable INTEGER NOT NULL,
130
+ latency_ms REAL NOT NULL,
131
+ error TEXT NOT NULL DEFAULT '',
132
+ PRIMARY KEY (run_id, canonical_key)
133
+ );
134
+ CREATE TABLE IF NOT EXISTS speed_results (
135
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
136
+ canonical_key TEXT NOT NULL,
137
+ status TEXT NOT NULL CHECK (status IN ('pending','running','speed_passed','speed_failed','cancelled','skipped')),
138
+ reachable INTEGER NOT NULL DEFAULT 0,
139
+ average_download_mb_s REAL NOT NULL DEFAULT 0,
140
+ latency_ms REAL NOT NULL DEFAULT 0,
141
+ error TEXT NOT NULL DEFAULT '',
142
+ PRIMARY KEY (run_id, canonical_key)
143
+ );
144
+ CREATE TABLE IF NOT EXISTS availability_results (
145
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
146
+ canonical_key TEXT NOT NULL,
147
+ status TEXT NOT NULL CHECK (status IN ('pending','running','availability_passed','availability_failed','cancelled','skipped')),
148
+ all_passed INTEGER NOT NULL DEFAULT 0,
149
+ provider_results TEXT NOT NULL DEFAULT '{}',
150
+ error TEXT NOT NULL DEFAULT '',
151
+ PRIMARY KEY (run_id, canonical_key)
152
+ );
153
+ `);
154
+ this.migrateSchema();
155
+ const latest = this.db.prepare('SELECT run_id FROM runs ORDER BY run_id DESC LIMIT 1').get();
156
+ this.runId = latest?.run_id;
157
+ }
158
+ static open(filePath) {
159
+ return new RunStore(new DatabaseSync(filePath));
160
+ }
161
+ static openOrImport(artifactDir) {
162
+ const dbPath = path.join(artifactDir, 'run.db');
163
+ if (fs.existsSync(dbPath)) {
164
+ const store = RunStore.open(dbPath);
165
+ try {
166
+ const hasLegacyLinks = readLegacyLines(path.join(artifactDir, 'vpn_node_raw.txt')).length > 0
167
+ || readLegacyLines(path.join(artifactDir, 'vpn_node_deduped.txt')).length > 0;
168
+ if (store.counts().deduped === 0 && hasLegacyLinks)
169
+ store.importLegacyArtifacts(artifactDir);
170
+ return store;
171
+ }
172
+ catch (error) {
173
+ store.close();
174
+ throw error;
175
+ }
176
+ }
177
+ const store = RunStore.open(dbPath);
178
+ try {
179
+ store.initializeRun('running');
180
+ store.importLegacyArtifacts(artifactDir);
181
+ return store;
182
+ }
183
+ catch (error) {
184
+ store.close();
185
+ try {
186
+ fs.rmSync(dbPath, { force: true });
187
+ }
188
+ catch { /* preserve the original import error */ }
189
+ throw error;
190
+ }
191
+ }
192
+ static seedRetry(sourceArtifactDir, destinationArtifactDir, boundary) {
193
+ const source = RunStore.openOrImport(sourceArtifactDir);
194
+ const dbPath = path.join(destinationArtifactDir, 'run.db');
195
+ let destination;
196
+ try {
197
+ destination = RunStore.open(dbPath);
198
+ destination.transaction(() => {
199
+ const inserted = destination.statement('INSERT INTO runs(status) VALUES (?)').run('running');
200
+ destination.runId = Number(inserted.lastInsertRowid);
201
+ const runId = destination.currentRunId();
202
+ for (const observation of source.rawObservations()) {
203
+ destination.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, observation.source, observation.link);
204
+ }
205
+ source.dedupedNodeOwnership().forEach(({ link, first_source }, index) => {
206
+ destination.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence, first_source) VALUES (?, ?, ?, ?, ?)')
207
+ .run(runId, canonicalVmessKey(parseVmessLink(link)), link, index + 1, first_source);
208
+ });
209
+ const preserveSpeed = boundary !== 'speedtest';
210
+ const preserveAvailability = !['speedtest', 'availability'].includes(boundary);
211
+ if (preserveSpeed) {
212
+ for (const probe of source.probeResults())
213
+ destination.recordProbe(probe);
214
+ for (const result of source.speedResults().filter((row) => row.status === 'speed_passed' || row.status === 'speed_failed')) {
215
+ destination.recordSpeedResult(result, result.status === 'speed_passed');
216
+ }
217
+ }
218
+ if (preserveAvailability) {
219
+ for (const result of source.availabilityResults().filter((row) => row.status === 'availability_passed' || row.status === 'availability_failed')) {
220
+ destination.recordAvailabilityResult(result);
221
+ }
222
+ }
223
+ });
224
+ return destination;
225
+ }
226
+ catch (error) {
227
+ destination?.close();
228
+ for (const suffix of ['', '-wal', '-shm']) {
229
+ try {
230
+ fs.rmSync(`${dbPath}${suffix}`, { force: true });
231
+ }
232
+ catch { /* preserve seed failure */ }
233
+ }
234
+ throw error;
235
+ }
236
+ finally {
237
+ source.close();
238
+ }
239
+ }
240
+ close() {
241
+ if (this.closed)
242
+ return;
243
+ this.closed = true;
244
+ this.db.close();
245
+ }
246
+ busyTimeout() {
247
+ return Number(this.statement('PRAGMA busy_timeout').get().timeout);
248
+ }
249
+ initializeRun(status = 'running') {
250
+ const result = this.statement('INSERT INTO runs(status) VALUES (?)').run(status);
251
+ this.runId = Number(result.lastInsertRowid);
252
+ return this.runId;
253
+ }
254
+ setRunStatus(status, error = '') {
255
+ const runId = this.currentRunId();
256
+ try {
257
+ this.statement(`UPDATE runs SET status = ?, error = ? WHERE run_id = ?
258
+ AND status NOT IN ('success','failed','cancelled')`).run(status, redactText(error), runId);
259
+ }
260
+ catch (failure) {
261
+ if (!(failure instanceof Error) || !/no such column: error/.test(failure.message))
262
+ throw failure;
263
+ this.statement(`UPDATE runs SET status = ? WHERE run_id = ?
264
+ AND status NOT IN ('success','failed','cancelled')`).run(status, runId);
265
+ }
266
+ }
267
+ reopenForResume() {
268
+ const runId = this.currentRunId();
269
+ this.transaction(() => {
270
+ this.statement("UPDATE runs SET status='running', error='' WHERE run_id=? AND status IN ('failed','cancelled','stopped')").run(runId);
271
+ const failedStages = this.statement(`SELECT stage_name FROM stage_events e WHERE run_id=? AND status IN ('failed','stopped')
272
+ 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);
273
+ for (const row of failedStages) {
274
+ this.statement("INSERT INTO stage_events(run_id, stage_name, status, error) VALUES (?, ?, 'running', '')").run(runId, row.stage_name);
275
+ }
276
+ });
277
+ }
278
+ reopenSourcesForResume(sourceNames) {
279
+ const runId = this.currentRunId();
280
+ this.transaction(() => {
281
+ if (sourceNames && sourceNames.length === 0)
282
+ return;
283
+ if (sourceNames) {
284
+ const placeholders = sourceNames.map(() => '?').join(',');
285
+ this.statement(`UPDATE source_progress SET status='pending', error='' WHERE run_id=?
286
+ AND source IN (${placeholders}) AND status IN ('failed','cancelled')`).run(runId, ...sourceNames);
287
+ }
288
+ else {
289
+ this.statement("UPDATE source_progress SET status='pending', error='' WHERE run_id=? AND status IN ('failed','cancelled')").run(runId);
290
+ }
291
+ });
292
+ }
293
+ resetSourceForRerun(source, total = 0) {
294
+ if (!Number.isInteger(total) || total < 0)
295
+ throw new RangeError('source rerun total must be a non-negative integer');
296
+ return this.transaction(() => Number(this.statement(`UPDATE source_progress SET processed=0, total=?, status='pending', error=''
297
+ WHERE run_id=? AND source=? AND status NOT IN ('success','skipped')`).run(total, this.currentRunId(), source).changes) > 0);
298
+ }
299
+ stopForResume(error = 'Stopped by user') {
300
+ const runId = this.currentRunId();
301
+ return this.transaction(() => {
302
+ const changed = Number(this.statement("UPDATE runs SET status='stopped', error=? WHERE run_id=? AND status IN ('pending','running')")
303
+ .run(redactText(error), runId).changes) > 0;
304
+ if (!changed)
305
+ return false;
306
+ const activeStages = this.statement(`SELECT stage_name FROM stage_events e WHERE run_id=? AND status='running'
307
+ 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);
308
+ for (const { stage_name } of activeStages) {
309
+ this.statement("INSERT INTO stage_events(run_id, stage_name, status, error) VALUES (?, ?, 'stopped', ?)")
310
+ .run(runId, stage_name, redactText(error));
311
+ }
312
+ this.statement("UPDATE speed_results SET status='pending', error='' WHERE run_id=? AND status='running'").run(runId);
313
+ this.statement("UPDATE availability_results SET status='pending', error='' WHERE run_id=? AND status='running'").run(runId);
314
+ this.statement("UPDATE source_progress SET status='pending', error='' WHERE run_id=? AND status='running'").run(runId);
315
+ return true;
316
+ });
317
+ }
318
+ setStageStatus(stageName, status, error = '') {
319
+ const runId = this.currentRunId();
320
+ try {
321
+ this.transaction(() => {
322
+ const latest = this.statement('SELECT status FROM stage_events WHERE run_id = ? AND stage_name = ? ORDER BY rowid DESC LIMIT 1')
323
+ .get(runId, stageName);
324
+ if (!latest || !TERMINAL_STATUSES.has(latest.status)) {
325
+ this.statement('INSERT INTO stage_events(run_id, stage_name, status, error) VALUES (?, ?, ?, ?)')
326
+ .run(runId, stageName, status, redactText(error));
327
+ }
328
+ });
329
+ }
330
+ catch (failure) {
331
+ if (!(failure instanceof Error) || !/no such column: run_id/.test(failure.message))
332
+ throw failure;
333
+ const latest = this.statement('SELECT status FROM stage_events WHERE stage_name = ? ORDER BY rowid DESC LIMIT 1')
334
+ .get(stageName);
335
+ if (!latest || !TERMINAL_STATUSES.has(latest.status)) {
336
+ this.statement('INSERT INTO stage_events(stage_name, status) VALUES (?, ?)').run(stageName, status);
337
+ }
338
+ }
339
+ }
340
+ recordSourceProgress(source, progress) {
341
+ if (!Number.isInteger(progress.processed) || !Number.isInteger(progress.total) || progress.processed < 0 || progress.total < 0 || progress.processed > progress.total) {
342
+ throw new RangeError('source progress requires integer values with 0 <= processed <= total');
343
+ }
344
+ this.statement(`INSERT INTO source_progress(run_id, source, processed, total, status, error)
345
+ VALUES (?, ?, ?, ?, ?, ?)
346
+ ON CONFLICT(run_id, source) DO UPDATE SET
347
+ processed=excluded.processed, total=excluded.total, status=excluded.status, error=excluded.error
348
+ WHERE excluded.processed >= source_progress.processed
349
+ AND excluded.total >= source_progress.total
350
+ AND source_progress.status NOT IN ('success','failed','cancelled','skipped')`)
351
+ .run(this.currentRunId(), source, progress.processed, progress.total, progress.status, redactText(progress.error ?? ''));
352
+ }
353
+ sourceProgress() {
354
+ const rows = this.statement('SELECT source, processed, total, status, error FROM source_progress WHERE run_id = ? ORDER BY rowid')
355
+ .all(this.currentRunId());
356
+ return rows.map((row) => ({ source: String(row.source), processed: Number(row.processed), total: Number(row.total), status: String(row.status), error: String(row.error) }));
357
+ }
358
+ incompleteSourceProgress() {
359
+ return this.sourceProgress().filter((row) => !['success', 'skipped'].includes(row.status));
360
+ }
361
+ recordExtractedNode(source, link) {
362
+ const runId = this.currentRunId();
363
+ const key = canonicalVmessKey(parseVmessLink(link));
364
+ return this.transaction(() => {
365
+ this.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, source, link);
366
+ const existing = this.statement('SELECT sequence FROM pipeline_nodes WHERE run_id = ? AND canonical_key = ?')
367
+ .get(runId, key);
368
+ if (existing)
369
+ return { inserted: false, sequence: existing.sequence };
370
+ const row = this.statement('SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM pipeline_nodes WHERE run_id = ?')
371
+ .get(runId);
372
+ this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence, first_source) VALUES (?, ?, ?, ?, ?)')
373
+ .run(runId, key, link, row.sequence, source);
374
+ return { inserted: true, sequence: row.sequence };
375
+ });
376
+ }
377
+ rawLinks() {
378
+ return this.statement('SELECT link FROM raw_observations WHERE run_id = ? ORDER BY observation_id').all(this.currentRunId()).map((row) => row.link);
379
+ }
380
+ rawLinksForSource(source) {
381
+ return this.statement('SELECT link FROM raw_observations WHERE run_id = ? AND source = ? ORDER BY observation_id')
382
+ .all(this.currentRunId(), source).map((row) => row.link);
383
+ }
384
+ dedupedLinks() {
385
+ return this.statement('SELECT link FROM pipeline_nodes WHERE run_id = ? ORDER BY sequence').all(this.currentRunId()).map((row) => row.link);
386
+ }
387
+ sourceDedupedCounts() {
388
+ const rows = this.statement(`SELECT first_source AS source, COUNT(*) AS count FROM pipeline_nodes
389
+ WHERE run_id = ? AND first_source IS NOT NULL GROUP BY first_source ORDER BY MIN(sequence)`)
390
+ .all(this.currentRunId());
391
+ return Object.fromEntries(rows.map((row) => [row.source, Number(row.count)]));
392
+ }
393
+ hasCompleteSourceOwnership() {
394
+ const row = this.statement(`SELECT NOT EXISTS(
395
+ SELECT 1 FROM pipeline_nodes WHERE run_id = ? AND first_source IS NULL
396
+ ) AS complete`).get(this.currentRunId());
397
+ return Boolean(row.complete);
398
+ }
399
+ sourceRawCounts() {
400
+ const rows = this.statement(`SELECT source, COUNT(*) AS count FROM raw_observations
401
+ WHERE run_id = ? GROUP BY source ORDER BY MIN(observation_id)`)
402
+ .all(this.currentRunId());
403
+ return Object.fromEntries(rows.map((row) => [row.source, Number(row.count)]));
404
+ }
405
+ markSpeedRunning(link) {
406
+ const { runId, key } = this.nodeIdentity(link);
407
+ this.statement(`INSERT INTO speed_results(run_id, canonical_key, status) VALUES (?, ?, 'running')
408
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status='running'
409
+ WHERE speed_results.status NOT IN ('success','failed','speed_passed','speed_failed','cancelled','skipped')`).run(runId, key);
410
+ }
411
+ recordProbe(result) {
412
+ const { runId, key } = this.nodeIdentity(result.link);
413
+ this.statement(`INSERT INTO probe_results(run_id, canonical_key, reachable, latency_ms, error) VALUES (?, ?, ?, ?, ?)
414
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET reachable=excluded.reachable, latency_ms=excluded.latency_ms, error=excluded.error`)
415
+ .run(runId, key, Number(result.reachable), result.latency_ms, redactText(result.error ?? ''));
416
+ }
417
+ recordSpeedResult(result, passed) {
418
+ const { runId, key } = this.nodeIdentity(result.link);
419
+ this.statement(`INSERT INTO speed_results(run_id, canonical_key, status, reachable, average_download_mb_s, latency_ms, error)
420
+ VALUES (?, ?, ?, ?, ?, ?, ?)
421
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status=excluded.status, reachable=excluded.reachable,
422
+ average_download_mb_s=excluded.average_download_mb_s, latency_ms=excluded.latency_ms, error=excluded.error
423
+ WHERE speed_results.status NOT IN ('speed_passed','speed_failed','cancelled','skipped')`)
424
+ .run(runId, key, passed ? 'speed_passed' : 'speed_failed', Number(result.reachable), result.average_download_mb_s, result.latency_ms, redactText(result.error ?? ''));
425
+ }
426
+ probeResults() {
427
+ const rows = this.statement(`SELECT n.link, p.reachable, p.latency_ms, p.error FROM probe_results p
428
+ JOIN pipeline_nodes n ON n.run_id=p.run_id AND n.canonical_key=p.canonical_key
429
+ WHERE p.run_id=? ORDER BY n.sequence`).all(this.currentRunId());
430
+ return rows.map((row) => ({ link: String(row.link), reachable: Boolean(row.reachable), latency_ms: Number(row.latency_ms), error: String(row.error) }));
431
+ }
432
+ speedResults() {
433
+ 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
434
+ JOIN pipeline_nodes n ON n.run_id=s.run_id AND n.canonical_key=s.canonical_key
435
+ WHERE s.run_id=? ORDER BY n.sequence`).all(this.currentRunId());
436
+ 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) }));
437
+ }
438
+ speedLinksNeedingWork() {
439
+ return this.statement(`SELECT n.link FROM pipeline_nodes n
440
+ LEFT JOIN speed_results s ON s.run_id=n.run_id AND s.canonical_key=n.canonical_key
441
+ WHERE n.run_id=? AND (s.status IS NULL OR s.status IN ('pending','running')) ORDER BY n.sequence`)
442
+ .all(this.currentRunId()).map((row) => row.link);
443
+ }
444
+ markAvailabilityRunning(link) {
445
+ const { runId, key } = this.nodeIdentity(link);
446
+ this.statement(`INSERT INTO availability_results(run_id, canonical_key, status) VALUES (?, ?, 'running')
447
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status='running'
448
+ WHERE availability_results.status NOT IN ('success','failed','availability_passed','availability_failed','cancelled','skipped')`).run(runId, key);
449
+ }
450
+ recordAvailabilityResult(result) {
451
+ const { runId, key } = this.nodeIdentity(result.link);
452
+ const error = redactText(result.error ?? '');
453
+ const passed = strictBoolean(result.all_passed) && !error;
454
+ const status = passed ? 'availability_passed' : 'availability_failed';
455
+ this.statement(`INSERT INTO availability_results(run_id, canonical_key, status, all_passed, provider_results, error)
456
+ VALUES (?, ?, ?, ?, ?, ?)
457
+ ON CONFLICT(run_id, canonical_key) DO UPDATE SET status=excluded.status, all_passed=excluded.all_passed,
458
+ provider_results=excluded.provider_results, error=excluded.error
459
+ WHERE availability_results.status NOT IN ('availability_passed','availability_failed','cancelled','skipped')`)
460
+ .run(runId, key, status, Number(passed), JSON.stringify(result.provider_results), error);
461
+ }
462
+ availabilityResults() {
463
+ const rows = this.statement(`SELECT n.link, a.status, a.all_passed, a.provider_results, a.error FROM availability_results a
464
+ JOIN pipeline_nodes n ON n.run_id=a.run_id AND n.canonical_key=a.canonical_key
465
+ WHERE a.run_id=? ORDER BY n.sequence`).all(this.currentRunId());
466
+ 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) }));
467
+ }
468
+ availabilityLinksNeedingWork() {
469
+ return this.statement(`SELECT n.link FROM pipeline_nodes n
470
+ JOIN speed_results s ON s.run_id=n.run_id AND s.canonical_key=n.canonical_key AND s.status='speed_passed'
471
+ LEFT JOIN availability_results a ON a.run_id=n.run_id AND a.canonical_key=n.canonical_key
472
+ WHERE n.run_id=? AND (a.status IS NULL OR a.status IN ('pending','running')) ORDER BY n.sequence`)
473
+ .all(this.currentRunId()).map((row) => row.link);
474
+ }
475
+ counts() {
476
+ const runId = this.currentRunId();
477
+ const count = (table) => Number(this.statement(`SELECT COUNT(*) AS count FROM ${table} WHERE run_id = ?`).get(runId).count);
478
+ return { raw: count('raw_observations'), deduped: count('pipeline_nodes'), probes: count('probe_results'), speed: count('speed_results'), availability: count('availability_results') };
479
+ }
480
+ resetInterruptedRunning() {
481
+ const runId = this.currentRunId();
482
+ return this.transaction(() => {
483
+ 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);
484
+ const speed = links('speed_results');
485
+ const availability = links('availability_results');
486
+ this.statement("UPDATE speed_results SET status='pending' WHERE run_id=? AND status='running'").run(runId);
487
+ this.statement("UPDATE availability_results SET status='pending' WHERE run_id=? AND status='running'").run(runId);
488
+ return { speed, availability };
489
+ });
490
+ }
491
+ classifySpeedResults(minDownloadMbS) {
492
+ this.statement(`UPDATE speed_results SET status = CASE
493
+ WHEN reachable = 1 AND average_download_mb_s >= ? AND error = '' THEN 'speed_passed'
494
+ ELSE 'speed_failed' END
495
+ WHERE run_id = ? AND status IN ('speed_passed','speed_failed')`)
496
+ .run(minDownloadMbS, this.currentRunId());
497
+ }
498
+ importLegacyArtifacts(artifactDir) {
499
+ const runId = this.currentRunId();
500
+ const rawLinks = readLegacyLines(path.join(artifactDir, 'vpn_node_raw.txt'));
501
+ const dedupedLinks = readLegacyLines(path.join(artifactDir, 'vpn_node_deduped.txt'));
502
+ const speedRows = readLegacyReport(path.join(artifactDir, 'vpn_node_speedtest_report.json'));
503
+ const availabilityRows = readLegacyReport(path.join(artifactDir, 'vpn_node_availability_report.json'));
504
+ this.transaction(() => {
505
+ const rawCanonicalKeys = new Set();
506
+ for (const link of rawLinks) {
507
+ rawCanonicalKeys.add(canonicalVmessKey(parseVmessLink(link)));
508
+ this.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, 'legacy', link);
509
+ }
510
+ const nodeLinks = dedupedLinks.length > 0 ? dedupedLinks : rawLinks;
511
+ const seen = new Set();
512
+ for (const link of nodeLinks) {
513
+ const key = canonicalVmessKey(parseVmessLink(link));
514
+ if (seen.has(key))
515
+ continue;
516
+ seen.add(key);
517
+ this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence, first_source) VALUES (?, ?, ?, ?, ?)')
518
+ .run(runId, key, link, seen.size, rawCanonicalKeys.has(key) ? 'legacy' : null);
519
+ }
520
+ for (const row of speedRows) {
521
+ const link = String(row.link ?? '');
522
+ const key = canonicalVmessKey(parseVmessLink(link));
523
+ if (!seen.has(key))
524
+ throw new Error('Legacy speed report references an unknown link');
525
+ const reachable = Boolean(row.reachable);
526
+ this.statement(`INSERT INTO speed_results(run_id, canonical_key, status, reachable, average_download_mb_s, latency_ms, error)
527
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
528
+ .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 ?? '')));
529
+ }
530
+ for (const row of availabilityRows) {
531
+ const link = String(row.link ?? '');
532
+ const key = canonicalVmessKey(parseVmessLink(link));
533
+ if (!seen.has(key))
534
+ throw new Error('Legacy availability report references an unknown link');
535
+ const error = redactText(String(row.error ?? ''));
536
+ const allPassed = strictBoolean(row.all_passed) && !error;
537
+ this.statement(`INSERT INTO availability_results(run_id, canonical_key, status, all_passed, provider_results, error)
538
+ VALUES (?, ?, ?, ?, ?, ?)`)
539
+ .run(runId, key, allPassed ? 'availability_passed' : 'availability_failed', Number(allPassed), JSON.stringify(row.provider_results ?? {}), error);
540
+ }
541
+ });
542
+ }
543
+ nodeIdentity(link) {
544
+ const runId = this.currentRunId();
545
+ const key = canonicalVmessKey(parseVmessLink(link));
546
+ const exists = this.statement('SELECT 1 AS found FROM pipeline_nodes WHERE run_id = ? AND canonical_key = ?').get(runId, key);
547
+ if (!exists)
548
+ throw new Error('unknown pipeline node');
549
+ return { runId, key };
550
+ }
551
+ rawObservations() {
552
+ return this.statement('SELECT source, link FROM raw_observations WHERE run_id = ? ORDER BY observation_id')
553
+ .all(this.currentRunId());
554
+ }
555
+ dedupedNodeOwnership() {
556
+ return this.statement('SELECT link, first_source FROM pipeline_nodes WHERE run_id = ? ORDER BY sequence')
557
+ .all(this.currentRunId());
558
+ }
559
+ migrateSchema() {
560
+ const columns = (table) => new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
561
+ this.db.exec('PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;');
562
+ this.db.exec('BEGIN IMMEDIATE');
563
+ try {
564
+ if (!columns('runs').has('error'))
565
+ this.db.exec("ALTER TABLE runs ADD COLUMN error TEXT NOT NULL DEFAULT ''");
566
+ if (!columns('pipeline_nodes').has('first_source')) {
567
+ this.db.exec('ALTER TABLE pipeline_nodes ADD COLUMN first_source TEXT');
568
+ const earliestSources = new Map();
569
+ const observations = this.db.prepare('SELECT run_id, source, link FROM raw_observations ORDER BY run_id, observation_id')
570
+ .all();
571
+ for (const observation of observations) {
572
+ try {
573
+ const identity = `${observation.run_id}\0${canonicalVmessKey(parseVmessLink(observation.link))}`;
574
+ if (!earliestSources.has(identity))
575
+ earliestSources.set(identity, observation.source);
576
+ }
577
+ catch {
578
+ // Historical malformed observations cannot establish canonical ownership.
579
+ }
580
+ }
581
+ const nodes = this.db.prepare('SELECT node_id, run_id, canonical_key FROM pipeline_nodes ORDER BY run_id, sequence')
582
+ .all();
583
+ const update = this.db.prepare('UPDATE pipeline_nodes SET first_source = ? WHERE node_id = ? AND first_source IS NULL');
584
+ for (const node of nodes) {
585
+ const source = earliestSources.get(`${node.run_id}\0${node.canonical_key}`);
586
+ if (source !== undefined)
587
+ update.run(source, node.node_id);
588
+ }
589
+ }
590
+ const stageColumns = columns('stage_events');
591
+ if (!stageColumns.has('run_id')) {
592
+ this.db.exec('ALTER TABLE stage_events ADD COLUMN run_id INTEGER');
593
+ 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');
594
+ }
595
+ if (!stageColumns.has('error'))
596
+ this.db.exec("ALTER TABLE stage_events ADD COLUMN error TEXT NOT NULL DEFAULT ''");
597
+ const schemaRows = this.db.prepare("SELECT name, sql FROM sqlite_schema WHERE type='table' AND name IN ('runs','stage_events')").all();
598
+ const needsStoppedMigration = schemaRows.some((row) => !row.sql.includes("'stopped'"));
599
+ if (needsStoppedMigration) {
600
+ this.db.exec(`
601
+ ALTER TABLE stage_events RENAME TO stage_events_v1;
602
+ CREATE TABLE stage_events (
603
+ run_id INTEGER NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
604
+ stage_name TEXT NOT NULL,
605
+ status TEXT NOT NULL CHECK (status IN ('pending','running','success','failed','cancelled','skipped','stopped')),
606
+ error TEXT NOT NULL DEFAULT ''
607
+ );
608
+ INSERT INTO stage_events(run_id,stage_name,status,error) SELECT run_id,stage_name,status,error FROM stage_events_v1;
609
+ DROP TABLE stage_events_v1;
610
+ ALTER TABLE runs RENAME TO runs_v1;
611
+ CREATE TABLE runs (
612
+ run_id INTEGER PRIMARY KEY AUTOINCREMENT,
613
+ status TEXT NOT NULL CHECK (status IN ('pending','running','success','failed','cancelled','stopped')),
614
+ error TEXT NOT NULL DEFAULT ''
615
+ );
616
+ INSERT INTO runs(run_id,status,error) SELECT run_id,status,error FROM runs_v1;
617
+ DROP TABLE runs_v1;
618
+ `);
619
+ }
620
+ this.db.exec('PRAGMA user_version=3; COMMIT');
621
+ this.db.exec('PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;');
622
+ }
623
+ catch (error) {
624
+ this.db.exec('ROLLBACK');
625
+ this.db.exec('PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;');
626
+ throw error;
627
+ }
628
+ }
629
+ currentRunId() {
630
+ if (this.runId === undefined)
631
+ throw new Error('RunStore.initializeRun() must be called first');
632
+ return this.runId;
633
+ }
634
+ statement(sql) {
635
+ let statement = this.statements.get(sql);
636
+ if (!statement) {
637
+ statement = this.db.prepare(sql);
638
+ this.statements.set(sql, statement);
639
+ }
640
+ return statement;
641
+ }
642
+ transaction(operation) {
643
+ this.db.exec('BEGIN IMMEDIATE');
644
+ try {
645
+ const result = operation();
646
+ this.db.exec('COMMIT');
647
+ return result;
648
+ }
649
+ catch (error) {
650
+ this.db.exec('ROLLBACK');
651
+ throw error;
652
+ }
653
+ }
654
+ }