@bobfrankston/mailx-store 0.1.47 → 0.1.49

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 +8 -1
  2. package/db.js +54 -1
  3. package/package.json +1 -1
package/db.d.ts CHANGED
@@ -9,7 +9,14 @@ 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
+ skipMigrations?: boolean;
19
+ });
13
20
  /** Fail loud + early if expected columns are missing. Cheap (PRAGMA only
14
21
  * runs at startup). The user-facing message names the recovery command. */
15
22
  private verifySchema;
package/db.js CHANGED
@@ -403,12 +403,56 @@ 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 (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
+ this.db.exec("PRAGMA busy_timeout = 5000");
423
+ return;
424
+ }
425
+ if (this.readOnly) {
426
+ // Read-worker connection (Phase 0 read isolation). The main-thread
427
+ // MailxDB has ALREADY created the schema and run every migration /
428
+ // backfill before this worker is spawned, so we must NOT touch any
429
+ // of that here — two threads racing writes on one WAL file means
430
+ // SQLITE_BUSY and corruption risk. Strategy:
431
+ // • Do NOT pass {readOnly:true} to DatabaseSync. An OS-read-only
432
+ // handle on a WAL database can't maintain the -shm wal-index
433
+ // and fails to read at all. We open read-WRITE at the OS level
434
+ // so WAL reads work, then forbid writes LOGICALLY below.
435
+ // • PRAGMA query_only = ON makes every statement on this handle
436
+ // read-only — INSERT/UPDATE/DELETE/DDL all throw. That is the
437
+ // guard that lets us reuse the full MailxDB read surface here
438
+ // without any chance of a stray write.
439
+ // WAL mode is a persisted property of the file (the writer set it),
440
+ // so readers see committed snapshots with no journal_mode call.
441
+ this.db.exec("PRAGMA foreign_keys = ON");
442
+ this.db.exec("PRAGMA query_only = ON");
443
+ // A reader can briefly collide with a WAL checkpoint; wait, don't throw.
444
+ this.db.exec("PRAGMA busy_timeout = 5000");
445
+ return;
446
+ }
410
447
  this.db.exec("PRAGMA journal_mode = WAL");
411
448
  this.db.exec("PRAGMA foreign_keys = ON");
449
+ // Multi-writer foundation for the sync-worker migration: once sync runs
450
+ // on its own thread with its own write connection, it and the main
451
+ // thread are two writers on one WAL file. WAL serializes writers, so a
452
+ // collision returns SQLITE_BUSY *immediately* by default — busy_timeout
453
+ // makes the loser WAIT for the lock (up to 5s) instead of failing. Safe
454
+ // and inert today (single writer); required before the worker lands.
455
+ this.db.exec("PRAGMA busy_timeout = 5000");
412
456
  this.db.exec(SCHEMA);
413
457
  this.migrateFtsSchema();
414
458
  // Purge phantom uid=0 rows (invalid IMAP UID — empty stub letters, the
@@ -1180,6 +1224,7 @@ export class MailxDB {
1180
1224
  accountId: r.account_id,
1181
1225
  folderId: r.folder_id,
1182
1226
  uid: r.uid,
1227
+ uuid: r.uuid || "", // stable identity for row data-uuid / selection restore
1183
1228
  messageId: r.message_id || "",
1184
1229
  inReplyTo: r.in_reply_to || "",
1185
1230
  references: JSON.parse(r.refs || "[]"),
@@ -1873,6 +1918,13 @@ export class MailxDB {
1873
1918
  // Bcc). The unified-inbox UI shows a small ⇆ badge on these
1874
1919
  // rows so the user knows "this is a copy of the same message".
1875
1920
  dupeCount: r.dupeCount | 0,
1921
+ // uuid is the stable local identity each message-list row carries as
1922
+ // data-uuid. Without it, restoreSelection() / rememberPosition() /
1923
+ // the set-diff scroll anchor all match on empty string and fail, so
1924
+ // the selected-row highlight vanishes on every reload (Bob
1925
+ // 2026-06-13: "no highlighting in the summary"). getMessages already
1926
+ // maps uuid; this unified-inbox map dropped it. SELECT m.* exposes it.
1927
+ uuid: r.uuid || "",
1876
1928
  }));
1877
1929
  return { items, total, page, pageSize };
1878
1930
  }
@@ -3132,6 +3184,7 @@ export class MailxDB {
3132
3184
  folderId: r.folder_id,
3133
3185
  folderName: r.folder_name || "",
3134
3186
  uid: r.uid,
3187
+ uuid: r.uuid || "", // stable identity for row data-uuid / selection restore
3135
3188
  messageId: r.message_id || "",
3136
3189
  inReplyTo: r.in_reply_to || "",
3137
3190
  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.47",
3
+ "version": "0.1.49",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",