@bobfrankston/mailx-store 0.1.76 → 0.1.80

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 +27 -0
  2. package/db.js +124 -2
  3. package/package.json +1 -1
package/db.d.ts CHANGED
@@ -25,6 +25,33 @@ export declare class MailxDB {
25
25
  * DDL / migrations / backfills (the main writer already did, before this
26
26
  * handle opened) and reject any write at the SQLite level. */
27
27
  readonly readOnly: boolean;
28
+ /** Set when a WRITER connection came back read-only anyway — the OS refused
29
+ * read-write access at open and SQLite silently downgraded the handle (see
30
+ * openWritable). Every write for the life of the process will throw. The
31
+ * daemon reads this right after construction and raises a sticky fatal
32
+ * banner; nothing else should ever swallow it. */
33
+ writeDenied: boolean;
34
+ /** Open the database read-WRITE and VERIFY that we actually got write
35
+ * access.
36
+ *
37
+ * SQLite silently downgrades to a read-only handle when the OS refuses the
38
+ * read-write open (a transient sharing/permission denial — AV or backup
39
+ * scan, a previous instance still exiting, resume-from-sleep). Nothing
40
+ * throws at open: reads all work, and then EVERY write for the life of the
41
+ * process fails with "attempt to write a readonly database". On 2026-08-02
42
+ * that ran an entire boot as a zombie session — is_replied backfill,
43
+ * membership collapse, addAccount for all four accounts, flags (★), sync:
44
+ * all dead, surfaced only as a per-account banner ("outlook: attempt to
45
+ * write a readonly database") that read like an account problem.
46
+ *
47
+ * So: probe with a real write inside a transaction we then ROLL BACK, and
48
+ * reopen with backoff while the verdict is read-only. It has to be an
49
+ * actual page write — under WAL, `BEGIN IMMEDIATE` alone takes its lock in
50
+ * the -shm wal-index and SUCCEEDS on a read-only database (measured), so a
51
+ * lock-only probe reports healthy on exactly the handle we're hunting.
52
+ * SQLITE_BUSY is the opposite verdict — it PROVES the handle is
53
+ * write-capable and merely contended, so keep it. */
54
+ private openWritable;
28
55
  constructor(dbDir: string, opts?: {
29
56
  readOnly?: boolean;
30
57
  skipMigrations?: boolean;
package/db.js CHANGED
@@ -426,17 +426,97 @@ const SCHEMA = `
426
426
  CREATE INDEX IF NOT EXISTS idx_message_folders_msg ON message_folders(message_row_id);
427
427
  CREATE INDEX IF NOT EXISTS idx_message_folders_folder_uid ON message_folders(folder_id, uid);
428
428
  `;
429
+ /** Park the thread for `ms` without spinning. The DB is opened in a
430
+ * constructor (no async available) and the write-access probe below needs to
431
+ * wait out a transient lock; Atomics.wait is the only true sync sleep. */
432
+ function sleepSync(ms) {
433
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
434
+ }
429
435
  export class MailxDB {
430
436
  db;
431
437
  /** True for the DB read-worker's connection. Read-only handles never run
432
438
  * DDL / migrations / backfills (the main writer already did, before this
433
439
  * handle opened) and reject any write at the SQLite level. */
434
440
  readOnly;
441
+ /** Set when a WRITER connection came back read-only anyway — the OS refused
442
+ * read-write access at open and SQLite silently downgraded the handle (see
443
+ * openWritable). Every write for the life of the process will throw. The
444
+ * daemon reads this right after construction and raises a sticky fatal
445
+ * banner; nothing else should ever swallow it. */
446
+ writeDenied = false;
447
+ /** Open the database read-WRITE and VERIFY that we actually got write
448
+ * access.
449
+ *
450
+ * SQLite silently downgrades to a read-only handle when the OS refuses the
451
+ * read-write open (a transient sharing/permission denial — AV or backup
452
+ * scan, a previous instance still exiting, resume-from-sleep). Nothing
453
+ * throws at open: reads all work, and then EVERY write for the life of the
454
+ * process fails with "attempt to write a readonly database". On 2026-08-02
455
+ * that ran an entire boot as a zombie session — is_replied backfill,
456
+ * membership collapse, addAccount for all four accounts, flags (★), sync:
457
+ * all dead, surfaced only as a per-account banner ("outlook: attempt to
458
+ * write a readonly database") that read like an account problem.
459
+ *
460
+ * So: probe with a real write inside a transaction we then ROLL BACK, and
461
+ * reopen with backoff while the verdict is read-only. It has to be an
462
+ * actual page write — under WAL, `BEGIN IMMEDIATE` alone takes its lock in
463
+ * the -shm wal-index and SUCCEEDS on a read-only database (measured), so a
464
+ * lock-only probe reports healthy on exactly the handle we're hunting.
465
+ * SQLITE_BUSY is the opposite verdict — it PROVES the handle is
466
+ * write-capable and merely contended, so keep it. */
467
+ openWritable(dbPath) {
468
+ const backoffMs = [0, 100, 300, 1000, 2000];
469
+ let lastErr = "";
470
+ for (let attempt = 0; attempt < backoffMs.length; attempt++) {
471
+ if (backoffMs[attempt])
472
+ sleepSync(backoffMs[attempt]);
473
+ const db = new DatabaseSync(dbPath);
474
+ try {
475
+ db.exec("BEGIN IMMEDIATE");
476
+ db.exec("CREATE TABLE _mailx_write_probe(x)");
477
+ db.exec("ROLLBACK"); // probe table never reaches the file
478
+ if (attempt > 0)
479
+ console.log(` [db] write access acquired on attempt ${attempt + 1}`);
480
+ return db;
481
+ }
482
+ catch (e) {
483
+ lastErr = e?.message || String(e);
484
+ if (!/readonly/i.test(lastErr)) {
485
+ // Busy/locked — another writer holds the lock this instant.
486
+ // That is a write-capable handle; take it.
487
+ try {
488
+ db.exec("ROLLBACK");
489
+ }
490
+ catch { /* no txn was opened */ }
491
+ return db;
492
+ }
493
+ console.error(` [db] opened READ-ONLY (attempt ${attempt + 1}/${backoffMs.length}): ${lastErr}`);
494
+ db.close();
495
+ }
496
+ }
497
+ // Out of retries. Return a handle anyway — reading mail beats no app at
498
+ // all — but flag it so the daemon can say so, loudly and stickily.
499
+ console.error(` [db] FATAL: ${dbPath} is READ-ONLY after ${backoffMs.length} attempts — no change will be saved (${lastErr})`);
500
+ this.writeDenied = true;
501
+ return new DatabaseSync(dbPath);
502
+ }
435
503
  constructor(dbDir, opts = {}) {
436
504
  fs.mkdirSync(dbDir, { recursive: true });
437
505
  const dbPath = path.join(dbDir, "mailx.db");
438
506
  this.readOnly = !!opts.readOnly;
439
- this.db = new DatabaseSync(dbPath);
507
+ this.db = this.readOnly ? new DatabaseSync(dbPath) : this.openWritable(dbPath);
508
+ if (this.writeDenied) {
509
+ // Asked for a writer, got a read-only handle (openWritable already
510
+ // shouted about it). Everything below — journal_mode, the schema,
511
+ // every migration and backfill — is a write, so running it would
512
+ // throw or bury the real message under a dozen derived errors.
513
+ // Set up as a reader and let the daemon surface the banner: the
514
+ // user can still READ mail while the app says, once and plainly,
515
+ // that nothing is being saved.
516
+ this.db.exec("PRAGMA foreign_keys = ON");
517
+ this.db.exec("PRAGMA busy_timeout = 5000");
518
+ return;
519
+ }
440
520
  if (opts.skipMigrations && !opts.readOnly) {
441
521
  // Write-capable connection that does NOT run schema/migrations —
442
522
  // used by the sync worker, which opens its own writer AFTER the
@@ -3704,8 +3784,26 @@ export class MailxDB {
3704
3784
  const LIKE_CC = ["m.cc_json"];
3705
3785
  const LIKE_SUBJ = ["m.subject"];
3706
3786
  const LIKE_ANY = ["m.subject", "m.from_name", "m.from_address"];
3787
+ // `NOT <item>` — exclusion, usable ANYWHERE including as the leading
3788
+ // (or only) token. FTS5's own NOT is strictly binary (`a NOT b`), so
3789
+ // a leading NOT was an FTS5 syntax error that the dangling-operator
3790
+ // trim "fixed" by dropping the NOT — silently searching FOR the term
3791
+ // the user asked to exclude (Bob 2026-07-30). Instead of leaning on
3792
+ // FTS5's operator, every `NOT x` pair is lifted OUT of the MATCH and
3793
+ // applied as a SQL-side exclusion (`m.id NOT IN (SELECT rowid …
3794
+ // MATCH x)`), which also negates qualifier tokens (`NOT from:…`,
3795
+ // `NOT is:read`) uniformly. Uppercase only, same as AND/OR — a
3796
+ // lowercase "not" stays an ordinary search term.
3797
+ const notFrags = [];
3798
+ let negateNext = false;
3707
3799
  for (let pi = 0; pi < parts.length; pi++) {
3708
3800
  const part = parts[pi];
3801
+ if (part === "NOT") {
3802
+ negateNext = true;
3803
+ continue;
3804
+ }
3805
+ const wPre = extraWhere.length;
3806
+ const fPre = frags.length;
3709
3807
  // A 1-2 char term next to a user-typed OR must be DROPPED, not
3710
3808
  // LIKE'd: LIKE clauses AND against the MATCH, so "ab OR sprinkler"
3711
3809
  // would intersect down to zero. Over-matching (just "sprinkler")
@@ -3826,12 +3924,14 @@ export class MailxDB {
3826
3924
  extraWhere.push("LOWER(f.name) LIKE ?");
3827
3925
  extraParams.push(`%${v.toLowerCase()}%`);
3828
3926
  }
3829
- else if (/^(AND|OR|NOT)$/.test(part)) {
3927
+ else if (/^(AND|OR)$/.test(part)) {
3830
3928
  // FTS5 boolean operators — pass through verbatim (must be uppercase).
3831
3929
  // Without this branch, `hoddie AND git` got wildcarded into
3832
3930
  // `hoddie* AND* git*`, which FTS5 reads as three required terms
3833
3931
  // (one of them being any word starting with "AND") — so a real
3834
3932
  // match like "Peter Hoddie" + "github" returned zero hits.
3933
+ // (NOT is handled above as an exclusion prefix, not passed
3934
+ // through — FTS5's binary-only NOT can't stand at the front.)
3835
3935
  frags.push({ s: part, op: true });
3836
3936
  }
3837
3937
  else if (isTri) {
@@ -3893,6 +3993,20 @@ export class MailxDB {
3893
3993
  }
3894
3994
  }
3895
3995
  }
3996
+ // Divert whatever this token contributed into the exclusion set.
3997
+ // FTS frags move to notFrags (applied as a NOT IN subquery below);
3998
+ // SQL-side qualifier clauses get wrapped in NOT(...) in place. An
3999
+ // AND/OR right after NOT is nonsense input — the op frag stands
4000
+ // and the stray NOT is dropped.
4001
+ if (negateNext) {
4002
+ if (!/^(AND|OR)$/.test(part)) {
4003
+ for (let w = wPre; w < extraWhere.length; w++)
4004
+ extraWhere[w] = `NOT (${extraWhere[w]})`;
4005
+ while (frags.length > fPre)
4006
+ notFrags.push(frags.pop().s);
4007
+ }
4008
+ negateNext = false;
4009
+ }
3896
4010
  }
3897
4011
  // A term diverted to the LIKE fallback can strand a user-typed
3898
4012
  // operator at the edge of the MATCH string ("ab OR sprinkler" → the
@@ -3902,6 +4016,14 @@ export class MailxDB {
3902
4016
  frags.shift();
3903
4017
  while (frags.length && frags[frags.length - 1].op)
3904
4018
  frags.pop();
4019
+ // NOT'd terms exclude via a subquery on the same FTS index: a message
4020
+ // matching ANY excluded term is out. Works whether or not there are
4021
+ // positive FTS terms — a pure `NOT foo` query takes the qualifier-only
4022
+ // path below and returns everything except matches.
4023
+ if (notFrags.length > 0) {
4024
+ extraWhere.push("m.id NOT IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?)");
4025
+ extraParams.push(notFrags.join(" OR "));
4026
+ }
3905
4027
  // Join fragments. Two adjacent value fragments need an explicit `AND`
3906
4028
  // (implicit-AND after a `(...)` / `{...}:` group is an FTS5 syntax
3907
4029
  // error); a user operator fragment glues itself, so no insert around it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.76",
3
+ "version": "0.1.80",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",