@bobfrankston/mailx-store 0.1.78 → 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.
- package/db.d.ts +27 -0
- package/db.js +81 -1
- 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
|