@blamejs/core 0.14.27 → 0.15.1

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 (134) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +158 -77
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +228 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +82 -29
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +293 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. package/sbom.cdx.json +6 -6
package/lib/mail-store.js CHANGED
@@ -63,6 +63,7 @@ var cryptoField = require("./crypto-field");
63
63
  var vault = require("./vault");
64
64
  var safeMime = require("./safe-mime");
65
65
  var safeSql = require("./safe-sql");
66
+ var sql = require("./sql");
66
67
  var guardMessageId = require("./guard-message-id");
67
68
  var mailStoreFts = require("./mail-store-fts");
68
69
  var { defineClass } = require("./framework-error");
@@ -133,13 +134,28 @@ function create(opts) {
133
134
  throw new MailStoreError("mail-store/bad-table-prefix",
134
135
  "mailStore.create: tablePrefix is not a valid SQL identifier: " + e.message);
135
136
  }
136
- var qMsgs = safeSql.quoteIdentifier(prefix + "_messages", "sqlite");
137
- var qFolders = safeSql.quoteIdentifier(prefix + "_folders", "sqlite");
138
- var qFlags = safeSql.quoteIdentifier(prefix + "_flags", "sqlite");
139
- var qQuota = safeSql.quoteIdentifier(prefix + "_quota", "sqlite");
140
- var qFts = safeSql.quoteIdentifier(prefix + "_messages_fts", "sqlite");
141
- var qMeta = safeSql.quoteIdentifier(prefix + "_meta", "sqlite");
137
+ // Bare logical table names. The store targets a CONCRETE sqlite handle
138
+ // (the operator's backend), never b.clusterStorage, so every b.sql
139
+ // builder is constructed with { dialect: "sqlite", quoteName: true } so
140
+ // the prefixed name emits as a quoted `"..."` identifier (the same
141
+ // quote-by-construction the prior hand-rolled safeSql.quoteIdentifier
142
+ // produced). The few quoted tokens kept below feed the two spots a verb
143
+ // builder cannot reach: the FTS5 virtual-table self-reference in a MATCH
144
+ // sub-query and the existing-row self-reference inside the quota upsert's
145
+ // `col = col + EXCLUDED.col` expression.
142
146
  var messagesTable = prefix + "_messages";
147
+ var foldersTable = prefix + "_folders";
148
+ var flagsTable = prefix + "_flags";
149
+ var quotaTable = prefix + "_quota";
150
+ var ftsTable = prefix + "_messages_fts";
151
+ var metaTable = prefix + "_meta";
152
+ var SQL = { dialect: "sqlite", quoteName: true };
153
+ // The only quoted table token kept as a literal: the quota table's
154
+ // existing-row self-reference inside the `col = col +/- ...` SET
155
+ // expressions (the decrement update + the accumulate-on-conflict upsert),
156
+ // which a structured set()/value cell cannot express. Every other table
157
+ // identifier emits through the b.sql builders above with quoteName.
158
+ var qQuota = safeSql.quoteIdentifier(quotaTable, "sqlite");
143
159
 
144
160
  var maxMessageBytes = opts.maxMessageBytes !== undefined ? opts.maxMessageBytes : DEFAULT_MAX_MESSAGE_BYTES;
145
161
  var maxBodyBytes = opts.maxBodyBytes !== undefined ? opts.maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
@@ -160,62 +176,138 @@ function create(opts) {
160
176
  });
161
177
 
162
178
  if (doInit) {
163
- _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota, qFts, qMeta);
164
- _ensureDefaultFolders(db, qFolders);
179
+ _ensureSchema(db, {
180
+ messagesTable: messagesTable, foldersTable: foldersTable,
181
+ flagsTable: flagsTable, quotaTable: quotaTable,
182
+ ftsTable: ftsTable, metaTable: metaTable,
183
+ });
184
+ _ensureDefaultFolders(db, foldersTable);
165
185
  }
166
186
 
167
- // Prepared statements — cached across the store lifetime.
168
- var stmtInsertMsg = db.prepare(
169
- "INSERT INTO " + qMsgs + " (" +
170
- "objectid, folder_id, modseq, internal_date, received_at, size_bytes, " +
171
- "message_id, message_id_hash, in_reply_to, references_csv, " +
172
- "thread_root_id, subject, from_addr, from_hash, to_addrs, " +
173
- "body_text, body_html, legal_hold) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)"
174
- );
175
- var stmtBumpFolderModseq = db.prepare("UPDATE " + qFolders + " SET modseq_max = ? WHERE name = ?");
176
- var stmtGetFolderByName = db.prepare("SELECT id, name, role, parent_id, modseq_max, uidvalidity FROM " + qFolders + " WHERE name = ?");
177
- var stmtFetchMsg = db.prepare("SELECT * FROM " + qMsgs + " WHERE objectid = ? AND folder_id = ?");
178
- var stmtQueryByModseq = db.prepare(
179
- "SELECT objectid, modseq, size_bytes, internal_date, legal_hold FROM " + qMsgs +
180
- " WHERE folder_id = ? AND modseq > ? ORDER BY modseq ASC LIMIT ?");
181
- var stmtFlagsForMsg = db.prepare("SELECT flag FROM " + qFlags + " WHERE objectid = ?");
182
- var stmtSetFlag = db.prepare("INSERT OR IGNORE INTO " + qFlags + " (objectid, flag, set_at) VALUES (?, ?, ?)");
183
- var stmtUnsetFlag = db.prepare("DELETE FROM " + qFlags + " WHERE objectid = ? AND flag = ?");
184
- var stmtLegalHold = db.prepare("UPDATE " + qMsgs + " SET legal_hold = ? WHERE objectid = ?");
185
- var stmtMoveByObjectId = db.prepare(
186
- "UPDATE " + qMsgs + " SET folder_id = ?, modseq = ? WHERE objectid = ? AND folder_id = ?");
187
- var stmtSizeByObjectId = db.prepare(
188
- "SELECT size_bytes FROM " + qMsgs + " WHERE objectid = ? AND folder_id = ?");
189
- var stmtDecrementQuota = db.prepare(
190
- "UPDATE " + qQuota + " SET used_bytes = used_bytes - ?, used_count = used_count - ? WHERE folder_id = ?");
191
- var stmtThreadFor = db.prepare("SELECT objectid FROM " + qMsgs + " WHERE thread_root_id = ? ORDER BY received_at ASC");
192
- var stmtFindThreadByMsgId = db.prepare(
193
- "SELECT objectid, thread_root_id FROM " + qMsgs + " WHERE message_id_hash = ? LIMIT 1");
194
- var stmtInsertFolder = db.prepare(
195
- "INSERT INTO " + qFolders + " (name, role, parent_id, modseq_max, uidvalidity) VALUES (?, ?, ?, 0, ?)");
196
- var stmtListFolders = db.prepare("SELECT id, name, role, parent_id, modseq_max FROM " + qFolders);
197
- var stmtQuotaForFolder = db.prepare("SELECT used_bytes, used_count, cap_bytes, cap_count FROM " + qQuota + " WHERE folder_id = ?");
198
- var stmtBumpQuota = db.prepare(
199
- "INSERT INTO " + qQuota + " (folder_id, used_bytes, used_count, cap_bytes, cap_count) VALUES (?, ?, ?, NULL, NULL) " +
200
- "ON CONFLICT(folder_id) DO UPDATE SET used_bytes = used_bytes + excluded.used_bytes, used_count = used_count + excluded.used_count");
187
+ // Prepared statements — cached across the store lifetime. Every
188
+ // statement text is composed through b.sql (sqlite dialect, quoteName so
189
+ // the prefixed table name emits as a quoted identifier against the
190
+ // concrete handle) and then prepared once; the node:sqlite handle binds
191
+ // the `?` placeholders positionally at run() / get() / all() time. Every
192
+ // VALUES / SET cell is a `"?"` placeholder string so the builder emits a
193
+ // `?` per column (only the SQL TEXT is kept; the throwaway params array
194
+ // of literal "?" strings is discarded) — the caller binds the real values
195
+ // at run() time, including the leading 0 the INSERT seeds `legal_hold` to.
196
+ var stmtInsertMsg = db.prepare(sql.insert(messagesTable, SQL)
197
+ .columns([
198
+ "objectid", "folder_id", "modseq", "internal_date", "received_at",
199
+ "size_bytes", "message_id", "message_id_hash", "in_reply_to",
200
+ "references_csv", "thread_root_id", "subject", "from_addr", "from_hash",
201
+ "to_addrs", "body_text", "body_html", "legal_hold",
202
+ ])
203
+ .values([
204
+ "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?", "?",
205
+ ]).toSql().sql);
206
+ var stmtBumpFolderModseq = db.prepare(sql.update(foldersTable, SQL)
207
+ .set("modseq_max", "?").where("name", "?").toSql().sql);
208
+ var stmtGetFolderByName = db.prepare(sql.select(foldersTable, SQL)
209
+ .columns(["id", "name", "role", "parent_id", "modseq_max", "uidvalidity"])
210
+ .where("name", "?").toSql().sql);
211
+ var stmtFetchMsg = db.prepare(sql.select(messagesTable, SQL)
212
+ .where("objectid", "?").where("folder_id", "?").toSql().sql);
213
+ // The modseq-cursor read carries a per-call row LIMIT. b.sql's limit()
214
+ // takes a concrete integer (a bound LIMIT placeholder is not a builder
215
+ // shape), and the limit is a server-controlled, hard-capped integer so
216
+ // the statement is composed per call with the integer baked in (the
217
+ // folder_id + sinceModseq still bind as `?`). Cached prepared statements
218
+ // are reserved for the fixed-shape reads above.
219
+ function _queryByModseqStmt(limit) {
220
+ // Coerce the per-call limit to a non-negative integer before it reaches
221
+ // b.sql's limit() (which THROWS on a non-integer). The limit is an
222
+ // operator-supplied query opt, so this is the defensive-reader tier: a
223
+ // garbage value floors to a sane page size rather than crashing the read.
224
+ var n = Math.floor(Number(limit));
225
+ if (!isFinite(n) || n < 0) n = 1000;
226
+ return db.prepare(sql.select(messagesTable, SQL)
227
+ .columns(["objectid", "modseq", "size_bytes", "internal_date", "legal_hold"])
228
+ .where("folder_id", "?").whereOp("modseq", ">", "?")
229
+ .orderBy("modseq", "asc").limit(n).toSql().sql);
230
+ }
231
+ function _queryByModseqRows(folderId, sinceModseq, limit) {
232
+ return _queryByModseqStmt(limit).all(folderId, sinceModseq);
233
+ }
234
+ var stmtFlagsForMsg = db.prepare(sql.select(flagsTable, SQL)
235
+ .columns(["flag"]).where("objectid", "?").toSql().sql);
236
+ // INSERT OR IGNORE — composed as ON CONFLICT(objectid, flag) DO NOTHING
237
+ // (the flags PK), the sqlite-final upsert form b.sql emits for the
238
+ // idempotent flag set.
239
+ var stmtSetFlag = db.prepare(sql.upsert(flagsTable, SQL)
240
+ .columns(["objectid", "flag", "set_at"]).values({ objectid: "?", flag: "?", set_at: "?" })
241
+ .onConflict(["objectid", "flag"]).doNothing().toSql().sql);
242
+ var stmtUnsetFlag = db.prepare(sql.delete(flagsTable, SQL)
243
+ .where("objectid", "?").where("flag", "?").toSql().sql);
244
+ var stmtLegalHold = db.prepare(sql.update(messagesTable, SQL)
245
+ .set("legal_hold", "?").where("objectid", "?").toSql().sql);
246
+ var stmtMoveByObjectId = db.prepare(sql.update(messagesTable, SQL)
247
+ .set("folder_id", "?").set("modseq", "?")
248
+ .where("objectid", "?").where("folder_id", "?").toSql().sql);
249
+ var stmtSizeByObjectId = db.prepare(sql.select(messagesTable, SQL)
250
+ .columns(["size_bytes"]).where("objectid", "?").where("folder_id", "?").toSql().sql);
251
+ // `used_bytes = used_bytes - ?` — a guarded setRaw expression (the
252
+ // decrement amount binds as the leading `?`; the column self-reference
253
+ // is quoted by construction).
254
+ var stmtDecrementQuota = db.prepare(sql.update(quotaTable, SQL)
255
+ .setRaw("used_bytes", qQuota + ".\"used_bytes\" - ?", ["?"])
256
+ .setRaw("used_count", qQuota + ".\"used_count\" - ?", ["?"])
257
+ .where("folder_id", "?").toSql().sql);
258
+ var stmtThreadFor = db.prepare(sql.select(messagesTable, SQL)
259
+ .columns(["objectid"]).where("thread_root_id", "?")
260
+ .orderBy("received_at", "asc").toSql().sql);
261
+ var stmtFindThreadByMsgId = db.prepare(sql.select(messagesTable, SQL)
262
+ .columns(["objectid", "thread_root_id"]).where("message_id_hash", "?")
263
+ .limit(1).toSql().sql);
264
+ var stmtInsertFolder = db.prepare(sql.insert(foldersTable, SQL)
265
+ .columns(["name", "role", "parent_id", "modseq_max", "uidvalidity"])
266
+ .values(["?", "?", "?", "?", "?"]).toSql().sql);
267
+ var stmtListFolders = db.prepare(sql.select(foldersTable, SQL)
268
+ .columns(["id", "name", "role", "parent_id", "modseq_max"]).toSql().sql);
269
+ var stmtQuotaForFolder = db.prepare(sql.select(quotaTable, SQL)
270
+ .columns(["used_bytes", "used_count", "cap_bytes", "cap_count"])
271
+ .where("folder_id", "?").toSql().sql);
272
+ // INSERT ... ON CONFLICT(folder_id) DO UPDATE SET col = col + EXCLUDED.col
273
+ // — the atomic per-folder quota accumulator. The existing-row and
274
+ // proposed-row (EXCLUDED) self-references are quoted by construction off
275
+ // the resolved table name so the prefix stays consistent across the
276
+ // INSERT target and the SET expression (the same shape b.rateLimit's
277
+ // window accumulator composes).
278
+ var stmtBumpQuota = db.prepare(sql.upsert(quotaTable, SQL)
279
+ .columns(["folder_id", "used_bytes", "used_count", "cap_bytes", "cap_count"])
280
+ .values({ folder_id: "?", used_bytes: "?", used_count: "?", cap_bytes: "?", cap_count: "?" })
281
+ .onConflict(["folder_id"])
282
+ .doUpdate({
283
+ used_bytes: qQuota + ".\"used_bytes\" + EXCLUDED.\"used_bytes\"",
284
+ used_count: qQuota + ".\"used_count\" + EXCLUDED.\"used_count\"",
285
+ }).toSql().sql);
201
286
  // Hard-expunge prepared statements — used by `hardExpunge` to delete
202
287
  // a message permanently after retention-floor + legal-hold gates
203
288
  // pass. The SELECT is the gate-input source (legal_hold flag + age);
204
289
  // the DELETE + flag-cleanup + quota-decrement run inside a backend
205
- // transaction so partial state can't survive a crash.
206
- var stmtSelectForExpunge = db.prepare(
207
- "SELECT objectid, folder_id, size_bytes, received_at, legal_hold FROM " + qMsgs +
208
- " WHERE folder_id = ? AND objectid IN (SELECT value FROM json_each(?))");
209
- var stmtDeleteMsg = db.prepare("DELETE FROM " + qMsgs + " WHERE objectid = ?");
210
- var stmtDeleteFlags = db.prepare("DELETE FROM " + qFlags + " WHERE objectid = ?");
290
+ // transaction so partial state can't survive a crash. The objectid set
291
+ // arrives as a JSON array string and is unrolled through the sqlite
292
+ // json_each table-valued function (whereInJsonEach) so the candidate
293
+ // list binds as a single `?` rather than a variable placeholder count.
294
+ var stmtSelectForExpunge = db.prepare(sql.select(messagesTable, SQL)
295
+ .columns(["objectid", "folder_id", "size_bytes", "received_at", "legal_hold"])
296
+ .where("folder_id", "?").whereInJsonEach("objectid", "?").toSql().sql);
297
+ var stmtDeleteMsg = db.prepare(sql.delete(messagesTable, SQL)
298
+ .where("objectid", "?").toSql().sql);
299
+ var stmtDeleteFlags = db.prepare(sql.delete(flagsTable, SQL)
300
+ .where("objectid", "?").toSql().sql);
211
301
  // Sealed-token FTS5 prepared statements — index sync runs in the
212
302
  // same transaction window as the canonical row mutation so a crash
213
303
  // between the two cannot leave the FTS index out of step with the
214
304
  // messages table. See lib/mail-store-fts.js for the tokenize +
215
305
  // vault-salted-hash transform applied here.
216
- var stmtInsertFts = db.prepare(
217
- "INSERT INTO " + qFts + " (objectid, subject_toks, addr_toks, body_toks) VALUES (?, ?, ?, ?)");
218
- var stmtDeleteFts = db.prepare("DELETE FROM " + qFts + " WHERE objectid = ?");
306
+ var stmtInsertFts = db.prepare(sql.insert(ftsTable, SQL)
307
+ .columns(["objectid", "subject_toks", "addr_toks", "body_toks"])
308
+ .values(["?", "?", "?", "?"]).toSql().sql);
309
+ var stmtDeleteFts = db.prepare(sql.delete(ftsTable, SQL)
310
+ .where("objectid", "?").toSql().sql);
219
311
 
220
312
  // `<prefix>_meta` marker read — used by the reindex gate at create()
221
313
  // AND by every FTS query path (search) to refuse a half-built index.
@@ -224,7 +316,8 @@ function create(opts) {
224
316
  // instead of throwing.
225
317
  function _readFtsMarker() {
226
318
  try {
227
- var row = db.prepare("SELECT value FROM " + qMeta + " WHERE key = ?")
319
+ var row = db.prepare(sql.select(metaTable, SQL)
320
+ .columns(["value"]).where("key", "?").toSql().sql)
228
321
  .get(FTS_FORMAT_META_KEY);
229
322
  return row ? row.value : null;
230
323
  } catch (_e) {
@@ -271,9 +364,9 @@ function create(opts) {
271
364
 
272
365
  // Count existing FTS rows AFTER the early-return so the common
273
366
  // already-current path pays nothing for the scan.
274
- var ftsCountRow = db.prepare("SELECT COUNT(*) AS n FROM " + qFts).get();
367
+ var ftsCountRow = db.prepare(sql.select(ftsTable, SQL).count("*", "n").toSql().sql).get();
275
368
  var ftsCount = (ftsCountRow && ftsCountRow.n) || 0;
276
- var msgCountRow = db.prepare("SELECT COUNT(*) AS n FROM " + qMsgs).get();
369
+ var msgCountRow = db.prepare(sql.select(messagesTable, SQL).count("*", "n").toSql().sql).get();
277
370
  var msgCount = (msgCountRow && msgCountRow.n) || 0;
278
371
 
279
372
  // Fresh store — no marker AND nothing indexed AND no messages to
@@ -310,8 +403,8 @@ function create(opts) {
310
403
  var allRows;
311
404
  db.prepare("BEGIN IMMEDIATE").run();
312
405
  try {
313
- allRows = db.prepare("SELECT * FROM " + qMsgs).all();
314
- db.prepare("DELETE FROM " + qFts).run();
406
+ allRows = db.prepare(sql.select(messagesTable, SQL).toSql().sql).all();
407
+ db.prepare(sql.delete(ftsTable, SQL).allowNoWhere().toSql().sql).run();
315
408
  for (var i = 0; i < allRows.length; i += 1) {
316
409
  // unsealRow returns the DB column names (subject / from_addr /
317
410
  // to_addrs / body_text); map them into the FTS param shape
@@ -348,10 +441,10 @@ function create(opts) {
348
441
  }
349
442
 
350
443
  function _writeFtsMarker(value) {
351
- db.prepare(
352
- "INSERT INTO " + qMeta + " (key, value) VALUES (?, ?) " +
353
- "ON CONFLICT(key) DO UPDATE SET value = excluded.value"
354
- ).run(FTS_FORMAT_META_KEY, value);
444
+ db.prepare(sql.upsert(metaTable, SQL)
445
+ .columns(["key", "value"]).values({ key: "?", value: "?" })
446
+ .onConflict(["key"]).doUpdateFromExcluded(["value"]).toSql().sql)
447
+ .run(FTS_FORMAT_META_KEY, value);
355
448
  }
356
449
 
357
450
  // Wire the reindex into create() AFTER schema bootstrap. A fresh store
@@ -370,7 +463,7 @@ function create(opts) {
370
463
  // transactions fall back to per-statement (the FTS insert is the
371
464
  // last write, so partial state == still consistent to the reader).
372
465
  var args = {
373
- db: db, qMsgs: qMsgs, qFlags: qFlags, messagesTable: messagesTable,
466
+ db: db, messagesTable: messagesTable,
374
467
  stmtInsertMsg: stmtInsertMsg,
375
468
  stmtInsertFts: stmtInsertFts,
376
469
  stmtBumpFolderModseq: stmtBumpFolderModseq,
@@ -391,7 +484,7 @@ function create(opts) {
391
484
  },
392
485
  fetchByObjectId: function (folderName, objectid) {
393
486
  return _fetchByObjectId({
394
- db: db, qMsgs: qMsgs, qFolders: qFolders, qFlags: qFlags, messagesTable: messagesTable,
487
+ db: db, messagesTable: messagesTable,
395
488
  stmtGetFolderByName: stmtGetFolderByName,
396
489
  stmtFetchMsg: stmtFetchMsg,
397
490
  stmtFlagsForMsg: stmtFlagsForMsg,
@@ -428,7 +521,12 @@ function create(opts) {
428
521
  }
429
522
  var f = filter || {};
430
523
  var sinceModseq = f.sinceModseq || 0;
431
- var limit = f.limit || 100;
524
+ // Normalize the row cap to a non-negative integer (default 100, hard
525
+ // cap 1000) before it reaches b.sql's limit() on the FTS path, which
526
+ // THROWS on a non-integer. Defensive-reader tier: a garbage operator
527
+ // value floors to the default page size.
528
+ var limit = Math.floor(Number(f.limit));
529
+ if (!isFinite(limit) || limit < 0) limit = 100;
432
530
  if (limit > 1000) limit = 1000; // query row cap, not bytes
433
531
 
434
532
  // The FTS index is queryable only when the on-disk format marker
@@ -439,7 +537,7 @@ function create(opts) {
439
537
  // matches as if it were complete is the corruption hazard the
440
538
  // reindex sentinel exists to prevent.
441
539
  if (!_ftsIndexUsable()) {
442
- var nonFinal = stmtQueryByModseq.all(folder.id, sinceModseq, limit);
540
+ var nonFinal = _queryByModseqRows(folder.id, sinceModseq, limit);
443
541
  return {
444
542
  rows: nonFinal.map(function (r) {
445
543
  return {
@@ -476,7 +574,7 @@ function create(opts) {
476
574
  }
477
575
 
478
576
  if (matchClauses.length === 0) {
479
- var fallback = stmtQueryByModseq.all(folder.id, sinceModseq, limit);
577
+ var fallback = _queryByModseqRows(folder.id, sinceModseq, limit);
480
578
  return {
481
579
  rows: fallback.map(function (r) {
482
580
  return {
@@ -491,14 +589,21 @@ function create(opts) {
491
589
  var matchExpr = matchClauses.join(" AND ");
492
590
  // FTS5 MATCH binds to the virtual-table name — aliases / joined-
493
591
  // table refs are parsed as ordinary column refs and fail. The
494
- // IN-subquery shape sidesteps that.
495
- var sql =
496
- "SELECT objectid, modseq, size_bytes, internal_date, legal_hold " +
497
- "FROM " + qMsgs + " " +
498
- "WHERE folder_id = ? AND modseq > ? " +
499
- "AND objectid IN (SELECT objectid FROM " + qFts + " WHERE " + qFts + " MATCH ?) " +
500
- "ORDER BY modseq ASC LIMIT ?";
501
- var rows = db.prepare(sql).all(folder.id, sinceModseq, matchExpr, limit);
592
+ // IN-subquery shape (whereIn(col, subBuilder)) sidesteps that: the
593
+ // sub selects objectid from the FTS5 table with `<fts> MATCH ?`
594
+ // (whereMatch, which targets the virtual-table identifier and
595
+ // bypasses the column gate). The MATCH operand stays a bound `?`, so
596
+ // the operator's tokenized-hashed query expression never reshapes the
597
+ // statement. Param bind order: folder_id, sinceModseq, then the
598
+ // sub-query's MATCH operand.
599
+ var ftsSub = sql.select(ftsTable, SQL)
600
+ .columns(["objectid"]).whereMatch(ftsTable, "?");
601
+ var matchStmt = db.prepare(sql.select(messagesTable, SQL)
602
+ .columns(["objectid", "modseq", "size_bytes", "internal_date", "legal_hold"])
603
+ .where("folder_id", "?").whereOp("modseq", ">", "?")
604
+ .whereIn("objectid", ftsSub)
605
+ .orderBy("modseq", "asc").limit(limit).toSql().sql);
606
+ var rows = matchStmt.all(folder.id, sinceModseq, matchExpr);
502
607
  return {
503
608
  rows: rows.map(function (r) {
504
609
  return {
@@ -518,7 +623,7 @@ function create(opts) {
518
623
  }
519
624
  var sinceModseq = (queryOpts && queryOpts.sinceModseq) || 0;
520
625
  var limit = (queryOpts && queryOpts.limit) || 1000; // query row cap, not bytes
521
- var rows = stmtQueryByModseq.all(folder.id, sinceModseq, limit);
626
+ var rows = _queryByModseqRows(folder.id, sinceModseq, limit);
522
627
  return rows.map(function (r) {
523
628
  return {
524
629
  objectid: r.objectid, modseq: r.modseq, sizeBytes: r.size_bytes,
@@ -528,7 +633,7 @@ function create(opts) {
528
633
  },
529
634
  setFlags: function (folderName, objectids, flagOpts) {
530
635
  return _setFlags({
531
- db: db, qMsgs: qMsgs,
636
+ db: db, messagesTable: messagesTable,
532
637
  stmtGetFolderByName: stmtGetFolderByName,
533
638
  stmtBumpFolderModseq: stmtBumpFolderModseq,
534
639
  stmtSetFlag: stmtSetFlag,
@@ -549,12 +654,13 @@ function create(opts) {
549
654
  var role = fo.role || null;
550
655
  var parentId = fo.parentId || null;
551
656
  var uidvalidity = Math.floor(Date.now() / 1000); // Unix timestamp, not bytes
552
- stmtInsertFolder.run(name, role, parentId, uidvalidity);
657
+ stmtInsertFolder.run(name, role, parentId, 0, uidvalidity); // modseq_max seeds to 0
553
658
  return stmtGetFolderByName.get(name);
554
659
  },
555
660
  listFolders: function () { return stmtListFolders.all(); },
556
661
  threadFor: function (objectid) {
557
- var msg = db.prepare("SELECT thread_root_id FROM " + qMsgs + " WHERE objectid = ?").get(objectid);
662
+ var msg = db.prepare(sql.select(messagesTable, SQL)
663
+ .columns(["thread_root_id"]).where("objectid", "?").toSql().sql).get(objectid);
558
664
  if (!msg) return [];
559
665
  return stmtThreadFor.all(msg.thread_root_id).map(function (r) { return r.objectid; });
560
666
  },
@@ -798,10 +904,10 @@ function _appendMessage(args) {
798
904
  sealed.received_at, sealed.size_bytes, sealed.message_id, sealed.message_id_hash,
799
905
  sealed.in_reply_to, sealed.references_csv, sealed.thread_root_id,
800
906
  sealed.subject, sealed.from_addr, sealed.from_hash, sealed.to_addrs,
801
- sealed.body_text, sealed.body_html
907
+ sealed.body_text, sealed.body_html, 0 // legal_hold seeds to 0
802
908
  );
803
909
  args.stmtBumpFolderModseq.run(modseq, args.folderName);
804
- args.stmtBumpQuota.run(folder.id, buf.length, 1);
910
+ args.stmtBumpQuota.run(folder.id, buf.length, 1, null, null); // cap_bytes / cap_count seed NULL on first insert
805
911
 
806
912
  // FTS index update — tokenize the PRE-seal plaintext, hash each
807
913
  // token with the per-deployment vault salt, insert into the FTS5
@@ -896,7 +1002,7 @@ function _moveMessages(args) {
896
1002
  // on append; move must keep both sides accurate.
897
1003
  if (changed > 0) {
898
1004
  args.stmtDecrementQuota.run(movedBytes, changed, fromFolder.id);
899
- args.stmtBumpQuota.run(toFolder.id, movedBytes, changed);
1005
+ args.stmtBumpQuota.run(toFolder.id, movedBytes, changed, null, null); // cap_bytes / cap_count seed NULL on first insert
900
1006
  }
901
1007
  args.stmtBumpFolderModseq.run(srcModseq, args.fromFolderName);
902
1008
  args.stmtBumpFolderModseq.run(dstModseq, args.toFolderName);
@@ -941,9 +1047,15 @@ function _setFlags(args) {
941
1047
  var CHUNK = 500; // IN-clause chunk size, not bytes
942
1048
  for (var i = 0; i < args.objectids.length; i += CHUNK) {
943
1049
  var chunk = args.objectids.slice(i, i + CHUNK);
944
- var placeholders = chunk.map(function () { return "?"; }).join(",");
945
- var sql = "UPDATE " + args.qMsgs + " SET modseq = ? WHERE objectid IN (" + placeholders + ")";
946
- var stmt = args.db.prepare(sql);
1050
+ // b.sql.update(...).whereIn(objectid, chunk) emits the per-chunk
1051
+ // `SET "modseq" = ? WHERE "objectid" IN (?, ?, ...)` text (the chunk
1052
+ // entries are `?` placeholders, bound positionally at run()); the
1053
+ // modseq `?` leads, so the bind list is [newModseq].concat(chunk).
1054
+ var stmtText = sql.update(args.messagesTable, { dialect: "sqlite", quoteName: true })
1055
+ .set("modseq", "?")
1056
+ .whereIn("objectid", chunk.map(function () { return "?"; }))
1057
+ .toSql().sql;
1058
+ var stmt = args.db.prepare(stmtText);
947
1059
  stmt.run.apply(stmt, [newModseq].concat(chunk));
948
1060
  }
949
1061
  }
@@ -969,7 +1081,13 @@ function _findThreadRoot(args) {
969
1081
  for (var c = 0; c < candidates.length; c += 1) {
970
1082
  var lookup = cryptoField.lookupHash(args.messagesTable, "message_id", candidates[c]);
971
1083
  if (!lookup) continue;
1084
+ // Dual-read across the keyed-MAC flip: try the active digest, then fall
1085
+ // back to the legacy salted-sha3 digest a pre-v0.15.0 row carries, so
1086
+ // thread-matching still finds messages indexed before the flip.
972
1087
  var row = args.stmtFindThreadByMsgId.get(lookup.value);
1088
+ if (!row && lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
1089
+ row = args.stmtFindThreadByMsgId.get(lookup.legacyValue);
1090
+ }
973
1091
  if (row) return row.thread_root_id;
974
1092
  }
975
1093
  return null;
@@ -1009,100 +1127,121 @@ function _normalizeMsgId(s) {
1009
1127
 
1010
1128
  // ---- Schema bootstrap ----------------------------------------------------
1011
1129
 
1012
- function _ensureSchema(db, qMsgs, qFolders, qFlags, qQuota, qFts, qMeta) {
1130
+ function _ensureSchema(db, tables) {
1131
+ // Every DDL statement is composed through b.sql with quoteName so the
1132
+ // prefixed table name emits as a quoted identifier against the concrete
1133
+ // sqlite handle (no clusterStorage rewrite — the store owns its backend).
1134
+ // The DDL builders return { sql } (DDL binds no values).
1135
+ var DDL = { dialect: "sqlite", quoteName: true };
1136
+ var foldersTable = tables.foldersTable;
1137
+ var messagesTable = tables.messagesTable;
1138
+ var flagsTable = tables.flagsTable;
1139
+ var quotaTable = tables.quotaTable;
1140
+ var ftsTable = tables.ftsTable;
1141
+ var metaTable = tables.metaTable;
1142
+
1143
+ function _ddl(built) { db.prepare(built.sql).run(); }
1144
+
1013
1145
  // Folders table — created first since messages reference folder_id.
1014
- db.prepare(
1015
- "CREATE TABLE IF NOT EXISTS " + qFolders + " (" +
1016
- "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
1017
- "name TEXT UNIQUE NOT NULL, " +
1018
- "role TEXT, " +
1019
- "parent_id INTEGER, " +
1020
- "modseq_max INTEGER NOT NULL DEFAULT 0, " +
1021
- "uidvalidity INTEGER NOT NULL)"
1022
- ).run();
1023
- db.prepare(
1024
- "CREATE INDEX IF NOT EXISTS " + safeSql.quoteIdentifier(qFolders.slice(1, -1) + "_role_idx", "sqlite") +
1025
- " ON " + qFolders + "(role)"
1026
- ).run();
1027
-
1028
- // Messages table sealed-by-default subject / from / to / body.
1029
- db.prepare(
1030
- "CREATE TABLE IF NOT EXISTS " + qMsgs + " (" +
1031
- "objectid TEXT PRIMARY KEY, " +
1032
- "folder_id INTEGER NOT NULL, " +
1033
- "modseq INTEGER NOT NULL, " +
1034
- "internal_date INTEGER NOT NULL, " +
1035
- "received_at INTEGER NOT NULL, " +
1036
- "size_bytes INTEGER NOT NULL, " +
1037
- "message_id TEXT, " +
1038
- "message_id_hash TEXT, " +
1039
- "in_reply_to TEXT, " +
1040
- "references_csv TEXT, " +
1041
- "thread_root_id TEXT NOT NULL, " +
1042
- "subject TEXT, " +
1043
- "from_addr TEXT, " +
1044
- "from_hash TEXT, " +
1045
- "to_addrs TEXT, " +
1046
- "body_text TEXT, " +
1047
- "body_html TEXT, " +
1048
- "legal_hold INTEGER NOT NULL DEFAULT 0, " +
1049
- "FOREIGN KEY(folder_id) REFERENCES " + qFolders + "(id))"
1050
- ).run();
1146
+ // `id` is an auto-increment PK (sqlite emits INTEGER PRIMARY KEY
1147
+ // AUTOINCREMENT, the rowid-backed identity column).
1148
+ _ddl(sql.createTable(foldersTable, [
1149
+ { name: "id", autoIncrement: true },
1150
+ { name: "name", type: "text", notNull: true, unique: true },
1151
+ { name: "role", type: "text" },
1152
+ { name: "parent_id", type: "int" },
1153
+ { name: "modseq_max", type: "int", notNull: true, default: 0 },
1154
+ { name: "uidvalidity", type: "int", notNull: true },
1155
+ ], DDL));
1156
+ _ddl(sql.createIndex(foldersTable + "_role_idx", foldersTable, ["role"], DDL));
1157
+
1158
+ // Messages table — sealed-by-default subject / from / to / body. The
1159
+ // folder_id FK inherits the quoteName so the referenced table resolves
1160
+ // to the same concrete identifier.
1161
+ _ddl(sql.createTable(messagesTable, [
1162
+ { name: "objectid", type: "text", primaryKey: true },
1163
+ { name: "folder_id", type: "int", notNull: true,
1164
+ references: { table: foldersTable, column: "id" } },
1165
+ { name: "modseq", type: "int", notNull: true },
1166
+ { name: "internal_date", type: "int", notNull: true },
1167
+ { name: "received_at", type: "int", notNull: true },
1168
+ { name: "size_bytes", type: "int", notNull: true },
1169
+ { name: "message_id", type: "text" },
1170
+ { name: "message_id_hash", type: "text" },
1171
+ { name: "in_reply_to", type: "text" },
1172
+ { name: "references_csv", type: "text" },
1173
+ { name: "thread_root_id", type: "text", notNull: true },
1174
+ { name: "subject", type: "text" },
1175
+ { name: "from_addr", type: "text" },
1176
+ { name: "from_hash", type: "text" },
1177
+ { name: "to_addrs", type: "text" },
1178
+ { name: "body_text", type: "text" },
1179
+ { name: "body_html", type: "text" },
1180
+ { name: "legal_hold", type: "int", notNull: true, default: 0 },
1181
+ ], DDL));
1051
1182
  // Indexes — modseq for CONDSTORE, thread_root_id for thread fetch,
1052
1183
  // message_id_hash for threading lookup, from_hash for sender search.
1053
1184
  ["modseq", "thread_root_id", "message_id_hash", "from_hash", "received_at", "legal_hold"]
1054
1185
  .forEach(function (col) {
1055
- db.prepare(
1056
- "CREATE INDEX IF NOT EXISTS " + safeSql.quoteIdentifier(qMsgs.slice(1, -1) + "_" + col + "_idx", "sqlite") +
1057
- " ON " + qMsgs + "(" + safeSql.quoteIdentifier(col, "sqlite") + ")"
1058
- ).run();
1186
+ _ddl(sql.createIndex(messagesTable + "_" + col + "_idx", messagesTable, [col], DDL));
1059
1187
  });
1060
1188
 
1061
- // Flags table — many-to-one with messages.
1062
- db.prepare(
1063
- "CREATE TABLE IF NOT EXISTS " + qFlags + " (" +
1064
- "objectid TEXT NOT NULL, " +
1065
- "flag TEXT NOT NULL, " +
1066
- "set_at INTEGER NOT NULL, " +
1067
- "PRIMARY KEY (objectid, flag), " +
1068
- "FOREIGN KEY(objectid) REFERENCES " + qMsgs + "(objectid) ON DELETE CASCADE)"
1069
- ).run();
1189
+ // Flags table — many-to-one with messages. Composite (objectid, flag) PK
1190
+ // + ON DELETE CASCADE FK back to the messages table.
1191
+ _ddl(sql.createTable(flagsTable, [
1192
+ { name: "objectid", type: "text", notNull: true,
1193
+ references: { table: messagesTable, column: "objectid", onDelete: "CASCADE" } },
1194
+ { name: "flag", type: "text", notNull: true },
1195
+ { name: "set_at", type: "int", notNull: true },
1196
+ ], Object.assign({ primaryKey: ["objectid", "flag"] }, DDL)));
1070
1197
 
1071
1198
  // Quota table — per-folder counters bumped atomically with append/delete.
1072
- db.prepare(
1073
- "CREATE TABLE IF NOT EXISTS " + qQuota + " (" +
1074
- "folder_id INTEGER PRIMARY KEY, " +
1075
- "used_bytes INTEGER NOT NULL DEFAULT 0, " +
1076
- "used_count INTEGER NOT NULL DEFAULT 0, " +
1077
- "cap_bytes INTEGER, " +
1078
- "cap_count INTEGER, " +
1079
- "FOREIGN KEY(folder_id) REFERENCES " + qFolders + "(id))"
1080
- ).run();
1199
+ _ddl(sql.createTable(quotaTable, [
1200
+ { name: "folder_id", type: "int", primaryKey: true,
1201
+ references: { table: foldersTable, column: "id" } },
1202
+ { name: "used_bytes", type: "int", notNull: true, default: 0 },
1203
+ { name: "used_count", type: "int", notNull: true, default: 0 },
1204
+ { name: "cap_bytes", type: "int" },
1205
+ { name: "cap_count", type: "int" },
1206
+ ], DDL));
1081
1207
 
1082
1208
  // Sealed-token FTS5 virtual table. The token-hash transform lives in
1083
1209
  // `lib/mail-store-fts.js`; this is the storage layer. Tokenizer is
1084
1210
  // `unicode61 remove_diacritics 2` so FTS5's segmenter splits hash-
1085
1211
  // tokens on whitespace exactly — hashes are ASCII-hex-only, so no
1086
- // Unicode case-fold runs at MATCH time.
1087
- db.prepare(mailStoreFts.createSql(qFts)).run();
1212
+ // Unicode case-fold runs at MATCH time. objectid is UNINDEXED (the join
1213
+ // key, stored but not searched).
1214
+ _ddl(sql.createVirtualTable(ftsTable, {
1215
+ columns: [
1216
+ { name: "objectid", unindexed: true },
1217
+ "subject_toks", "addr_toks", "body_toks",
1218
+ ],
1219
+ tokenize: "unicode61 remove_diacritics 2",
1220
+ }));
1088
1221
 
1089
1222
  // Per-prefix key/value metadata table. Holds the FTS on-disk format
1090
1223
  // marker (`fts_format`) so the reindex path can detect a stale index
1091
1224
  // and rebuild it. Scoped per table-prefix (NOT PRAGMA user_version,
1092
1225
  // which is db-global and would collide across stores sharing one
1093
1226
  // sqlite file).
1094
- db.prepare(
1095
- "CREATE TABLE IF NOT EXISTS " + qMeta + " (" +
1096
- "key TEXT PRIMARY KEY, " +
1097
- "value TEXT)"
1098
- ).run();
1227
+ _ddl(sql.createTable(metaTable, [
1228
+ { name: "key", type: "text", primaryKey: true },
1229
+ { name: "value", type: "text" },
1230
+ ], DDL));
1099
1231
  }
1100
1232
 
1101
- function _ensureDefaultFolders(db, qFolders) {
1102
- var stmt = db.prepare("INSERT OR IGNORE INTO " + qFolders +
1103
- " (name, role, parent_id, modseq_max, uidvalidity) VALUES (?, ?, NULL, 0, ?)");
1233
+ function _ensureDefaultFolders(db, foldersTable) {
1234
+ // INSERT OR IGNORE composed as ON CONFLICT(name) DO NOTHING (the
1235
+ // folders UNIQUE name) so re-bootstrapping an existing store is a no-op.
1236
+ // Every column is a `?` placeholder bound positionally at run() (the
1237
+ // builder emits one `?` per value; parent_id binds NULL, modseq_max 0).
1238
+ var stmtText = sql.upsert(foldersTable, { dialect: "sqlite", quoteName: true })
1239
+ .columns(["name", "role", "parent_id", "modseq_max", "uidvalidity"])
1240
+ .values({ name: "?", role: "?", parent_id: "?", modseq_max: "?", uidvalidity: "?" })
1241
+ .onConflict(["name"]).doNothing().toSql().sql;
1242
+ var stmt = db.prepare(stmtText);
1104
1243
  var uv = Math.floor(Date.now() / 1000); // Unix timestamp, not bytes
1105
- DEFAULT_FOLDERS.forEach(function (f) { stmt.run(f.name, f.role, uv); });
1244
+ DEFAULT_FOLDERS.forEach(function (f) { stmt.run(f.name, f.role, null, 0, uv); });
1106
1245
  }
1107
1246
 
1108
1247
  module.exports = {