@bobfrankston/mailx-store 0.1.135 → 0.1.138

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 (2) hide show
  1. package/db.js +81 -34
  2. package/package.json +3 -3
package/db.js CHANGED
@@ -618,23 +618,34 @@ export class MailxDB {
618
618
  // Purge phantom uid=0 rows (invalid IMAP UID — empty stub letters, the
619
619
  // upsertMessage guard now prevents new ones; this clears any already on
620
620
  // disk, e.g. Bob's 00:03 INBOX phantom). Cascade to FTS + membership.
621
- try {
622
- const ph = this.db.prepare("SELECT id FROM messages WHERE uid = 0").all();
623
- if (ph.length) {
624
- for (const r of ph) {
625
- try {
626
- this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(r.id);
621
+ //
622
+ // 2026-09-30 — Claude Code (Opus 5.5): this and the other heals below
623
+ // that end in "idempotent" ran on EVERY start and found nothing — on
624
+ // Bob's 215k-message store the five passes cost ~1.8 s before the
625
+ // window could open (measured read-only: trim ids 786 ms, is_replied
626
+ // 546 ms, membership collapse 353 ms, this purge 80 ms). Bob: "took a
627
+ // long time to load". The cause of each is fixed at the write path, so
628
+ // each now runs once and records that in kv, like the migrations
629
+ // further down. is_replied is scoped instead (see there).
630
+ if (!this.getKv("schema", "purge_uid0_v1"))
631
+ try {
632
+ const ph = this.db.prepare("SELECT id FROM messages WHERE uid = 0").all();
633
+ if (ph.length) {
634
+ for (const r of ph) {
635
+ try {
636
+ this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?").run(r.id);
637
+ }
638
+ catch { /* */ }
639
+ this.db.prepare("DELETE FROM message_folders WHERE message_row_id = ?").run(r.id);
627
640
  }
628
- catch { /* */ }
629
- this.db.prepare("DELETE FROM message_folders WHERE message_row_id = ?").run(r.id);
641
+ this.db.prepare("DELETE FROM messages WHERE uid = 0").run();
642
+ console.log(` [db] purged ${ph.length} phantom uid=0 row(s)`);
630
643
  }
631
- this.db.prepare("DELETE FROM messages WHERE uid = 0").run();
632
- console.log(` [db] purged ${ph.length} phantom uid=0 row(s)`);
644
+ this.setKv("schema", "purge_uid0_v1", "1");
645
+ }
646
+ catch (e) {
647
+ console.error(` [db] phantom purge failed: ${e?.message || e}`);
633
648
  }
634
- }
635
- catch (e) {
636
- console.error(` [db] phantom purge failed: ${e?.message || e}`);
637
- }
638
649
  // Idempotent migrations for older databases that predate new columns.
639
650
  // SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so we just try the
640
651
  // ALTER and catch the "duplicate column" error. Simpler and more robust
@@ -705,12 +716,17 @@ export class MailxDB {
705
716
  // "missing" messages forever (3,707 duplicates of one reply,
706
717
  // 2026-07-02). Idempotent — after the first pass the WHERE matches
707
718
  // nothing. char(9/10/13)+space covers all RFC 5322 fold whitespace.
708
- try {
709
- const ws = "char(9)||char(10)||char(13)||' '";
710
- this.db.exec(`UPDATE messages SET message_id = TRIM(message_id, ${ws}) WHERE message_id <> TRIM(message_id, ${ws})`);
711
- this.db.exec(`UPDATE messages SET in_reply_to = TRIM(in_reply_to, ${ws}) WHERE in_reply_to IS NOT NULL AND in_reply_to <> TRIM(in_reply_to, ${ws})`);
712
- }
713
- catch { /* non-fatal */ }
719
+ // Once (kv, 2026-09-30): the envelope parser trims since 2026-07-02.
720
+ if (!this.getKv("schema", "trim_folded_ids_v1"))
721
+ try {
722
+ const ws = "char(9)||char(10)||char(13)||' '";
723
+ this.db.exec(`UPDATE messages SET message_id = TRIM(message_id, ${ws}) WHERE message_id <> TRIM(message_id, ${ws})`);
724
+ this.db.exec(`UPDATE messages SET in_reply_to = TRIM(in_reply_to, ${ws}) WHERE in_reply_to IS NOT NULL AND in_reply_to <> TRIM(in_reply_to, ${ws})`);
725
+ this.setKv("schema", "trim_folded_ids_v1", "1");
726
+ }
727
+ catch (e) {
728
+ console.error(` [db] Message-ID trim heal failed (retried next start): ${e?.message || e}`);
729
+ }
714
730
  try {
715
731
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_folder_sentdate ON messages(account_id, folder_id, sent_date DESC)");
716
732
  }
@@ -731,19 +747,46 @@ export class MailxDB {
731
747
  // so a 100k-row mailbox finishes in milliseconds. Idempotent — after
732
748
  // the first run the WHERE-is_replied=0 filter prunes work down to any
733
749
  // new linkages that arrived during the previous session.
750
+ //
751
+ // 2026-09-30 — Claude Code (Opus 5.5): scoped to linkages that can be
752
+ // NEW since the last pass. It used to test every one of 215k rows each
753
+ // start (546 ms, zero changes). A new linkage needs a new row on one
754
+ // side: a reply that arrived (its parent may be old) or a parent that
755
+ // arrived after its reply. Both sides are covered from `cached_at`;
756
+ // the first run after this change (no kv yet) starts from 0, i.e. the
757
+ // old full pass. The stamp is taken BEFORE the pass so rows written
758
+ // while it runs are looked at again next time.
734
759
  try {
735
- const r = this.db.prepare(`
760
+ const since = Number(this.getKv("schema", "is_replied_backfill_at") || 0);
761
+ const stamp = Date.now();
762
+ // A new reply marks its (possibly old) parent. CROSS JOIN pins the
763
+ // replies (c) as the outer loop, each seeking its parent through
764
+ // idx_messages_acct_msgid: 176 ms on Bob's store. A plain JOIN let
765
+ // the planner scan every parent instead — 2,344 ms, four times the
766
+ // pass it replaced (measured read-only 2026-09-30).
767
+ const byReply = this.db.prepare(`
768
+ UPDATE messages SET is_replied = 1 WHERE id IN (
769
+ SELECT p.id FROM messages c
770
+ CROSS JOIN messages p ON p.account_id = c.account_id AND p.message_id = c.in_reply_to
771
+ WHERE c.cached_at >= ? AND c.in_reply_to IS NOT NULL AND c.in_reply_to != ''
772
+ AND p.is_replied = 0
773
+ )
774
+ `).run(since);
775
+ // A new parent whose reply was already here.
776
+ const byParent = this.db.prepare(`
736
777
  UPDATE messages SET is_replied = 1
737
- WHERE is_replied = 0
778
+ WHERE cached_at >= ? AND is_replied = 0
738
779
  AND message_id IS NOT NULL AND message_id != ''
739
780
  AND EXISTS (
740
781
  SELECT 1 FROM messages m2
741
782
  WHERE m2.account_id = messages.account_id
742
783
  AND m2.in_reply_to = messages.message_id
743
784
  )
744
- `).run();
745
- if (r.changes > 0)
746
- console.log(` [db] backfilled is_replied on ${r.changes} rows`);
785
+ `).run(since);
786
+ const changes = Number(byReply.changes || 0) + Number(byParent.changes || 0);
787
+ if (changes > 0)
788
+ console.log(` [db] backfilled is_replied on ${changes} rows`);
789
+ this.setKv("schema", "is_replied_backfill_at", String(stamp));
747
790
  }
748
791
  catch (e) {
749
792
  console.error(` [db] is_replied backfill failed: ${e?.message || e}`);
@@ -811,17 +854,21 @@ export class MailxDB {
811
854
  // backlog without waiting for each folder to re-sync. Same-folder only —
812
855
  // cross-folder memberships (a real move's stale source) are NOT touched
813
856
  // here; those need server reconcile to resolve safely.
814
- try {
815
- const r = this.db.prepare(`DELETE FROM message_folders WHERE id NOT IN (
857
+ // Once (kv, 2026-09-30): upsertMessageFolder has kept the invariant
858
+ // since 2026-06-16; this was clearing a backlog that is long gone.
859
+ if (!this.getKv("schema", "collapse_dup_memberships_v1"))
860
+ try {
861
+ const r = this.db.prepare(`DELETE FROM message_folders WHERE id NOT IN (
816
862
  SELECT MAX(id) FROM message_folders GROUP BY message_row_id, folder_id
817
863
  )`).run();
818
- const n = Number(r.changes || 0);
819
- if (n > 0)
820
- console.log(` [db] collapsed ${n} duplicate same-folder membership row(s)`);
821
- }
822
- catch (e) {
823
- console.error(` [db] membership collapse failed: ${e?.message || e}`);
824
- }
864
+ const n = Number(r.changes || 0);
865
+ if (n > 0)
866
+ console.log(` [db] collapsed ${n} duplicate same-folder membership row(s)`);
867
+ this.setKv("schema", "collapse_dup_memberships_v1", "1");
868
+ }
869
+ catch (e) {
870
+ console.error(` [db] membership collapse failed: ${e?.message || e}`);
871
+ }
825
872
  // (The per-boot cross-folder collapse for "exclusive" accounts was
826
873
  // REMOVED 2026-06-16 — it deleted intentional copies. Cross-folder stale
827
874
  // memberships are now cleaned by server-truth reconcile, which keeps real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.135",
3
+ "version": "0.1.138",
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.108",
13
- "@bobfrankston/mailx-settings": "^0.1.107",
13
+ "@bobfrankston/mailx-settings": "^0.1.108",
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.108",
33
- "@bobfrankston/mailx-settings": "^0.1.107",
33
+ "@bobfrankston/mailx-settings": "^0.1.108",
34
34
  "@bobfrankston/mailx-bus": "^0.1.2",
35
35
  "mailparser": "^3.7.2"
36
36
  }