@bobfrankston/mailx-store 0.1.48 → 0.1.50
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/db.d.ts +10 -0
- package/db.js +50 -0
- package/package.json +3 -3
package/db.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare class MailxDB {
|
|
|
15
15
|
readonly readOnly: boolean;
|
|
16
16
|
constructor(dbDir: string, opts?: {
|
|
17
17
|
readOnly?: boolean;
|
|
18
|
+
skipMigrations?: boolean;
|
|
18
19
|
});
|
|
19
20
|
/** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
|
|
20
21
|
* runs at startup). The user-facing message names the recovery command. */
|
|
@@ -399,6 +400,15 @@ export declare class MailxDB {
|
|
|
399
400
|
} | null;
|
|
400
401
|
/** Clear a UID's prefetch-failure record after a successful fetch. */
|
|
401
402
|
clearPrefetchFailure(accountId: string, folderId: number, uid: number): void;
|
|
403
|
+
/** Wipe ALL prefetch-failure backoffs. Called once per boot: the backoff is
|
|
404
|
+
* a within-SESSION optimisation (it stops a poison body being re-fetched
|
|
405
|
+
* every 60s cycle), so a fresh session should give every body another try —
|
|
406
|
+
* the network may have recovered, and crucially a body backed off by a
|
|
407
|
+
* transient cause (a two-writer DB lock, a Dovecot 300s timeout) shouldn't
|
|
408
|
+
* stay suppressed for up to 12h (Bob 2026-06-14: "lots of predownloading
|
|
409
|
+
* not happening"). Genuine poison bodies simply re-fail once and re-back-off
|
|
410
|
+
* for the session — no per-cycle loop. */
|
|
411
|
+
clearAllPrefetchFailures(): number;
|
|
402
412
|
/** Highest server UID we've seen in this folder. After the
|
|
403
413
|
* message_folders refactor, this reads from the membership table
|
|
404
414
|
* rather than messages.uid — a server-side move from this folder
|
package/db.js
CHANGED
|
@@ -412,6 +412,22 @@ export class MailxDB {
|
|
|
412
412
|
const dbPath = path.join(dbDir, "mailx.db");
|
|
413
413
|
this.readOnly = !!opts.readOnly;
|
|
414
414
|
this.db = new DatabaseSync(dbPath);
|
|
415
|
+
if (opts.skipMigrations && !opts.readOnly) {
|
|
416
|
+
// Write-capable connection that does NOT run schema/migrations —
|
|
417
|
+
// used by the sync worker, which opens its own writer AFTER the
|
|
418
|
+
// main thread has already built the schema. Two threads running the
|
|
419
|
+
// DDL/backfills concurrently would race on the WAL write lock.
|
|
420
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
421
|
+
this.db.exec("PRAGMA foreign_keys = ON");
|
|
422
|
+
// 10s (vs the main thread's 5s): this is the BACKGROUND writer (sync,
|
|
423
|
+
// prefetch); waiting a bit longer for the foreground's lock is fine.
|
|
424
|
+
// NOT higher because busy_timeout BLOCKS this thread synchronously
|
|
425
|
+
// (it can't forward sync events while parked) — for longer holds the
|
|
426
|
+
// prefetch write path uses an ASYNC retry that yields between
|
|
427
|
+
// attempts instead. Fixes prefetch "database is locked" (Bob 2026-06-14).
|
|
428
|
+
this.db.exec("PRAGMA busy_timeout = 10000");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
415
431
|
if (this.readOnly) {
|
|
416
432
|
// Read-worker connection (Phase 0 read isolation). The main-thread
|
|
417
433
|
// MailxDB has ALREADY created the schema and run every migration /
|
|
@@ -430,10 +446,19 @@ export class MailxDB {
|
|
|
430
446
|
// so readers see committed snapshots with no journal_mode call.
|
|
431
447
|
this.db.exec("PRAGMA foreign_keys = ON");
|
|
432
448
|
this.db.exec("PRAGMA query_only = ON");
|
|
449
|
+
// A reader can briefly collide with a WAL checkpoint; wait, don't throw.
|
|
450
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
433
451
|
return;
|
|
434
452
|
}
|
|
435
453
|
this.db.exec("PRAGMA journal_mode = WAL");
|
|
436
454
|
this.db.exec("PRAGMA foreign_keys = ON");
|
|
455
|
+
// Multi-writer foundation for the sync-worker migration: once sync runs
|
|
456
|
+
// on its own thread with its own write connection, it and the main
|
|
457
|
+
// thread are two writers on one WAL file. WAL serializes writers, so a
|
|
458
|
+
// collision returns SQLITE_BUSY *immediately* by default — busy_timeout
|
|
459
|
+
// makes the loser WAIT for the lock (up to 5s) instead of failing. Safe
|
|
460
|
+
// and inert today (single writer); required before the worker lands.
|
|
461
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
437
462
|
this.db.exec(SCHEMA);
|
|
438
463
|
this.migrateFtsSchema();
|
|
439
464
|
// Purge phantom uid=0 rows (invalid IMAP UID — empty stub letters, the
|
|
@@ -550,6 +575,15 @@ export class MailxDB {
|
|
|
550
575
|
// this column landed. One UPDATE + an id roundtrip per row — cheap
|
|
551
576
|
// at our row counts, runs once per DB upgrade.
|
|
552
577
|
this.backfillUuids();
|
|
578
|
+
// Per-boot: clear prefetch-failure backoffs so transiently-failed bodies
|
|
579
|
+
// (two-writer DB lock, Dovecot timeout) get a fresh try this session
|
|
580
|
+
// instead of staying suppressed for up to 12h. See clearAllPrefetchFailures.
|
|
581
|
+
try {
|
|
582
|
+
const cleared = this.clearAllPrefetchFailures();
|
|
583
|
+
if (cleared > 0)
|
|
584
|
+
console.log(` [db] cleared ${cleared} prefetch-failure backoff(s) for a fresh session`);
|
|
585
|
+
}
|
|
586
|
+
catch { /* non-fatal */ }
|
|
553
587
|
// One-shot cleanup: the retired insertOptimisticSentRow path wrote
|
|
554
588
|
// synthetic-negative-UID rows into Sent. Those rows are stale (the
|
|
555
589
|
// real server-synced row eventually appears with a positive UID),
|
|
@@ -2216,6 +2250,22 @@ export class MailxDB {
|
|
|
2216
2250
|
clearPrefetchFailure(accountId, folderId, uid) {
|
|
2217
2251
|
this.db.prepare("DELETE FROM prefetch_failures WHERE account_id = ? AND folder_id = ? AND uid = ?").run(accountId, folderId, uid);
|
|
2218
2252
|
}
|
|
2253
|
+
/** Wipe ALL prefetch-failure backoffs. Called once per boot: the backoff is
|
|
2254
|
+
* a within-SESSION optimisation (it stops a poison body being re-fetched
|
|
2255
|
+
* every 60s cycle), so a fresh session should give every body another try —
|
|
2256
|
+
* the network may have recovered, and crucially a body backed off by a
|
|
2257
|
+
* transient cause (a two-writer DB lock, a Dovecot 300s timeout) shouldn't
|
|
2258
|
+
* stay suppressed for up to 12h (Bob 2026-06-14: "lots of predownloading
|
|
2259
|
+
* not happening"). Genuine poison bodies simply re-fail once and re-back-off
|
|
2260
|
+
* for the session — no per-cycle loop. */
|
|
2261
|
+
clearAllPrefetchFailures() {
|
|
2262
|
+
try {
|
|
2263
|
+
return this.db.prepare("DELETE FROM prefetch_failures").run().changes;
|
|
2264
|
+
}
|
|
2265
|
+
catch {
|
|
2266
|
+
return 0;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2219
2269
|
/** Highest server UID we've seen in this folder. After the
|
|
2220
2270
|
* message_folders refactor, this reads from the membership table
|
|
2221
2271
|
* rather than messages.uid — a server-side move from this folder
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-store",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.50",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/mailx-types": "^0.1.19",
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
13
|
+
"@bobfrankston/mailx-settings": "^0.1.27",
|
|
14
14
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
15
15
|
"mailparser": "^3.7.2"
|
|
16
16
|
},
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
".transformedSnapshot": {
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@bobfrankston/mailx-types": "^0.1.19",
|
|
33
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
33
|
+
"@bobfrankston/mailx-settings": "^0.1.27",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
|
36
36
|
}
|