@bobfrankston/mailx-store 0.1.134 → 0.1.137
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.js +81 -34
- 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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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.
|
|
632
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
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
|
-
|
|
746
|
-
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.137",
|
|
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.
|
|
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.
|
|
33
|
+
"@bobfrankston/mailx-settings": "^0.1.108",
|
|
34
34
|
"@bobfrankston/mailx-bus": "^0.1.2",
|
|
35
35
|
"mailparser": "^3.7.2"
|
|
36
36
|
}
|