@bobfrankston/mailx-store 0.1.49 → 0.1.51

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 +20 -0
  2. package/db.js +73 -1
  3. package/package.json +3 -3
package/db.d.ts CHANGED
@@ -400,6 +400,15 @@ export declare class MailxDB {
400
400
  } | null;
401
401
  /** Clear a UID's prefetch-failure record after a successful fetch. */
402
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;
403
412
  /** Highest server UID we've seen in this folder. After the
404
413
  * message_folders refactor, this reads from the membership table
405
414
  * rather than messages.uid — a server-side move from this folder
@@ -470,6 +479,17 @@ export declare class MailxDB {
470
479
  * already open this just runs `fn` (its writes join the open txn);
471
480
  * otherwise it owns a fresh BEGIN/COMMIT. `fn` MUST be synchronous so it
472
481
  * can't span an await and leave a transaction open across a yield. */
482
+ /** Run a WAL checkpoint to bound the WAL file size. PASSIVE never blocks
483
+ * on readers — it reclaims frames older than the oldest live snapshot and
484
+ * returns immediately otherwise. Drive it from ONE connection (main) on a
485
+ * timer so the WAL can't balloon while three connections share the file.
486
+ * Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
487
+ * frames moved into the db) or null on error. */
488
+ checkpoint(mode?: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE"): {
489
+ busy: number;
490
+ log: number;
491
+ checkpointed: number;
492
+ } | null;
473
493
  runInTxn<T>(fn: () => T): T;
474
494
  /** Record an address used in sent mail */
475
495
  recordSentAddress(name: string, email: string): void;
package/db.js CHANGED
@@ -419,7 +419,13 @@ export class MailxDB {
419
419
  // DDL/backfills concurrently would race on the WAL write lock.
420
420
  this.db.exec("PRAGMA journal_mode = WAL");
421
421
  this.db.exec("PRAGMA foreign_keys = ON");
422
- this.db.exec("PRAGMA busy_timeout = 5000");
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");
423
429
  return;
424
430
  }
425
431
  if (this.readOnly) {
@@ -446,6 +452,23 @@ export class MailxDB {
446
452
  }
447
453
  this.db.exec("PRAGMA journal_mode = WAL");
448
454
  this.db.exec("PRAGMA foreign_keys = ON");
455
+ // WAL hygiene. With THREE connections (main writer, sync-worker writer,
456
+ // read-worker reader) the WAL was never getting checkpointed — failed
457
+ // writes (two-writer contention) never commit, so the auto-checkpoint
458
+ // that normally fires on commit never ran, and the WAL ballooned to
459
+ // 71 MB (Bob 2026-06-14: every read/write crawled, `database is locked`
460
+ // everywhere, a bulk move took 51s and timed out). A bloated WAL is
461
+ // self-reinforcing: bigger WAL → slower writes → more lock failures →
462
+ // fewer commits → no checkpoint. Boot is the one moment main is the
463
+ // SOLE connection (read/sync workers spawn later), so TRUNCATE here
464
+ // resets the file to empty; `checkpoint()` on a timer keeps it small.
465
+ try {
466
+ const r = this.db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
467
+ console.log(` [db] boot WAL checkpoint: busy=${r?.busy} log=${r?.log} checkpointed=${r?.checkpointed}`);
468
+ }
469
+ catch (e) {
470
+ console.error(` [db] boot WAL checkpoint failed: ${e?.message || e}`);
471
+ }
449
472
  // Multi-writer foundation for the sync-worker migration: once sync runs
450
473
  // on its own thread with its own write connection, it and the main
451
474
  // thread are two writers on one WAL file. WAL serializes writers, so a
@@ -569,6 +592,15 @@ export class MailxDB {
569
592
  // this column landed. One UPDATE + an id roundtrip per row — cheap
570
593
  // at our row counts, runs once per DB upgrade.
571
594
  this.backfillUuids();
595
+ // Per-boot: clear prefetch-failure backoffs so transiently-failed bodies
596
+ // (two-writer DB lock, Dovecot timeout) get a fresh try this session
597
+ // instead of staying suppressed for up to 12h. See clearAllPrefetchFailures.
598
+ try {
599
+ const cleared = this.clearAllPrefetchFailures();
600
+ if (cleared > 0)
601
+ console.log(` [db] cleared ${cleared} prefetch-failure backoff(s) for a fresh session`);
602
+ }
603
+ catch { /* non-fatal */ }
572
604
  // One-shot cleanup: the retired insertOptimisticSentRow path wrote
573
605
  // synthetic-negative-UID rows into Sent. Those rows are stale (the
574
606
  // real server-synced row eventually appears with a positive UID),
@@ -2235,6 +2267,22 @@ export class MailxDB {
2235
2267
  clearPrefetchFailure(accountId, folderId, uid) {
2236
2268
  this.db.prepare("DELETE FROM prefetch_failures WHERE account_id = ? AND folder_id = ? AND uid = ?").run(accountId, folderId, uid);
2237
2269
  }
2270
+ /** Wipe ALL prefetch-failure backoffs. Called once per boot: the backoff is
2271
+ * a within-SESSION optimisation (it stops a poison body being re-fetched
2272
+ * every 60s cycle), so a fresh session should give every body another try —
2273
+ * the network may have recovered, and crucially a body backed off by a
2274
+ * transient cause (a two-writer DB lock, a Dovecot 300s timeout) shouldn't
2275
+ * stay suppressed for up to 12h (Bob 2026-06-14: "lots of predownloading
2276
+ * not happening"). Genuine poison bodies simply re-fail once and re-back-off
2277
+ * for the session — no per-cycle loop. */
2278
+ clearAllPrefetchFailures() {
2279
+ try {
2280
+ return this.db.prepare("DELETE FROM prefetch_failures").run().changes;
2281
+ }
2282
+ catch {
2283
+ return 0;
2284
+ }
2285
+ }
2238
2286
  /** Highest server UID we've seen in this folder. After the
2239
2287
  * message_folders refactor, this reads from the membership table
2240
2288
  * rather than messages.uid — a server-side move from this folder
@@ -2363,13 +2411,37 @@ export class MailxDB {
2363
2411
  * already open this just runs `fn` (its writes join the open txn);
2364
2412
  * otherwise it owns a fresh BEGIN/COMMIT. `fn` MUST be synchronous so it
2365
2413
  * can't span an await and leave a transaction open across a yield. */
2414
+ /** Run a WAL checkpoint to bound the WAL file size. PASSIVE never blocks
2415
+ * on readers — it reclaims frames older than the oldest live snapshot and
2416
+ * returns immediately otherwise. Drive it from ONE connection (main) on a
2417
+ * timer so the WAL can't balloon while three connections share the file.
2418
+ * Returns {busy, log, checkpointed} (log = frames in WAL, checkpointed =
2419
+ * frames moved into the db) or null on error. */
2420
+ checkpoint(mode = "PASSIVE") {
2421
+ try {
2422
+ return this.db.prepare(`PRAGMA wal_checkpoint(${mode})`).get();
2423
+ }
2424
+ catch {
2425
+ return null;
2426
+ }
2427
+ }
2366
2428
  runInTxn(fn) {
2367
2429
  if (this.db.isTransaction)
2368
2430
  return fn();
2369
2431
  this.db.exec("BEGIN");
2432
+ const _t0 = Date.now();
2370
2433
  try {
2371
2434
  const r = fn();
2372
2435
  this.db.exec("COMMIT");
2436
+ // Diagnostic: a write transaction held >1s on the MAIN connection is
2437
+ // what locks out the sync-worker's prefetch write (Bob 2026-06-14
2438
+ // "find the long main-thread transaction"). Log the call site so the
2439
+ // next occurrence names itself instead of needing code archaeology.
2440
+ const _ms = Date.now() - _t0;
2441
+ if (_ms > 1000) {
2442
+ const where = (new Error().stack || "").split("\n")[2]?.trim() || "?";
2443
+ console.warn(` [db] SLOW write txn ${_ms}ms (readOnly=${this.readOnly}) at ${where}`);
2444
+ }
2373
2445
  return r;
2374
2446
  }
2375
2447
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
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.28",
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.28",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }