@bobfrankston/mailx-store 0.1.11 → 0.1.13

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 +33 -2
  2. package/db.js +102 -9
  3. package/package.json +1 -1
package/db.d.ts CHANGED
@@ -148,6 +148,20 @@ export declare class MailxDB {
148
148
  updateLastSync(accountId: string, timestamp: number): void;
149
149
  upsertFolder(accountId: string, folderPath: string, name: string, specialUse: string, delimiter: string): number;
150
150
  getFolders(accountId: string): Folder[];
151
+ /** Append a row to the audit_log table. Every destructive operation on
152
+ * the messages table writes here so "where did the rows go?" has an
153
+ * authoritative answer. Cheap insert, indexed by ts and (account, folder). */
154
+ audit(entry: {
155
+ kind: "delete-msg" | "delete-bulk" | "migration" | "manual" | string;
156
+ accountId?: string;
157
+ folderId?: number;
158
+ uid?: number;
159
+ count?: number;
160
+ messageId?: string;
161
+ subject?: string;
162
+ reason?: string;
163
+ source?: string;
164
+ }): void;
151
165
  deleteFolder(folderId: number): void;
152
166
  markFolderRead(folderId: number): void;
153
167
  deleteAllMessages(accountId: string, folderId: number): void;
@@ -204,8 +218,9 @@ export declare class MailxDB {
204
218
  getMessageCount(accountId: string, folderId: number): number;
205
219
  /** Get all UIDs for a folder */
206
220
  getUidsForFolder(accountId: string, folderId: number): number[];
207
- /** Delete a message by account + UID */
208
- deleteMessage(accountId: string, uid: number): void;
221
+ /** Delete a message by account + UID. Reason is propagated into the
222
+ * audit_log so the DB carries an authoritative trail of every removal. */
223
+ deleteMessage(accountId: string, uid: number, reason?: string, source?: string): void;
209
224
  /** Recalculate folder total/unread counts from actual messages */
210
225
  recalcFolderCounts(folderId: number): void;
211
226
  /** Bulk insert within a transaction for sync performance */
@@ -238,6 +253,22 @@ export declare class MailxDB {
238
253
  private _onContactsChanged?;
239
254
  setOnContactsChanged(cb: () => void): void;
240
255
  private notifyContactsChanged;
256
+ /** Fired when upsertMessage detects a server-side move (Message-ID match
257
+ * in another folder rebinds the existing row to the destination). The
258
+ * reconciler can listen and cancel any pending deferred-delete for the
259
+ * original (folder, uid), so the move propagates without the source
260
+ * folder's reconcile-delete firing 30 minutes later for a row that's
261
+ * been rebound elsewhere. */
262
+ private _onMoveDetected?;
263
+ setOnMoveDetected(cb: (info: {
264
+ accountId: string;
265
+ messageId: string;
266
+ fromFolderId: number;
267
+ fromUid: number;
268
+ toFolderId: number;
269
+ toUid: number;
270
+ rowId: number;
271
+ }) => void): void;
241
272
  /** Seed `discovered`-tier contacts from every address that appears in
242
273
  * any cached message — From / To / Cc / Bcc across all folders. One row
243
274
  * per email; first non-empty name observed wins. Sent-folder rows skip
package/db.js CHANGED
@@ -285,6 +285,35 @@ const SCHEMA = `
285
285
  updated_at INTEGER NOT NULL,
286
286
  PRIMARY KEY(scope, key)
287
287
  );
288
+
289
+ -- Audit trail of every destructive DB operation. Writes ONLY — the row
290
+ -- inserted here records the deletion the caller is about to (or just
291
+ -- did) commit on the messages table. Lets the user retroactively answer
292
+ -- "where did my Sent rows go?" — every deletion has a row with
293
+ -- kind/reason/folder/uid + caller stack hint. Trivial cost (rows are
294
+ -- tiny), no retention policy yet — kept indefinitely.
295
+ --
296
+ -- kind values:
297
+ -- delete-msg single message removed (sync_actions drain, body
298
+ -- fetch reported isNotFound, reconcile)
299
+ -- delete-bulk bulk delete (reconcile pruning, emptyFolder, etc.)
300
+ -- migration schema migrations that touch messages
301
+ -- manual direct user "rebuild" / "wipe folder"
302
+ CREATE TABLE IF NOT EXISTS audit_log (
303
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
304
+ ts INTEGER NOT NULL,
305
+ kind TEXT NOT NULL,
306
+ account_id TEXT,
307
+ folder_id INTEGER,
308
+ uid INTEGER,
309
+ count INTEGER,
310
+ message_id TEXT,
311
+ subject TEXT,
312
+ reason TEXT,
313
+ source TEXT
314
+ );
315
+ CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
316
+ CREATE INDEX IF NOT EXISTS idx_audit_log_folder ON audit_log(account_id, folder_id);
288
317
  `;
289
318
  export class MailxDB {
290
319
  db;
@@ -344,8 +373,10 @@ export class MailxDB {
344
373
  const purgedFlag = this.getKv("schema", "drop_synthetic_uids_v1");
345
374
  if (!purgedFlag) {
346
375
  const r = this.db.prepare("DELETE FROM messages WHERE uid < 0").run();
347
- if (r.changes) {
348
- console.log(` [migration] dropped ${r.changes} synthetic-UID rows from messages`);
376
+ const count = r.changes || 0;
377
+ if (count > 0) {
378
+ console.log(` [migration] dropped ${count} synthetic-UID rows from messages`);
379
+ this.audit({ kind: "migration", count, reason: "drop_synthetic_uids_v1 — purge negative-UID optimistic-Sent rows", source: "MailxDB constructor" });
349
380
  }
350
381
  this.setKv("schema", "drop_synthetic_uids_v1", "1");
351
382
  }
@@ -930,8 +961,24 @@ export class MailxDB {
930
961
  }
931
962
  return folders;
932
963
  }
964
+ /** Append a row to the audit_log table. Every destructive operation on
965
+ * the messages table writes here so "where did the rows go?" has an
966
+ * authoritative answer. Cheap insert, indexed by ts and (account, folder). */
967
+ audit(entry) {
968
+ try {
969
+ this.db.prepare(`INSERT INTO audit_log (ts, kind, account_id, folder_id, uid, count, message_id, subject, reason, source)
970
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(Date.now(), entry.kind, entry.accountId ?? null, entry.folderId ?? null, entry.uid ?? null, entry.count ?? null, entry.messageId ?? null, entry.subject ?? null, entry.reason ?? null, entry.source ?? null);
971
+ }
972
+ catch (e) {
973
+ // Audit must never break the operation it's auditing.
974
+ console.error(` [audit] failed: ${e?.message || e}`);
975
+ }
976
+ }
933
977
  deleteFolder(folderId) {
934
- this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
978
+ const r = this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
979
+ const count = r.changes || 0;
980
+ if (count > 0)
981
+ this.audit({ kind: "delete-bulk", folderId, count, reason: "folder deleted", source: "deleteFolder" });
935
982
  this.db.prepare("DELETE FROM folders WHERE id = ?").run(folderId);
936
983
  }
937
984
  markFolderRead(folderId) {
@@ -939,7 +986,10 @@ export class MailxDB {
939
986
  this.recalcFolderCounts(folderId);
940
987
  }
941
988
  deleteAllMessages(accountId, folderId) {
942
- this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
989
+ const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND folder_id = ?").run(accountId, folderId);
990
+ const count = r.changes || 0;
991
+ if (count > 0)
992
+ this.audit({ kind: "delete-bulk", accountId, folderId, count, reason: "deleteAllMessages (emptyFolder)", source: "deleteAllMessages" });
943
993
  this.recalcFolderCounts(folderId);
944
994
  }
945
995
  updateFolderCounts(folderId, total, unread) {
@@ -995,6 +1045,25 @@ export class MailxDB {
995
1045
  // Update folder_id + uid; preserve uuid, body_path, flags
996
1046
  // (server flags will catch up on the next full sync).
997
1047
  this.db.prepare("UPDATE messages SET folder_id = ?, uid = ?, cached_at = ? WHERE id = ?").run(msg.folderId, msg.uid, Date.now(), moved.id);
1048
+ // Notify subscribers so e.g. the reconciler can cancel any
1049
+ // pending deferred-delete for the original (folder, uid) —
1050
+ // otherwise the reconcile-delete grace timer fires for a
1051
+ // row that's been rebound elsewhere, and the user sees the
1052
+ // moved message vanish 30 minutes after the move.
1053
+ if (this._onMoveDetected) {
1054
+ try {
1055
+ this._onMoveDetected({
1056
+ accountId: msg.accountId,
1057
+ messageId: msg.messageId,
1058
+ fromFolderId: moved.folder_id,
1059
+ fromUid: moved.uid,
1060
+ toFolderId: msg.folderId,
1061
+ toUid: msg.uid,
1062
+ rowId: moved.id,
1063
+ });
1064
+ }
1065
+ catch { /* listener errors must not break sync */ }
1066
+ }
998
1067
  return moved.id;
999
1068
  }
1000
1069
  }
@@ -1231,11 +1300,25 @@ export class MailxDB {
1231
1300
  const rows = this.db.prepare("SELECT uid FROM messages WHERE account_id = ? AND folder_id = ?").all(accountId, folderId);
1232
1301
  return rows.map(r => r.uid);
1233
1302
  }
1234
- /** Delete a message by account + UID */
1235
- deleteMessage(accountId, uid) {
1236
- // Get folderId before deleting so we can update counts
1237
- const msg = this.db.prepare("SELECT folder_id FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
1238
- this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
1303
+ /** Delete a message by account + UID. Reason is propagated into the
1304
+ * audit_log so the DB carries an authoritative trail of every removal. */
1305
+ deleteMessage(accountId, uid, reason, source) {
1306
+ // Get folderId + message_id + subject before deleting so the audit
1307
+ // row carries enough context to identify what was removed.
1308
+ const msg = this.db.prepare("SELECT folder_id, message_id, subject FROM messages WHERE account_id = ? AND uid = ?").get(accountId, uid);
1309
+ const r = this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ?").run(accountId, uid);
1310
+ if (r.changes && msg) {
1311
+ this.audit({
1312
+ kind: "delete-msg",
1313
+ accountId,
1314
+ folderId: msg.folder_id,
1315
+ uid,
1316
+ messageId: msg.message_id || undefined,
1317
+ subject: msg.subject || undefined,
1318
+ reason: reason || "deleteMessage (no reason)",
1319
+ source: source || "db.deleteMessage",
1320
+ });
1321
+ }
1239
1322
  // Refresh folder counts
1240
1323
  if (msg)
1241
1324
  this.recalcFolderCounts(msg.folder_id);
@@ -1322,6 +1405,16 @@ export class MailxDB {
1322
1405
  }
1323
1406
  catch { /* ignore */ }
1324
1407
  }
1408
+ /** Fired when upsertMessage detects a server-side move (Message-ID match
1409
+ * in another folder rebinds the existing row to the destination). The
1410
+ * reconciler can listen and cancel any pending deferred-delete for the
1411
+ * original (folder, uid), so the move propagates without the source
1412
+ * folder's reconcile-delete firing 30 minutes later for a row that's
1413
+ * been rebound elsewhere. */
1414
+ _onMoveDetected;
1415
+ setOnMoveDetected(cb) {
1416
+ this._onMoveDetected = cb;
1417
+ }
1325
1418
  /** Seed `discovered`-tier contacts from every address that appears in
1326
1419
  * any cached message — From / To / Cc / Bcc across all folders. One row
1327
1420
  * per email; first non-empty name observed wins. Sent-folder rows skip
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",