@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.
Files changed (3) hide show
  1. package/db.d.ts +10 -0
  2. package/db.js +50 -0
  3. 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.48",
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.26",
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.26",
33
+ "@bobfrankston/mailx-settings": "^0.1.27",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }