@blamejs/core 0.11.24 → 0.11.26

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/lib/mail-store.js CHANGED
@@ -63,6 +63,7 @@ var cryptoField = require("./crypto-field");
63
63
  var safeMime = require("./safe-mime");
64
64
  var safeSql = require("./safe-sql");
65
65
  var guardMessageId = require("./guard-message-id");
66
+ var mailStoreFts = require("./mail-store-fts");
66
67
  var { defineClass } = require("./framework-error");
67
68
 
68
69
  var MailStoreError = defineClass("MailStoreError", { alwaysPermanent: true });
@@ -126,6 +127,7 @@ function create(opts) {
126
127
  var qFolders = safeSql.quoteIdentifier(prefix + "_folders", "sqlite");
127
128
  var qFlags = safeSql.quoteIdentifier(prefix + "_flags", "sqlite");
128
129
  var qQuota = safeSql.quoteIdentifier(prefix + "_quota", "sqlite");
130
+ var qFts = safeSql.quoteIdentifier(prefix + "_messages_fts", "sqlite");
129
131
  var messagesTable = prefix + "_messages";
130
132
 
131
133
  var maxMessageBytes = opts.maxMessageBytes !== undefined ? opts.maxMessageBytes : DEFAULT_MAX_MESSAGE_BYTES;
@@ -147,7 +149,7 @@ function create(opts) {
147
149
  });
148
150
 
149
151
  if (doInit) {
150
- _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota);
152
+ _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota, qFts);
151
153
  _ensureDefaultFolders(db, qFolders);
152
154
  }
153
155
 
@@ -195,12 +197,27 @@ function create(opts) {
195
197
  " WHERE folder_id = ? AND objectid IN (SELECT value FROM json_each(?))");
196
198
  var stmtDeleteMsg = db.prepare("DELETE FROM " + qMsgs + " WHERE objectid = ?");
197
199
  var stmtDeleteFlags = db.prepare("DELETE FROM " + qFlags + " WHERE objectid = ?");
200
+ // Sealed-token FTS5 prepared statements — index sync runs in the
201
+ // same transaction window as the canonical row mutation so a crash
202
+ // between the two cannot leave the FTS index out of step with the
203
+ // messages table. See lib/mail-store-fts.js for the tokenize +
204
+ // vault-salted-hash transform applied here.
205
+ var stmtInsertFts = db.prepare(
206
+ "INSERT INTO " + qFts + " (objectid, subject_toks, addr_toks, body_toks) VALUES (?, ?, ?, ?)");
207
+ var stmtDeleteFts = db.prepare("DELETE FROM " + qFts + " WHERE objectid = ?");
198
208
 
199
209
  return {
200
210
  appendMessage: function (folderName, rawBytes, appendOpts) {
201
- return _appendMessage({
211
+ // Wrap canonical row insert + FTS row insert in a single backend
212
+ // transaction so a crash / FTS-row failure CANNOT leave a message
213
+ // persisted but unsearchable (state drift). better-sqlite3-style
214
+ // backends expose `.transaction(fn)()`; backends without
215
+ // transactions fall back to per-statement (the FTS insert is the
216
+ // last write, so partial state == still consistent to the reader).
217
+ var args = {
202
218
  db: db, qMsgs: qMsgs, qFlags: qFlags, messagesTable: messagesTable,
203
219
  stmtInsertMsg: stmtInsertMsg,
220
+ stmtInsertFts: stmtInsertFts,
204
221
  stmtBumpFolderModseq: stmtBumpFolderModseq,
205
222
  stmtGetFolderByName: stmtGetFolderByName,
206
223
  stmtFindThreadByMsgId: stmtFindThreadByMsgId,
@@ -209,7 +226,13 @@ function create(opts) {
209
226
  safeMimeOpts: safeMimeOpts,
210
227
  maxMessageBytes: maxMessageBytes,
211
228
  maxBodyBytes: maxBodyBytes,
212
- });
229
+ };
230
+ if (typeof db.transaction === "function") {
231
+ var result;
232
+ db.transaction(function () { result = _appendMessage(args); })();
233
+ return result;
234
+ }
235
+ return _appendMessage(args);
213
236
  },
214
237
  fetchByObjectId: function (folderName, objectid) {
215
238
  return _fetchByObjectId({
@@ -220,6 +243,97 @@ function create(opts) {
220
243
  folderName: folderName, objectid: objectid,
221
244
  });
222
245
  },
246
+ /**
247
+ * search — sealed-token full-text search inside a single folder.
248
+ *
249
+ * Composes the FTS5 virtual table populated by `appendMessage`.
250
+ * Each filter term is tokenized + vault-salted-hashed exactly like
251
+ * the index side, then issued as an FTS5 `MATCH` expression
252
+ * intersected with the modseq + flag window. Result rows carry the
253
+ * SAME shape as `queryByModseq` so operators iterate either path
254
+ * symmetrically.
255
+ *
256
+ * `filter` accepts (any subset; all present terms AND-combine):
257
+ * - text: match across subject + addr + body
258
+ * - subject: match against `subject_toks` column only
259
+ * - body: match against `body_toks` column only
260
+ * - from / to: match against `addr_toks`
261
+ * - sinceModseq: integer floor
262
+ * - limit: result cap (default 100, hard cap 1000)
263
+ *
264
+ * When NO text-side filter is present, falls through to the
265
+ * `queryByModseq` path — search is purely additive on the existing
266
+ * modseq cursor.
267
+ */
268
+ search: function (folderName, filter) {
269
+ var folder = stmtGetFolderByName.get(folderName);
270
+ if (!folder) {
271
+ throw new MailStoreError("mail-store/no-folder",
272
+ "search: folder '" + folderName + "' not found");
273
+ }
274
+ var f = filter || {};
275
+ var sinceModseq = f.sinceModseq || 0;
276
+ var limit = f.limit || 100;
277
+ if (limit > 1000) limit = 1000; // allow:raw-byte-literal — query row cap, not bytes
278
+
279
+ var matchClauses = [];
280
+ function addMatch(filterKey, term) {
281
+ if (!term) return;
282
+ var m = mailStoreFts.columnAndFieldFor(filterKey);
283
+ if (!m) return;
284
+ var expr = mailStoreFts.buildMatchExpression(messagesTable, m.field, term);
285
+ if (expr) matchClauses.push(m.column + ":(" + expr + ")");
286
+ }
287
+ if (f.subject) addMatch("subject", f.subject);
288
+ if (f.body) addMatch("body", f.body);
289
+ if (f.from) addMatch("from", f.from);
290
+ if (f.to) addMatch("to", f.to);
291
+ if (f.text) {
292
+ var perCol = ["subject", "body", "from"].map(function (key) {
293
+ var m = mailStoreFts.columnAndFieldFor(key);
294
+ var perColExpr = mailStoreFts.buildMatchExpression(messagesTable, m.field, f.text);
295
+ return perColExpr ? "(" + m.column + ":(" + perColExpr + "))" : null;
296
+ }).filter(Boolean);
297
+ if (perCol.length > 0) {
298
+ matchClauses.push("(" + perCol.join(" OR ") + ")");
299
+ }
300
+ }
301
+
302
+ if (matchClauses.length === 0) {
303
+ var fallback = stmtQueryByModseq.all(folder.id, sinceModseq, limit);
304
+ return {
305
+ rows: fallback.map(function (r) {
306
+ return {
307
+ objectid: r.objectid, modseq: r.modseq, sizeBytes: r.size_bytes,
308
+ internalDate: r.internal_date, legalHold: r.legal_hold === 1,
309
+ };
310
+ }),
311
+ nextModseq: fallback.length > 0 ? fallback[fallback.length - 1].modseq : sinceModseq,
312
+ };
313
+ }
314
+
315
+ var matchExpr = matchClauses.join(" AND ");
316
+ // FTS5 MATCH binds to the virtual-table name — aliases / joined-
317
+ // table refs are parsed as ordinary column refs and fail. The
318
+ // IN-subquery shape sidesteps that.
319
+ var sql =
320
+ "SELECT objectid, modseq, size_bytes, internal_date, legal_hold " +
321
+ "FROM " + qMsgs + " " +
322
+ "WHERE folder_id = ? AND modseq > ? " +
323
+ "AND objectid IN (SELECT objectid FROM " + qFts + " WHERE " + qFts + " MATCH ?) " +
324
+ "ORDER BY modseq ASC LIMIT ?";
325
+ var rows = db.prepare(sql).all(folder.id, sinceModseq, matchExpr, limit);
326
+ return {
327
+ rows: rows.map(function (r) {
328
+ return {
329
+ objectid: r.objectid, modseq: r.modseq, sizeBytes: r.size_bytes,
330
+ internalDate: r.internal_date, legalHold: r.legal_hold === 1,
331
+ };
332
+ }),
333
+ nextModseq: rows.length > 0 ? rows[rows.length - 1].modseq : sinceModseq,
334
+ matchExpr: matchExpr,
335
+ };
336
+ },
223
337
  queryByModseq: function (folderName, queryOpts) {
224
338
  var folder = stmtGetFolderByName.get(folderName);
225
339
  if (!folder) {
@@ -368,6 +482,7 @@ function create(opts) {
368
482
  function _runTxn() {
369
483
  for (var di = 0; di < toDelete.length; di += 1) {
370
484
  stmtDeleteFlags.run(toDelete[di].objectid);
485
+ stmtDeleteFts.run(toDelete[di].objectid);
371
486
  stmtDeleteMsg.run(toDelete[di].objectid);
372
487
  totalBytes += toDelete[di].size_bytes || 0;
373
488
  }
@@ -512,6 +627,18 @@ function _appendMessage(args) {
512
627
  args.stmtBumpFolderModseq.run(modseq, args.folderName);
513
628
  args.stmtBumpQuota.run(folder.id, buf.length, 1);
514
629
 
630
+ // FTS index update — tokenize the PRE-seal plaintext, hash each
631
+ // token with the per-deployment vault salt, insert into the FTS5
632
+ // virtual table.
633
+ var ftsRow = mailStoreFts.rowFromMessage(args.messagesTable, {
634
+ objectid: objectid,
635
+ subject: subject,
636
+ from: fromAddr,
637
+ to: toAddrs,
638
+ body: bodyText,
639
+ });
640
+ args.stmtInsertFts.run(ftsRow.objectid, ftsRow.subject_toks, ftsRow.addr_toks, ftsRow.body_toks);
641
+
515
642
  return { objectid: objectid, modseq: modseq, sizeBytes: buf.length, threadRootId: threadRootId };
516
643
  }
517
644
 
@@ -706,7 +833,7 @@ function _normalizeMsgId(s) {
706
833
 
707
834
  // ---- Schema bootstrap ----------------------------------------------------
708
835
 
709
- function _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota) {
836
+ function _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota, qFts) {
710
837
  // Folders table — created first since messages reference folder_id.
711
838
  db.prepare(
712
839
  "CREATE TABLE IF NOT EXISTS " + qFolders + " (" +
@@ -775,6 +902,13 @@ function _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota) {
775
902
  "cap_count INTEGER, " +
776
903
  "FOREIGN KEY(folder_id) REFERENCES " + qFolders + "(id))"
777
904
  ).run();
905
+
906
+ // Sealed-token FTS5 virtual table. The token-hash transform lives in
907
+ // `lib/mail-store-fts.js`; this is the storage layer. Tokenizer is
908
+ // `unicode61 remove_diacritics 2` so FTS5's segmenter splits hash-
909
+ // tokens on whitespace exactly — hashes are ASCII-hex-only, so no
910
+ // Unicode case-fold runs at MATCH time.
911
+ db.prepare(mailStoreFts.createSql(qFts)).run();
778
912
  }
779
913
 
780
914
  function _ensureDefaultFolders(db, qFolders) {
@@ -788,4 +922,8 @@ module.exports = {
788
922
  create: create,
789
923
  DEFAULT_FOLDERS: DEFAULT_FOLDERS,
790
924
  MailStoreError: MailStoreError,
925
+ // Sealed-token FTS substrate. Exposed for adjacent primitives (e.g.
926
+ // wire-protocol adapters translating IMAP SEARCH TEXT into the
927
+ // store's FTS5 column expression).
928
+ fts: mailStoreFts,
791
929
  };