@bobfrankston/mailx-store 0.1.18 → 0.1.19

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 +1 -0
  2. package/db.js +55 -10
  3. package/package.json +5 -5
package/db.d.ts CHANGED
@@ -82,6 +82,7 @@ export declare class MailxDB {
82
82
  dirty?: boolean;
83
83
  recurringEventId?: string;
84
84
  htmlLink?: string;
85
+ isHoliday?: boolean;
85
86
  }): string;
86
87
  getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
87
88
  /** Lookup by uuid only — used by patch/delete paths that don't have an
package/db.js CHANGED
@@ -406,6 +406,11 @@ export class MailxDB {
406
406
  // Filters like "hide recurring events" check this column.
407
407
  this.addColumnIfMissing("calendar_events", "recurring_event_id", "TEXT");
408
408
  this.addColumnIfMissing("calendar_events", "html_link", "TEXT");
409
+ // Holidays come from a separate Google calendar
410
+ // (en.usa#holiday@group.v.calendar.google.com) but share the
411
+ // calendar_events table; the flag distinguishes them so the UI can
412
+ // style them differently and the alarm scheduler can skip them.
413
+ this.addColumnIfMissing("calendar_events", "is_holiday", "INTEGER NOT NULL DEFAULT 0");
409
414
  // Backfill UUIDs for any pre-existing rows that were inserted before
410
415
  // this column landed. One UPDATE + an id roundtrip per row — cheap
411
416
  // at our row counts, runs once per DB upgrade.
@@ -732,8 +737,8 @@ export class MailxDB {
732
737
  INSERT INTO calendar_events
733
738
  (uuid, account_id, provider_id, calendar_id, title, start_ms, end_ms,
734
739
  all_day, location, notes, etag, last_synced, dirty, deleted, updated_at,
735
- recurring_event_id, html_link)
736
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
740
+ recurring_event_id, html_link, is_holiday)
741
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
737
742
  ON CONFLICT(uuid) DO UPDATE SET
738
743
  account_id=excluded.account_id, provider_id=excluded.provider_id,
739
744
  calendar_id=excluded.calendar_id, title=excluded.title,
@@ -743,8 +748,9 @@ export class MailxDB {
743
748
  last_synced=excluded.last_synced, dirty=excluded.dirty,
744
749
  updated_at=excluded.updated_at,
745
750
  recurring_event_id=excluded.recurring_event_id,
746
- html_link=excluded.html_link
747
- `).run(uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary", ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0, ev.location || "", ev.notes || "", ev.etag || null, ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now(), ev.recurringEventId || null, ev.htmlLink || null);
751
+ html_link=excluded.html_link,
752
+ is_holiday=excluded.is_holiday
753
+ `).run(uuid, ev.accountId, ev.providerId || null, ev.calendarId || "primary", ev.title, ev.startMs, ev.endMs, ev.allDay ? 1 : 0, ev.location || "", ev.notes || "", ev.etag || null, ev.dirty ? 0 : Date.now(), ev.dirty ? 1 : 0, Date.now(), ev.recurringEventId || null, ev.htmlLink || null, ev.isHoliday ? 1 : 0);
748
754
  return uuid;
749
755
  }
750
756
  getCalendarEvents(accountId, fromMs, toMs) {
@@ -779,6 +785,7 @@ export class MailxDB {
779
785
  etag: r.etag, lastSynced: r.last_synced, dirty: !!r.dirty, deleted: !!r.deleted,
780
786
  recurringEventId: r.recurring_event_id || null,
781
787
  htmlLink: r.html_link || null,
788
+ isHoliday: !!r.is_holiday,
782
789
  };
783
790
  }
784
791
  /** Find a calendar event by its Google Calendar event id (provider_id).
@@ -1045,10 +1052,31 @@ export class MailxDB {
1045
1052
  }
1046
1053
  }
1047
1054
  deleteFolder(folderId) {
1048
- // Drop memberships in this folder first, then GC any messages
1049
- // rows that are now orphaned (no remaining memberships). A
1050
- // multi-folder message (e.g. Gmail label = INBOX + Important)
1051
- // keeps its body intact when only one of its folders is deleted.
1055
+ // Repoint the legacy `messages.folder_id` for any message that
1056
+ // lives in this folder AND at least one other. Without this the
1057
+ // final `DELETE FROM folders` fails with a FOREIGN KEY constraint:
1058
+ // the message survives the GC (still belongs to its other folder)
1059
+ // but its primary folder_id still points at the row we're about
1060
+ // to delete. Pick the smallest remaining membership as the new
1061
+ // primary; arbitrary but stable.
1062
+ this.db.prepare(`
1063
+ UPDATE messages
1064
+ SET folder_id = (
1065
+ SELECT MIN(folder_id) FROM message_folders
1066
+ WHERE message_row_id = messages.id
1067
+ AND folder_id != ?
1068
+ )
1069
+ WHERE folder_id = ?
1070
+ AND EXISTS (
1071
+ SELECT 1 FROM message_folders
1072
+ WHERE message_row_id = messages.id
1073
+ AND folder_id != ?
1074
+ )
1075
+ `).run(folderId, folderId, folderId);
1076
+ // Drop memberships in this folder, then GC any messages rows that
1077
+ // are now orphaned (no remaining memberships). A multi-folder
1078
+ // message (e.g. Gmail label = INBOX + Important) keeps its body
1079
+ // intact when only one of its folders is deleted.
1052
1080
  const orphans = this.gcMembershipsAndCollectOrphans(folderId);
1053
1081
  if (orphans.count > 0)
1054
1082
  this.audit({ kind: "delete-bulk", folderId, count: orphans.count, reason: "folder deleted", source: "deleteFolder" });
@@ -1234,6 +1262,23 @@ export class MailxDB {
1234
1262
  if (msg.providerId && !existing.provider_id) {
1235
1263
  this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
1236
1264
  }
1265
+ // Local-first flag preservation: a row whose flags the user
1266
+ // changed (mark read, flag/star, etc.) gets a pending
1267
+ // `sync_action` queued. Until that action drains to the server,
1268
+ // the server's snapshot is STALE for flags — pulling its
1269
+ // "unread" answer back and overwriting the local "\Seen" would
1270
+ // bounce the read row back to unread, which is what Bob 2026-05-12
1271
+ // reported: "you keep reverting messages to show up as unread
1272
+ // even after it is marked read." Keep the local flags whenever a
1273
+ // pending flag-push exists; the regular processSyncActions cycle
1274
+ // will reconcile in the user's direction.
1275
+ const pendingFlags = this.db.prepare(`SELECT 1 FROM sync_actions
1276
+ WHERE account_id = ? AND folder_id = ? AND uid = ?
1277
+ AND action = 'flags'
1278
+ LIMIT 1`).get(msg.accountId, msg.folderId, msg.uid);
1279
+ const flagsToWrite = pendingFlags
1280
+ ? (this.db.prepare("SELECT flags_json FROM messages WHERE id = ?").get(existing.id)?.flags_json ?? JSON.stringify(msg.flags))
1281
+ : JSON.stringify(msg.flags);
1237
1282
  // Only overwrite body_path / preview when the caller actually has a
1238
1283
  // body. Metadata-only syncs (Gmail API storeApiMessages, IMAP
1239
1284
  // header-only fetches) pass bodyPath: "" and would otherwise wipe
@@ -1243,13 +1288,13 @@ export class MailxDB {
1243
1288
  this.db.prepare(`
1244
1289
  UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
1245
1290
  WHERE id = ?
1246
- `).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
1291
+ `).run(flagsToWrite, msg.preview, msg.bodyPath, Date.now(), existing.id);
1247
1292
  }
1248
1293
  else {
1249
1294
  this.db.prepare(`
1250
1295
  UPDATE messages SET flags_json = ?, cached_at = ?
1251
1296
  WHERE id = ?
1252
- `).run(JSON.stringify(msg.flags), Date.now(), existing.id);
1297
+ `).run(flagsToWrite, Date.now(), existing.id);
1253
1298
  }
1254
1299
  // Refresh membership last_seen_at — server confirmed this UID
1255
1300
  // is still in this folder. No-op if migration already populated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.10",
13
- "@bobfrankston/mailx-settings": "^0.1.14"
12
+ "@bobfrankston/mailx-types": "^0.1.11",
13
+ "@bobfrankston/mailx-settings": "^0.1.15"
14
14
  },
15
15
  "repository": {
16
16
  "type": "git",
@@ -25,8 +25,8 @@
25
25
  },
26
26
  ".transformedSnapshot": {
27
27
  "dependencies": {
28
- "@bobfrankston/mailx-types": "^0.1.10",
29
- "@bobfrankston/mailx-settings": "^0.1.14"
28
+ "@bobfrankston/mailx-types": "^0.1.11",
29
+ "@bobfrankston/mailx-settings": "^0.1.15"
30
30
  }
31
31
  }
32
32
  }