@bobfrankston/mailx-store 0.1.46 → 0.1.48

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 +7 -1
  2. package/db.js +44 -3
  3. package/package.json +1 -1
package/db.d.ts CHANGED
@@ -9,7 +9,13 @@ import type { MessageEnvelope, Folder, EmailAddress, PagedResult, MessageQuery }
9
9
  export declare function setContactsDenyPatterns(patterns: string[]): void;
10
10
  export declare class MailxDB {
11
11
  private db;
12
- constructor(dbDir: string);
12
+ /** True for the DB read-worker's connection. Read-only handles never run
13
+ * DDL / migrations / backfills (the main writer already did, before this
14
+ * handle opened) and reject any write at the SQLite level. */
15
+ readonly readOnly: boolean;
16
+ constructor(dbDir: string, opts?: {
17
+ readOnly?: boolean;
18
+ });
13
19
  /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
14
20
  * runs at startup). The user-facing message names the recovery command. */
15
21
  private verifySchema;
package/db.js CHANGED
@@ -403,10 +403,35 @@ const SCHEMA = `
403
403
  `;
404
404
  export class MailxDB {
405
405
  db;
406
- constructor(dbDir) {
406
+ /** True for the DB read-worker's connection. Read-only handles never run
407
+ * DDL / migrations / backfills (the main writer already did, before this
408
+ * handle opened) and reject any write at the SQLite level. */
409
+ readOnly;
410
+ constructor(dbDir, opts = {}) {
407
411
  fs.mkdirSync(dbDir, { recursive: true });
408
412
  const dbPath = path.join(dbDir, "mailx.db");
413
+ this.readOnly = !!opts.readOnly;
409
414
  this.db = new DatabaseSync(dbPath);
415
+ if (this.readOnly) {
416
+ // Read-worker connection (Phase 0 read isolation). The main-thread
417
+ // MailxDB has ALREADY created the schema and run every migration /
418
+ // backfill before this worker is spawned, so we must NOT touch any
419
+ // of that here — two threads racing writes on one WAL file means
420
+ // SQLITE_BUSY and corruption risk. Strategy:
421
+ // • Do NOT pass {readOnly:true} to DatabaseSync. An OS-read-only
422
+ // handle on a WAL database can't maintain the -shm wal-index
423
+ // and fails to read at all. We open read-WRITE at the OS level
424
+ // so WAL reads work, then forbid writes LOGICALLY below.
425
+ // • PRAGMA query_only = ON makes every statement on this handle
426
+ // read-only — INSERT/UPDATE/DELETE/DDL all throw. That is the
427
+ // guard that lets us reuse the full MailxDB read surface here
428
+ // without any chance of a stray write.
429
+ // WAL mode is a persisted property of the file (the writer set it),
430
+ // so readers see committed snapshots with no journal_mode call.
431
+ this.db.exec("PRAGMA foreign_keys = ON");
432
+ this.db.exec("PRAGMA query_only = ON");
433
+ return;
434
+ }
410
435
  this.db.exec("PRAGMA journal_mode = WAL");
411
436
  this.db.exec("PRAGMA foreign_keys = ON");
412
437
  this.db.exec(SCHEMA);
@@ -1180,6 +1205,7 @@ export class MailxDB {
1180
1205
  accountId: r.account_id,
1181
1206
  folderId: r.folder_id,
1182
1207
  uid: r.uid,
1208
+ uuid: r.uuid || "", // stable identity for row data-uuid / selection restore
1183
1209
  messageId: r.message_id || "",
1184
1210
  inReplyTo: r.in_reply_to || "",
1185
1211
  references: JSON.parse(r.refs || "[]"),
@@ -1803,17 +1829,24 @@ export class MailxDB {
1803
1829
  // The PARTITION BY collapses duplicates: keep the newest per
1804
1830
  // Message-ID. Empty Message-IDs (rare; non-RFC senders) are
1805
1831
  // grouped by row id so each gets its own bucket.
1832
+ //
1833
+ // Dedup is PER INBOX (account_id is in the partition key), not global.
1834
+ // The same Message-ID delivered to two different accounts' inboxes
1835
+ // (e.g. a list that hits both bob.ma and Gmail) is genuinely two
1836
+ // copies the user expects to see in All Inboxes — collapsing across
1837
+ // accounts hid the second one (Bob 2026-06-12). Within a single inbox,
1838
+ // the to-self N-copies case still collapses as before.
1806
1839
  const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM (
1807
1840
  SELECT 1
1808
1841
  FROM messages m
1809
1842
  JOIN message_folders mf ON mf.message_row_id = m.id
1810
1843
  WHERE mf.folder_id IN (${placeholders})${flagFilter}
1811
- GROUP BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
1844
+ GROUP BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
1812
1845
  )`).get(...folderIds).cnt;
1813
1846
  const rows = this.db.prepare(`WITH ranked AS (
1814
1847
  SELECT m.id AS m_id, mf.uid AS mf_uid, mf.folder_id AS mf_folder_id,
1815
1848
  ROW_NUMBER() OVER (
1816
- PARTITION BY CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
1849
+ PARTITION BY m.account_id, CASE WHEN COALESCE(m.message_id, '') = '' THEN 'mid-empty:' || m.id ELSE m.message_id END
1817
1850
  ORDER BY m.date DESC, m.id DESC
1818
1851
  ) AS rn
1819
1852
  FROM messages m
@@ -1866,6 +1899,13 @@ export class MailxDB {
1866
1899
  // Bcc). The unified-inbox UI shows a small ⇆ badge on these
1867
1900
  // rows so the user knows "this is a copy of the same message".
1868
1901
  dupeCount: r.dupeCount | 0,
1902
+ // uuid is the stable local identity each message-list row carries as
1903
+ // data-uuid. Without it, restoreSelection() / rememberPosition() /
1904
+ // the set-diff scroll anchor all match on empty string and fail, so
1905
+ // the selected-row highlight vanishes on every reload (Bob
1906
+ // 2026-06-13: "no highlighting in the summary"). getMessages already
1907
+ // maps uuid; this unified-inbox map dropped it. SELECT m.* exposes it.
1908
+ uuid: r.uuid || "",
1869
1909
  }));
1870
1910
  return { items, total, page, pageSize };
1871
1911
  }
@@ -3125,6 +3165,7 @@ export class MailxDB {
3125
3165
  folderId: r.folder_id,
3126
3166
  folderName: r.folder_name || "",
3127
3167
  uid: r.uid,
3168
+ uuid: r.uuid || "", // stable identity for row data-uuid / selection restore
3128
3169
  messageId: r.message_id || "",
3129
3170
  inReplyTo: r.in_reply_to || "",
3130
3171
  references: JSON.parse(r.refs || "[]"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",