@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/db-query.js CHANGED
@@ -17,6 +17,20 @@
17
17
  * updateOne(changes), updateMany(changes),
18
18
  * deleteOne(), deleteMany().
19
19
  *
20
+ * SQL construction composes b.sql (lib/sql.js): every terminal builds a
21
+ * b.sql verb builder ({ dialect: "sqlite" }, the local node:sqlite
22
+ * backend), replays the recorded structured WHERE conditions onto it, and
23
+ * calls .toSql() for the { sql, params } pair — which db-query then
24
+ * prepares + runs on the local sqlite handle. b.sql owns identifier
25
+ * quoting (through b.safeSql), value binding (every value a `?`
26
+ * placeholder), IN-list expansion, LIKE auto-escape, and the output
27
+ * validator (_assertEmittable). db-query keeps everything b.sql cannot
28
+ * know about: the residency write-gate, sealed-row seal/unseal, _id
29
+ * auto-generation, per-row-key materialization, the column-membership
30
+ * gate, sealed-field → derived-hash translation, and the JSONB/JSON-path
31
+ * value guard — all applied at condition-record / row-build time, before
32
+ * the structured shape reaches b.sql.
33
+ *
20
34
  * Sealed-field semantics:
21
35
  * - On insert/update, sealed columns are vault.seal()'d and their derived
22
36
  * hashes computed automatically.
@@ -33,6 +47,7 @@ var { generateToken } = require("./crypto");
33
47
  var safeJson = require("./safe-json");
34
48
  var safeJsonPath = require("./safe-jsonpath");
35
49
  var safeSql = require("./safe-sql");
50
+ var sql = require("./sql");
36
51
  var audit = require("./audit");
37
52
  var lazyRequire = require("./lazy-require");
38
53
  var { DbQueryError } = require("./framework-error");
@@ -195,8 +210,15 @@ class Query {
195
210
  this._schema = schema;
196
211
  this._table = table;
197
212
  this._qualifiedKey = schema ? schema + "." + table : table;
198
- this._where = [];
199
- this._whereParams = [];
213
+ // Recorded WHERE chain — an ordered list of leaves. Each leaf is
214
+ // { joiner, apply(predicate) } where apply() replays the leaf onto a
215
+ // b.sql Predicate (or builder) using its where-family methods. The
216
+ // sealed-field translation, JSONB value guard, and column-membership
217
+ // gate run at record time (in _addCondition / whereRaw / search /
218
+ // whereGroup / orWhere), so the recorded shape is already safe; the
219
+ // terminal just replays it through b.sql, which owns quoting +
220
+ // binding + the output validator.
221
+ this._conditions = [];
200
222
  this._select = null;
201
223
  this._orderBy = null;
202
224
  this._limit = null;
@@ -215,6 +237,16 @@ class Query {
215
237
  : (Array.isArray(opts.declaredColumns) ? new Set(opts.declaredColumns) : null);
216
238
  this._columnGateMode = opts.columnGateMode || "reject";
217
239
  this._allowedColumns = null;
240
+ // PRIMARY KEY column for the dialect-aware single-row write idiom on
241
+ // non-sqlite handles (sqlite uses the implicit rowid). db.from() tables
242
+ // key on `_id`; a table with a different PK declares it here. Validated
243
+ // as an identifier so it can splice into SQL as a quoted column.
244
+ if (opts.primaryKey !== undefined && opts.primaryKey !== null) {
245
+ safeSql.validateIdentifier(opts.primaryKey, { allowReserved: true });
246
+ this._primaryKey = opts.primaryKey;
247
+ } else {
248
+ this._primaryKey = null;
249
+ }
218
250
  }
219
251
 
220
252
  // Restrict the operator-allowable columns to an explicit subset
@@ -259,11 +291,55 @@ class Query {
259
291
  ". Use .allowedColumns([...]) or db.init({ columnGate: 'off' }) to bypass.");
260
292
  }
261
293
 
262
- // Quoted SQL form: `"schema"."table"` if schema-qualified, else `"table"`.
263
- _quotedTable() {
294
+ // Resolve the SQL dialect for the handle this Query runs against.
295
+ // db.from() drives the framework's local node:sqlite handle (dialect
296
+ // "sqlite", the default). An operator who constructs `new Query(handle,
297
+ // table)` over their OWN Postgres / MySQL handle declares the dialect on
298
+ // the handle via `handle.dialect` ("postgres" | "mysql"), so b.sql emits
299
+ // the matching identifier quoting + single-row-write idiom. An unknown /
300
+ // absent value falls back to "sqlite" — the historical default — so every
301
+ // existing caller is byte-identical.
302
+ _dialect() {
303
+ var d = this._db && this._db.dialect;
304
+ if (d === "postgres" || d === "mysql" || d === "sqlite") return d;
305
+ return "sqlite";
306
+ }
307
+
308
+ // The b.sql opts for every terminal's verb builder. The dialect is
309
+ // resolved from the handle (sqlite by default; the operator's external
310
+ // handle can declare postgres / mysql). quoteName forces b.sql to QUOTE
311
+ // the resolved table name: db-query does NO clusterStorage prefix rewrite,
312
+ // so it never needs the bare-unquoted form — and quoting preserves
313
+ // db-query's reserved-word / case-sensitive table-name support (`"name"`
314
+ // is the safe identifier form). The schema qualifier (when present) makes
315
+ // b.sql emit the quoted `"schema"."table"` form. db-query owns the column
316
+ // gate (sealed-field rewrite happens before b.sql sees a column), so the
317
+ // builder's own gate stays off.
318
+ _sqlOpts() {
264
319
  return this._schema
265
- ? '"' + this._schema + '"."' + this._table + '"'
266
- : '"' + this._table + '"';
320
+ ? { dialect: this._dialect(), schema: this._schema, quoteName: true }
321
+ : { dialect: this._dialect(), quoteName: true };
322
+ }
323
+
324
+ // Whether any WHERE condition has been recorded — drives the
325
+ // unconditional-update / -delete / -increment refusals.
326
+ _hasConditions() {
327
+ return this._conditions.length > 0;
328
+ }
329
+
330
+ // Replay the recorded WHERE chain onto a b.sql verb builder. The whole
331
+ // chain is wrapped in one b.sql whereGroup so the leaves' AND/OR
332
+ // joiners compose at a single precedence level (and a no-condition
333
+ // chain leaves the builder's where untouched). Returns the builder.
334
+ _applyConditions(builder) {
335
+ if (this._conditions.length === 0) return builder;
336
+ var conds = this._conditions;
337
+ builder.whereGroup(function (pred) {
338
+ for (var i = 0; i < conds.length; i++) {
339
+ conds[i].apply(pred);
340
+ }
341
+ });
342
+ return builder;
267
343
  }
268
344
 
269
345
  // ---- Chainable filters ----
@@ -283,7 +359,20 @@ class Query {
283
359
  return this._addCondition(fieldOrObj, op, value);
284
360
  }
285
361
 
286
- _addCondition(field, op, value) {
362
+ // whereIn(field, values) — AND an `IN (...)` membership predicate. Facade
363
+ // over where(field, "IN", values) symmetric with b.sql's whereIn, so a
364
+ // caller can match a column against a value list (e.g. the dual-read
365
+ // derived-hash candidate set) without spelling the "IN" operator.
366
+ whereIn(field, values) {
367
+ return this.where(field, "IN", values);
368
+ }
369
+
370
+ // Resolve a (field, op, value) predicate through the framework gates
371
+ // (JSONB value guard, sealed-field → derived-hash rewrite, column
372
+ // membership) and return the post-rewrite { field, op, value } that
373
+ // b.sql will emit. Shared by _addCondition and the WhereBuilder so the
374
+ // gates run identically whether the leaf is top-level or grouped.
375
+ _resolvePredicate(field, op, value) {
287
376
  if (!ALLOWED_OPS.has(op)) {
288
377
  throw new Error("invalid where operator: " + op);
289
378
  }
@@ -341,45 +430,67 @@ class Query {
341
430
  );
342
431
  }
343
432
  field = lookup.field;
344
- value = lookup.value;
433
+ if (op === "=" && lookup.legacyValue != null && lookup.legacyValue !== lookup.value) {
434
+ // Dual-read across the v0.15.0 keyed-MAC default flip: a row written
435
+ // before the flip carries the legacy salted-sha3 digest, so an
436
+ // equality lookup on a sealed field must match BOTH the active
437
+ // keyed-MAC digest and the legacy one — otherwise the flip silently
438
+ // drops every un-migrated row from the result. b.sql expands the
439
+ // IN-list to (?, ?) and binds each digest.
440
+ op = "IN";
441
+ value = [lookup.value, lookup.legacyValue];
442
+ } else {
443
+ value = lookup.value;
444
+ }
345
445
  }
346
- cryptoField && _validateField(field);
446
+ _validateField(field);
347
447
  // Gate the post-sealed-rewrite physical column (derived-hash
348
448
  // columns are declared physical columns, so the rewrite target
349
449
  // passes membership).
350
450
  this._assertColumnMember(field, "where");
351
451
  if (op === "IN") {
352
- // node:sqlite ? does not support array-binding. Pre-v0.8.18
353
- // `where(field, "IN", [1,2,3])` silently bound the entire
354
- // array to a single placeholder and matched zero rows.
355
- // Expand to (?, ?, ?) and push each value separately.
452
+ // node:sqlite ? does not support array-binding; b.sql expands the
453
+ // IN-list to (?, ?, ?) and binds each element. Validate the shape
454
+ // here so the failure is db-query's clear message, not a builder
455
+ // error deeper in the stack.
356
456
  if (!Array.isArray(value) || value.length === 0) {
357
457
  throw new Error("where IN requires a non-empty array of values");
358
458
  }
359
- var placeholders = value.map(function () { return "?"; }).join(", ");
360
- this._where.push('"' + field + '" IN (' + placeholders + ")");
361
- for (var i = 0; i < value.length; i += 1) this._whereParams.push(value[i]);
362
- return this;
363
459
  }
364
- if (op === "LIKE" && typeof value === "string") {
365
- // Escape SQL LIKE metacharacters % and _ in operator-supplied
366
- // input. Without this, a single `%` in untrusted input becomes
367
- // a wildcard that matches everything a column-disclosure
368
- // class (`q=%@%` enumerates entire table). Use a backslash as
369
- // the escape character (uniform across SQLite + Postgres) and
370
- // emit the corresponding ESCAPE clause so the engine treats it
371
- // as the escape token. Operators who deliberately want LIKE
372
- // wildcards in their value bypass via whereRaw().
373
- var escaped = value.replace(/[\\%_]/g, "\\$&");
374
- this._where.push('"' + field + '" LIKE ? ESCAPE ' + "'\\\\'");
375
- this._whereParams.push(escaped);
376
- return this;
460
+ return { field: field, op: op, value: value };
461
+ }
462
+
463
+ // Apply a resolved predicate onto a b.sql Predicate using the given
464
+ // joiner ("AND" via where* / "OR" via orWhere*). LIKE auto-escape,
465
+ // IN-list expansion, IS NULL, and JSONB emission are all owned by
466
+ // b.sql's _cmp from here.
467
+ _emitPredicate(pred, joiner, field, op, value) {
468
+ if (op === "IN") {
469
+ if (joiner === "OR") pred.orWhereIn(field, value);
470
+ else pred.whereIn(field, value);
471
+ return;
377
472
  }
378
- this._where.push('"' + field + '" ' + op + " ?");
379
- this._whereParams.push(value);
473
+ if (joiner === "OR") pred.orWhereOp(field, op, value);
474
+ else pred.whereOp(field, op, value);
475
+ }
476
+
477
+ _addCondition(field, op, value) {
478
+ var resolved = this._resolvePredicate(field, op, value);
479
+ var self = this;
480
+ this._pushLeaf("AND", function (pred) {
481
+ self._emitPredicate(pred, "AND", resolved.field, resolved.op, resolved.value);
482
+ });
380
483
  return this;
381
484
  }
382
485
 
486
+ // Append a WHERE leaf. `apply(pred)` replays it onto a b.sql Predicate
487
+ // (AND-joined at the chain level — the leaf's own apply decides AND vs
488
+ // OR internally). orWhere() rewrites the last leaf rather than
489
+ // appending, to preserve `(prev OR new)` grouping precedence.
490
+ _pushLeaf(joiner, apply) {
491
+ this._conditions.push({ joiner: joiner, apply: apply });
492
+ }
493
+
383
494
  _isSealedField(field) {
384
495
  var sealed = cryptoField.getSealedFields(this._cryptoFieldKey());
385
496
  return sealed.indexOf(field) !== -1;
@@ -400,31 +511,35 @@ class Query {
400
511
 
401
512
  // whereRaw — append a parenthesized raw SQL fragment with positional
402
513
  // placeholders and the parameter values that fill them. Composes with
403
- // .where() (AND-joined via the same `_where` array). The fragment
404
- // must NOT contain operator-supplied SQL — it's caller-controlled
405
- // text used to build expressions the chainable .where() can't express
406
- // (compound OR, row-value comparison for cursor pagination, etc.).
407
- // Placeholder count must match params.length.
408
- whereRaw(sql, params, opts) {
409
- if (typeof sql !== "string" || sql.length === 0) {
514
+ // .where() (AND-joined). The fragment must NOT contain operator-
515
+ // supplied SQL — it's caller-controlled text used to build expressions
516
+ // the chainable .where() can't express (compound OR, row-value
517
+ // comparison for cursor pagination, etc.). b.sql's whereRaw guards the
518
+ // fragment (b.guardSql + embedded-literal + placeholder-count); the
519
+ // count + literal validation that db-query historically did inline now
520
+ // lives in that one choke-point.
521
+ whereRaw(sql_, params, opts) {
522
+ if (typeof sql_ !== "string" || sql_.length === 0) {
410
523
  throw new Error("whereRaw: sql must be a non-empty string");
411
524
  }
412
- if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql, "whereRaw");
413
- var p = Array.isArray(params) ? params : (params == null ? [] : [params]);
414
- // Count `?` placeholders, but skip occurrences inside string
415
- // literals ('...' or "..."), line comments (-- to EOL), and
416
- // block comments (/* ... */). Pre-v0.8.18 the naive regex
417
- // counted `?` inside literals (e.g. `WHERE name = 'a?b' AND id
418
- // = ?`) which caused mismatched-count errors OR — worse — let
419
- // through fragments where the literal-`?` placebo masked a
420
- // missed real placeholder.
421
- var holders = _countPlaceholders(sql);
525
+ var p = Array.isArray(params) ? params.slice() : (params == null ? [] : [params]);
526
+ // Fail-fast at the chain-build boundary (matching the pre-b.sql
527
+ // contract the operator catches a bad fragment at the whereRaw call,
528
+ // not deep inside a terminal). The embedded-literal + placeholder-count
529
+ // refusals keep db-query's stable SafeSqlError `sql/raw-literal` /
530
+ // explicit count-mismatch contract; b.sql's whereRaw (applied at the
531
+ // terminal) is the additional emission-time guard (b.guardSql, stacked-
532
+ // statement, encoding). allowLiterals opts the operator out of the
533
+ // literal refusal for a static, operator-controlled literal.
534
+ if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql_, "whereRaw");
535
+ var holders = safeSql.countPlaceholders(sql_);
422
536
  if (holders !== p.length) {
423
537
  throw new Error("whereRaw: " + holders + " placeholder(s) in sql but " +
424
538
  p.length + " param(s) supplied");
425
539
  }
426
- this._where.push("(" + sql + ")");
427
- for (var i = 0; i < p.length; i++) this._whereParams.push(p[i]);
540
+ this._pushLeaf("AND", function (pred) {
541
+ pred.whereRaw(sql_, p, opts);
542
+ });
428
543
  return this;
429
544
  }
430
545
 
@@ -476,49 +591,46 @@ class Query {
476
591
  return this;
477
592
  }
478
593
 
479
- // ---- Build SELECT components ----
480
-
481
- _whereClause() {
482
- return this._where.length === 0 ? "" : " WHERE " + this._where.join(" AND ");
483
- }
594
+ // ---- Build SELECT components on a b.sql builder ----
484
595
 
485
- _orderLimitOffset() {
486
- var s = "";
596
+ // Apply the recorded projection / order / limit / offset onto a b.sql
597
+ // SELECT builder. Projection columns + orderBy fields already passed
598
+ // _validateField + the column gate at record time.
599
+ _applySelectClauses(qb) {
600
+ if (this._select) qb.columns(this._select);
487
601
  if (this._orderBy) {
488
602
  var entries = Array.isArray(this._orderBy) ? this._orderBy : [this._orderBy];
489
- var fragments = [];
490
603
  for (var i = 0; i < entries.length; i++) {
491
- fragments.push('"' + entries[i].field + '" ' + entries[i].direction);
604
+ qb.orderBy(entries[i].field, entries[i].direction === "DESC" ? "desc" : "asc");
492
605
  }
493
- s += " ORDER BY " + fragments.join(", ");
494
606
  }
495
- if (this._limit !== null) s += " LIMIT " + this._limit;
496
- if (this._offset !== null) s += " OFFSET " + this._offset;
497
- return s;
498
- }
499
-
500
- _projection() {
501
- if (!this._select) return "*";
502
- return this._select.map(function (c) { return '"' + c + '"'; }).join(", ");
607
+ if (this._limit !== null) qb.limit(this._limit);
608
+ if (this._offset !== null) qb.offset(this._offset);
609
+ return qb;
503
610
  }
504
611
 
505
612
  // ---- Terminal methods (sync) ----
506
613
 
507
614
  first() {
508
- var sql = "SELECT " + this._projection() + " FROM " + this._quotedTable() +
509
- this._whereClause() + this._orderLimitOffset() + " LIMIT 1";
510
- var stmt = this._db.prepare(sql);
511
- var row = stmt.get.apply(stmt, this._whereParams);
615
+ var qb = sql.select(this._table, this._sqlOpts());
616
+ this._applyConditions(qb);
617
+ this._applySelectClauses(qb);
618
+ qb.limit(1);
619
+ var built = qb.toSql();
620
+ var stmt = this._db.prepare(built.sql);
621
+ var row = stmt.get.apply(stmt, built.params);
512
622
  // 4th arg (dbHandle) lets unsealRow fetch + unwrap the row-scoped
513
623
  // K_row for vault.row: cells (declarePerRowKey tables).
514
624
  return row ? cryptoField.unsealRow(this._cryptoFieldKey(), row, undefined, this._db) : null;
515
625
  }
516
626
 
517
627
  all() {
518
- var sql = "SELECT " + this._projection() + " FROM " + this._quotedTable() +
519
- this._whereClause() + this._orderLimitOffset();
520
- var stmt = this._db.prepare(sql);
521
- var rows = stmt.all.apply(stmt, this._whereParams);
628
+ var qb = sql.select(this._table, this._sqlOpts());
629
+ this._applyConditions(qb);
630
+ this._applySelectClauses(qb);
631
+ var built = qb.toSql();
632
+ var stmt = this._db.prepare(built.sql);
633
+ var rows = stmt.all.apply(stmt, built.params);
522
634
  var out = new Array(rows.length);
523
635
  var key = this._cryptoFieldKey();
524
636
  var dbHandle = this._db;
@@ -535,8 +647,10 @@ class Query {
535
647
  // StreamLimit ceiling enforced from the module-level db
536
648
  // config; per-call opts.streamLimit overrides for one-off bumps.
537
649
  stream(opts) {
538
- var sql = "SELECT " + this._projection() + " FROM " + this._quotedTable() +
539
- this._whereClause() + this._orderLimitOffset();
650
+ var qb = sql.select(this._table, this._sqlOpts());
651
+ this._applyConditions(qb);
652
+ this._applySelectClauses(qb);
653
+ var built = qb.toSql();
540
654
  var perCallLimit;
541
655
  // db.js exports getStreamLimit so this module reads the live
542
656
  // ceiling without bouncing through the lib's circular load.
@@ -550,11 +664,11 @@ class Query {
550
664
  }
551
665
  perCallLimit = opts.streamLimit;
552
666
  }
553
- var stmt = this._db.prepare(sql);
667
+ var stmt = this._db.prepare(built.sql);
554
668
  var key = this._cryptoFieldKey();
555
669
  var dbHandle = this._db;
556
670
  var iter;
557
- try { iter = stmt.iterate.apply(stmt, this._whereParams); }
671
+ try { iter = stmt.iterate.apply(stmt, built.params); }
558
672
  catch (e) {
559
673
  var r = new Readable({ objectMode: true, read: function () {} });
560
674
  setImmediate(function () { r.destroy(e); });
@@ -583,9 +697,11 @@ class Query {
583
697
  }
584
698
 
585
699
  count() {
586
- var sql = "SELECT COUNT(*) AS n FROM " + this._quotedTable() + this._whereClause();
587
- var stmt = this._db.prepare(sql);
588
- var row = stmt.get.apply(stmt, this._whereParams);
700
+ var qb = sql.select(this._table, this._sqlOpts()).count("*", "n");
701
+ this._applyConditions(qb);
702
+ var built = qb.toSql();
703
+ var stmt = this._db.prepare(built.sql);
704
+ var row = stmt.get.apply(stmt, built.params);
589
705
  return row ? row.n : 0;
590
706
  }
591
707
 
@@ -605,7 +721,7 @@ class Query {
605
721
  // (vault.row: cells). rowId MUST be withId._id — the same value
606
722
  // b.subject.eraseHard / b.retention destroy on, so a later shred
607
723
  // makes these cells undecryptable. Materialize stores the random
608
- // row-secret AAD-sealed in _blamejs_per_row_keys.
724
+ // row-secret AAD-sealed in the per-row-key store.
609
725
  var sealOpts;
610
726
  var cfKey = this._cryptoFieldKey();
611
727
  if (cryptoField.hasPerRowKey(cfKey)) {
@@ -613,13 +729,9 @@ class Query {
613
729
  sealOpts = { kRow: kRow, rowId: withId._id };
614
730
  }
615
731
  var sealed = cryptoField.sealRow(cfKey, withId, sealOpts);
616
- var cols = Object.keys(sealed);
617
- var placeholders = cols.map(function () { return "?"; }).join(", ");
618
- var quotedCols = cols.map(function (c) { return '"' + c + '"'; }).join(", ");
619
- var values = cols.map(function (c) { return sealed[c]; });
620
- var sql = "INSERT INTO " + this._quotedTable() + " (" + quotedCols + ") VALUES (" + placeholders + ")";
621
- var insertStmt = this._db.prepare(sql);
622
- insertStmt.run.apply(insertStmt, values);
732
+ var built = sql.insert(this._table, this._sqlOpts()).values(sealed).toSql();
733
+ var insertStmt = this._db.prepare(built.sql);
734
+ insertStmt.run.apply(insertStmt, built.params);
623
735
  // Return the original row with _id filled in (plaintext, never sealed)
624
736
  return Object.assign({}, withId);
625
737
  }
@@ -646,7 +758,7 @@ class Query {
646
758
  if (!changes || typeof changes !== "object") {
647
759
  throw new Error("update requires a changes object");
648
760
  }
649
- if (this._where.length === 0) {
761
+ if (!this._hasConditions()) {
650
762
  throw new Error("refusing unconditional update — call where(...) first");
651
763
  }
652
764
  // Residency gates on the plaintext change set — an UPDATE that
@@ -670,27 +782,106 @@ class Query {
670
782
  setKeys.forEach(_validateField);
671
783
  var selfUpd = this;
672
784
  setKeys.forEach(function (k) { selfUpd._assertColumnMember(k, "update"); });
673
- var setClause = setKeys.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
674
- var setValues = setKeys.map(function (k) { return sealed[k]; });
675
785
 
676
- var whereSql = this._where.join(" AND ");
677
- var limit = single ? " LIMIT 1" : "";
678
- // SQLite supports LIMIT on UPDATE only when compiled with SQLITE_ENABLE_UPDATE_DELETE_LIMIT.
679
- // node:sqlite ships without that flag emulate single-row with a sub-select on rowid.
680
- var sql;
681
- var qt = this._quotedTable();
786
+ // No engine ships a portable UPDATE ... LIMIT, so a single-row update
787
+ // resolves exactly one row then writes it. The shape is dialect-aware
788
+ // (sqlite rowid sub-select / postgres PK sub-select / mysql
789
+ // resolve-then-write_buildSingleRowWrite). A null result means the
790
+ // WHERE matched no row, so there is nothing to update (0 changes).
791
+ var built;
682
792
  if (single) {
683
- sql = "UPDATE " + qt + " SET " + setClause +
684
- " WHERE rowid = (SELECT rowid FROM " + qt + " WHERE " + whereSql + " LIMIT 1)";
793
+ built = this._buildSingleRowWrite(sealed);
794
+ if (built === null) return 0;
685
795
  } else {
686
- sql = "UPDATE " + qt + " SET " + setClause + " WHERE " + whereSql + limit;
796
+ var qb = sql.update(this._table, this._sqlOpts()).set(sealed);
797
+ this._applyConditions(qb);
798
+ built = qb.toSql();
687
799
  }
688
- var allParams = setValues.concat(this._whereParams);
689
- var updStmt = this._db.prepare(sql);
690
- var info = updStmt.run.apply(updStmt, allParams);
800
+ var updStmt = this._db.prepare(built.sql);
801
+ var info = updStmt.run.apply(updStmt, built.params);
691
802
  return info.changes;
692
803
  }
693
804
 
805
+ // The single-row-write row locator, by dialect. No engine ships
806
+ // UPDATE ... LIMIT portably (node:sqlite is built without
807
+ // SQLITE_ENABLE_UPDATE_DELETE_LIMIT), so the single-row idiom is a
808
+ // sub-SELECT that resolves exactly one row then matches it:
809
+ //
810
+ // sqlite — the implicit `rowid` system column (every non-WITHOUT-
811
+ // ROWID table has one); `WHERE "rowid" = (SELECT "rowid"
812
+ // FROM t WHERE ... LIMIT 1)`.
813
+ // postgres — the table's PRIMARY KEY (`_id`, the db.from() convention).
814
+ // Postgres accepts LIMIT in a scalar subquery, so the same
815
+ // `= (SELECT "_id" ... LIMIT 1)` shape works — and using the
816
+ // real, UNIQUE `_id` column keeps b.sql's quote-by-
817
+ // construction intact (ctid is an unquotable system column
818
+ // that would force a raw-identifier escape and is unstable
819
+ // across VACUUM).
820
+ // mysql — also the PRIMARY KEY, but MySQL refuses LIMIT in a
821
+ // subquery that directly references the same table in an
822
+ // `IN`/`=` predicate; wrapping the inner SELECT in a derived
823
+ // table (`... IN (SELECT "_id" FROM (SELECT "_id" ... LIMIT
824
+ // 1) AS _s)`) is the standard work-around.
825
+ //
826
+ // The inner SELECT is composed through b.sql (same table + conditions)
827
+ // and spliced via whereSub — passing the inner BUILDER (not concatenated
828
+ // SQL) so b.sql concatenates the sub-query's sql + params itself and the
829
+ // final statement still runs through b.sql's output validator.
830
+ _rowLocatorColumn(dialect) {
831
+ return dialect === "sqlite" ? "rowid" : this._pkColumn();
832
+ }
833
+
834
+ // The PRIMARY KEY column for single-row writes on non-sqlite dialects.
835
+ // db.from() tables key on `_id` (auto-generated when absent on insert);
836
+ // an operator running a table with a different PK overrides it via the
837
+ // `primaryKey` construction opt.
838
+ _pkColumn() {
839
+ return this._primaryKey || "_id";
840
+ }
841
+
842
+ _buildSingleRowWrite(sealed) {
843
+ if (this._dialect() === "mysql") {
844
+ // MySQL forbids referencing the UPDATE/DELETE target table in a
845
+ // subquery (error 1093), so the single-statement sub-SELECT idiom
846
+ // the other dialects use is unavailable. Resolve the one row's PK in
847
+ // a prior SELECT, then write `WHERE pk = ?` with the resolved value
848
+ // bound — every value still binds, the identifier still quotes by
849
+ // construction, and the write is a single validated statement with no
850
+ // self-referential subquery. Returns null when no row matched.
851
+ var pkVal = this._resolveSinglePk();
852
+ if (pkVal === null) return null;
853
+ return sql.update(this._table, this._sqlOpts())
854
+ .set(sealed)
855
+ .where(this._pkColumn(), pkVal)
856
+ .toSql();
857
+ }
858
+ var col = this._rowLocatorColumn(this._dialect());
859
+ var inner = sql.select(this._table, this._sqlOpts()).columns([col]);
860
+ this._applyConditions(inner);
861
+ inner.limit(1);
862
+ return sql.update(this._table, this._sqlOpts())
863
+ .set(sealed)
864
+ .whereSub(col, "=", inner)
865
+ .toSql();
866
+ }
867
+
868
+ // Resolve the PK of exactly one row matching the recorded WHERE (LIMIT
869
+ // 1). Used by the MySQL single-row write path, where a self-referential
870
+ // subquery is rejected by the engine. The SELECT is a clean, fully-bound
871
+ // b.sql statement; returns the PK value, or null when nothing matched.
872
+ _resolveSinglePk() {
873
+ var pk = this._pkColumn();
874
+ var pick = sql.select(this._table, this._sqlOpts()).columns([pk]);
875
+ this._applyConditions(pick);
876
+ pick.limit(1);
877
+ var built = pick.toSql();
878
+ var stmt = this._db.prepare(built.sql);
879
+ var row = stmt.get.apply(stmt, built.params);
880
+ if (!row) return null;
881
+ var v = row[pk];
882
+ return (v === undefined || v === null) ? null : v;
883
+ }
884
+
694
885
  // Per-row-key UPDATE. Sealed columns on a declarePerRowKey table are
695
886
  // K_row cells (vault.row:), so each affected row must be re-sealed
696
887
  // under its OWN K_row — a single set-based UPDATE can't carry per-row
@@ -699,11 +890,12 @@ class Query {
699
890
  // under it (derived hashes computed from plaintext as usual), and
700
891
  // UPDATE that single row by _id. `single` stops after the first row.
701
892
  _updatePerRowKey(cfKey, changes, single) {
702
- var whereSql = this._where.join(" AND ");
703
- var qt = this._quotedTable();
704
- var idStmt = this._db.prepare(
705
- "SELECT _id FROM " + qt + " WHERE " + whereSql + (single ? " LIMIT 1" : ""));
706
- var idRows = idStmt.all.apply(idStmt, this._whereParams);
893
+ var idSelect = sql.select(this._table, this._sqlOpts()).columns(["_id"]);
894
+ this._applyConditions(idSelect);
895
+ if (single) idSelect.limit(1);
896
+ var idBuilt = idSelect.toSql();
897
+ var idStmt = this._db.prepare(idBuilt.sql);
898
+ var idRows = idStmt.all.apply(idStmt, idBuilt.params);
707
899
  var changed = 0;
708
900
  for (var r = 0; r < idRows.length; r++) {
709
901
  var rowId = idRows[r]._id;
@@ -717,10 +909,10 @@ class Query {
717
909
  setKeys.forEach(_validateField);
718
910
  var selfUpd = this;
719
911
  setKeys.forEach(function (k) { selfUpd._assertColumnMember(k, "update"); });
720
- var setClause = setKeys.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
721
- var setValues = setKeys.map(function (k) { return sealed[k]; });
722
- var updStmt = this._db.prepare("UPDATE " + qt + " SET " + setClause + " WHERE _id = ?");
723
- var info = updStmt.run.apply(updStmt, setValues.concat([rowId]));
912
+ var built = sql.update(this._table, this._sqlOpts())
913
+ .set(sealed).where("_id", rowId).toSql();
914
+ var updStmt = this._db.prepare(built.sql);
915
+ var info = updStmt.run.apply(updStmt, built.params);
724
916
  changed += (info && info.changes) || 0;
725
917
  }
726
918
  return changed;
@@ -737,9 +929,9 @@ class Query {
737
929
  // Atomic counter increment.
738
930
  //
739
931
  // `from(table).where(filter).increment("col", 1)` emits
740
- // `UPDATE table SET col = col + ? WHERE ...` so concurrent writers
741
- // can't collide on a fetch/mutate/store sequence (which would lose
742
- // increments under racing transactions). Pass a negative delta to
932
+ // `UPDATE table SET col = COALESCE(col, 0) + ? WHERE ...` so concurrent
933
+ // writers can't collide on a fetch/mutate/store sequence (which would
934
+ // lose increments under racing transactions). Pass a negative delta to
743
935
  // decrement.
744
936
  //
745
937
  // Returns the number of rows changed (matches updateMany shape).
@@ -753,19 +945,22 @@ class Query {
753
945
  if (typeof delta !== "number" || !Number.isFinite(delta) || !Number.isInteger(delta)) {
754
946
  throw new Error("increment(column, delta): delta must be a finite integer (default 1)");
755
947
  }
756
- if (this._where.length === 0) {
948
+ if (!this._hasConditions()) {
757
949
  throw new Error("refusing unconditional increment — call where(...) first");
758
950
  }
759
- var whereSql = this._where.join(" AND ");
760
- var qt = this._quotedTable();
761
- var qc = '"' + column + '"';
762
951
  // Use COALESCE so a NULL counter starts at 0 instead of producing
763
952
  // NULL + delta = NULL silently (which would silently drop the
764
- // operation under SQLite's NULL-arithmetic rules).
765
- var sql = "UPDATE " + qt + " SET " + qc + " = COALESCE(" + qc + ", 0) + ? WHERE " + whereSql;
766
- var allParams = [delta].concat(this._whereParams);
767
- var stmt = this._db.prepare(sql);
768
- var info = stmt.run.apply(stmt, allParams);
953
+ // operation under SQLite's NULL-arithmetic rules). The quoted column
954
+ // expression is built by b.safeSql under the active dialect so the
955
+ // increment RHS references the same quoted identifier b.sql's set
956
+ // target uses (double-quote on sqlite/postgres, backtick on mysql).
957
+ var qc = safeSql.quoteIdentifier(column, this._dialect(), { allowReserved: true });
958
+ var qb = sql.update(this._table, this._sqlOpts())
959
+ .setRaw(column, "COALESCE(" + qc + ", 0) + ?", [delta]);
960
+ this._applyConditions(qb);
961
+ var built = qb.toSql();
962
+ var stmt = this._db.prepare(built.sql);
963
+ var info = stmt.run.apply(stmt, built.params);
769
964
  return info.changes;
770
965
  }
771
966
 
@@ -785,10 +980,10 @@ class Query {
785
980
  }
786
981
  var sub = new WhereBuilder(this);
787
982
  closure(sub);
788
- var built = sub.build();
789
- if (!built.sql) return this;
790
- this._where.push("(" + built.sql + ")");
791
- for (var i = 0; i < built.params.length; i++) this._whereParams.push(built.params[i]);
983
+ if (sub._parts.length === 0) return this;
984
+ this._pushLeaf("AND", function (pred) {
985
+ pred.whereGroup(function (g) { sub.replay(g); });
986
+ });
792
987
  return this;
793
988
  }
794
989
 
@@ -796,36 +991,61 @@ class Query {
796
991
  // `.where(a).orWhere(b)` produces `WHERE (a) OR (b)` rather than
797
992
  // `WHERE (a) AND (b)`. Accepts the same arg shapes as `.where`:
798
993
  // object-literal map, `(field, value)`, `(field, op, value)`, or a
799
- // `(qb) => ...` closure.
994
+ // `(qb) => ...` closure. Replays as `(prevLeaf OR newLeaf)` so the
995
+ // grouping precedence matches the pre-b.sql `( prev OR ( new ) )` form.
800
996
  orWhere(fieldOrObjOrFn, op, value) {
801
- if (this._where.length === 0) {
997
+ if (this._conditions.length === 0) {
802
998
  throw new Error("orWhere(...): no prior where(...) — start the chain with where(...)");
803
999
  }
1000
+ var argc = arguments.length;
1001
+ var prevLeaf = this._conditions.pop();
1002
+ var orApply;
804
1003
  if (typeof fieldOrObjOrFn === "function") {
805
1004
  var sub = new WhereBuilder(this);
806
1005
  fieldOrObjOrFn(sub);
807
- var built = sub.build();
808
- if (!built.sql) return this;
809
- var prev = this._where.pop();
810
- this._where.push("(" + prev + " OR (" + built.sql + "))");
811
- for (var i = 0; i < built.params.length; i++) this._whereParams.push(built.params[i]);
812
- return this;
813
- }
814
- // For non-closure shapes, build a transient single-leaf Query and
815
- // splice it. We compile to a `WhereBuilder` for symmetry.
816
- var sub2 = new WhereBuilder(this);
817
- if (fieldOrObjOrFn !== null && typeof fieldOrObjOrFn === "object" && !Array.isArray(fieldOrObjOrFn)) {
818
- Object.keys(fieldOrObjOrFn).forEach(function (k) { sub2.eq(k, fieldOrObjOrFn[k]); });
819
- } else if (op === undefined) {
820
- sub2.eq(fieldOrObjOrFn, /* value */ arguments[1]);
1006
+ if (sub._parts.length === 0) {
1007
+ // Empty OR closure — restore the prior leaf untouched.
1008
+ this._conditions.push(prevLeaf);
1009
+ return this;
1010
+ }
1011
+ orApply = function (pred) {
1012
+ pred.orWhereGroup(function (g) { sub.replay(g); });
1013
+ };
1014
+ } else if (fieldOrObjOrFn !== null && typeof fieldOrObjOrFn === "object" &&
1015
+ !Array.isArray(fieldOrObjOrFn)) {
1016
+ // Object map all equalities OR'd as one group leaf.
1017
+ var self = this;
1018
+ var resolvedList = Object.keys(fieldOrObjOrFn).map(function (k) {
1019
+ return self._resolvePredicate(k, "=", fieldOrObjOrFn[k]);
1020
+ });
1021
+ orApply = function (pred) {
1022
+ pred.orWhereGroup(function (g) {
1023
+ for (var i = 0; i < resolvedList.length; i++) {
1024
+ self._emitPredicate(g, "AND", resolvedList[i].field, resolvedList[i].op,
1025
+ resolvedList[i].value);
1026
+ }
1027
+ });
1028
+ };
821
1029
  } else {
822
- sub2._push("AND", fieldOrObjOrFn, op, value);
823
- }
824
- var built2 = sub2.build();
825
- if (!built2.sql) return this;
826
- var prev2 = this._where.pop();
827
- this._where.push("(" + prev2 + " OR (" + built2.sql + "))");
828
- for (var j = 0; j < built2.params.length; j++) this._whereParams.push(built2.params[j]);
1030
+ // 2-arg orWhere(field, value) is the equality shorthand; 3-arg
1031
+ // orWhere(field, op, value) carries an explicit operator. Mirror
1032
+ // .where()'s arguments.length discrimination so a 2-arg value of
1033
+ // (e.g.) the number 5 is never mistaken for an operator.
1034
+ var resolved = (argc === 2)
1035
+ ? this._resolvePredicate(fieldOrObjOrFn, "=", op)
1036
+ : this._resolvePredicate(fieldOrObjOrFn, op, value);
1037
+ var selfP = this;
1038
+ orApply = function (pred) {
1039
+ selfP._emitPredicate(pred, "OR", resolved.field, resolved.op, resolved.value);
1040
+ };
1041
+ }
1042
+ // Re-push a single leaf that emits ( prevLeaf OR newLeaf ).
1043
+ this._pushLeaf("AND", function (pred) {
1044
+ pred.whereGroup(function (g) {
1045
+ prevLeaf.apply(g);
1046
+ orApply(g);
1047
+ });
1048
+ });
829
1049
  return this;
830
1050
  }
831
1051
 
@@ -851,22 +1071,24 @@ class Query {
851
1071
  }
852
1072
  if (term.length === 0) return this;
853
1073
  var match = (opts && opts.match) || "substring";
854
- // Escape the operator's term so SQL LIKE wildcards in user input
855
- // don't widen the match. Use `~` as the ESCAPE char (SQLite's
856
- // ESCAPE clause requires a single character — picking `~` rather
857
- // than `\` avoids JS-string-literal escaping headaches; `~` rarely
858
- // appears in user-supplied search terms).
859
- var escaped = String(term).replace(/[~%_]/g, function (c) { return "~" + c; });
860
- var pattern;
861
- if (match === "exact") pattern = escaped;
862
- else if (match === "prefix") pattern = escaped + "%";
863
- else if (match === "substring") pattern = "%" + escaped + "%";
864
- else throw new Error("search: opts.match must be 'substring' | 'prefix' | 'exact'");
865
- var clauses = fields.map(function (f) { return '"' + f + '" LIKE ? ESCAPE \'~\''; });
866
- var sql = "(" + clauses.join(" OR ") + ")";
867
- var params = fields.map(function () { return pattern; });
868
- this._where.push(sql);
869
- for (var i = 0; i < params.length; i++) this._whereParams.push(params[i]);
1074
+ if (match !== "exact" && match !== "prefix" && match !== "substring") {
1075
+ throw new Error("search: opts.match must be 'substring' | 'prefix' | 'exact'");
1076
+ }
1077
+ // b.sql's whereLike owns the wildcard handling end-to-end: it escapes
1078
+ // the user's `%` / `_` metacharacters with `~`, adds the LIVE wrapping
1079
+ // wildcard per mode, and emits `"field" LIKE ? ESCAPE '~'` (a
1080
+ // builder-emitted ESCAPE clause, so no raw-fragment guard refusal). An
1081
+ // OR group across every search field; the first leaf leads, the rest
1082
+ // OR-join.
1083
+ var fieldList = fields.slice();
1084
+ this._pushLeaf("AND", function (pred) {
1085
+ pred.whereGroup(function (g) {
1086
+ for (var i = 0; i < fieldList.length; i++) {
1087
+ if (i === 0) g.whereLike(fieldList[i], term, match);
1088
+ else g.orWhereLike(fieldList[i], term, match);
1089
+ }
1090
+ });
1091
+ });
870
1092
  return this;
871
1093
  }
872
1094
 
@@ -907,20 +1129,41 @@ class Query {
907
1129
  }
908
1130
 
909
1131
  _delete(single) {
910
- if (this._where.length === 0) {
1132
+ if (!this._hasConditions()) {
911
1133
  throw new Error("refusing unconditional delete — call where(...) first");
912
1134
  }
913
- var whereSql = this._where.join(" AND ");
914
- var sql;
915
- var qt = this._quotedTable();
1135
+ var built;
916
1136
  if (single) {
917
- sql = "DELETE FROM " + qt +
918
- " WHERE rowid = (SELECT rowid FROM " + qt + " WHERE " + whereSql + " LIMIT 1)";
1137
+ // No engine ships a portable DELETE ... LIMIT, so single-row delete
1138
+ // mirrors the single-row update idiom: sqlite splices a rowid
1139
+ // sub-select, postgres a PK sub-select (both via b.sql whereSub, the
1140
+ // inner builder object — b.sql concatenates the sub-query's sql +
1141
+ // params, no hand-rolled string), and mysql resolves the one PK in a
1142
+ // prior SELECT then deletes `WHERE pk = ?` (the engine forbids a
1143
+ // subquery referencing the DELETE target table). A null PK means the
1144
+ // WHERE matched nothing — 0 rows deleted.
1145
+ if (this._dialect() === "mysql") {
1146
+ var pkVal = this._resolveSinglePk();
1147
+ if (pkVal === null) return 0;
1148
+ built = sql.delete(this._table, this._sqlOpts())
1149
+ .where(this._pkColumn(), pkVal)
1150
+ .toSql();
1151
+ } else {
1152
+ var col = this._rowLocatorColumn(this._dialect());
1153
+ var inner = sql.select(this._table, this._sqlOpts()).columns([col]);
1154
+ this._applyConditions(inner);
1155
+ inner.limit(1);
1156
+ built = sql.delete(this._table, this._sqlOpts())
1157
+ .whereSub(col, "=", inner)
1158
+ .toSql();
1159
+ }
919
1160
  } else {
920
- sql = "DELETE FROM " + qt + " WHERE " + whereSql;
1161
+ var dqb = sql.delete(this._table, this._sqlOpts());
1162
+ this._applyConditions(dqb);
1163
+ built = dqb.toSql();
921
1164
  }
922
- var delStmt = this._db.prepare(sql);
923
- var info = delStmt.run.apply(delStmt, this._whereParams);
1165
+ var delStmt = this._db.prepare(built.sql);
1166
+ var info = delStmt.run.apply(delStmt, built.params);
924
1167
  return info.changes;
925
1168
  }
926
1169
  }
@@ -934,11 +1177,13 @@ class Query {
934
1177
  // `.orGte` / `.orLt` / `.orLte` / `.orIn` / `.orLike` ORs an
935
1178
  // expression. `.raw(sql, params)` AND's an arbitrary fragment.
936
1179
  //
937
- // `.build()` returns `{ sql, params }`. Empty builder `{ sql: "",
938
- // params: [] }`.
1180
+ // Each part is recorded structurally ({ joiner, kind, ... }) and replayed
1181
+ // onto a b.sql Predicate via replay(pred) — b.sql owns the quoting +
1182
+ // binding + LIKE escape + IN-list expansion. The owning Query runs the
1183
+ // column-membership gate as each part is recorded.
939
1184
  class WhereBuilder {
940
1185
  constructor(gate) {
941
- this._parts = []; // [{ joiner: "AND"|"OR", sql: "...", params: [...] }]
1186
+ this._parts = []; // [{ joiner, kind: "cmp"|"raw", ... }]
942
1187
  // The owning Query, so grouped/OR sub-expressions enforce the
943
1188
  // same column-membership gate as the top-level chain.
944
1189
  this._gate = gate || null;
@@ -949,19 +1194,17 @@ class WhereBuilder {
949
1194
  }
950
1195
  _validateField(field);
951
1196
  if (this._gate) this._gate._assertColumnMember(field, "whereGroup");
952
- var qf = '"' + field + '"';
953
1197
  if (op === "IN" || op === "NOT IN") {
954
1198
  if (!Array.isArray(value) || value.length === 0) {
955
1199
  throw new Error("WhereBuilder: " + op + " requires a non-empty array of values");
956
1200
  }
957
- var placeholders = value.map(function () { return "?"; }).join(", ");
958
- this._parts.push({ joiner: joiner, sql: qf + " " + op + " (" + placeholders + ")", params: value.slice() });
1201
+ this._parts.push({ joiner: joiner, kind: "cmp", field: field, op: op, value: value.slice() });
959
1202
  return this;
960
1203
  }
961
- if (!ALLOWED_OPS.has(op)) {
1204
+ if (!ALLOWED_OPS.has(op) && op !== "NOT IN") {
962
1205
  throw new Error("WhereBuilder: invalid operator '" + op + "'");
963
1206
  }
964
- this._parts.push({ joiner: joiner, sql: qf + " " + op + " ?", params: [value] });
1207
+ this._parts.push({ joiner: joiner, kind: "cmp", field: field, op: op, value: value });
965
1208
  return this;
966
1209
  }
967
1210
  eq(f, v) { return this._push("AND", f, "=", v); }
@@ -980,56 +1223,67 @@ class WhereBuilder {
980
1223
  orLte(f, v) { return this._push("OR", f, "<=", v); }
981
1224
  orIn(f, vs) { return this._push("OR", f, "IN", vs); }
982
1225
  orLike(f, v) { return this._push("OR", f, "LIKE", v); }
983
- raw(sql, params, opts) {
984
- if (typeof sql !== "string" || sql.length === 0) {
1226
+ raw(sql_, params, opts) {
1227
+ if (typeof sql_ !== "string" || sql_.length === 0) {
985
1228
  throw new Error("WhereBuilder.raw: sql must be a non-empty string");
986
1229
  }
987
- if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql, "WhereBuilder.raw");
988
- var p = Array.isArray(params) ? params : (params == null ? [] : [params]);
989
- if (_countPlaceholders(sql) !== p.length) {
1230
+ var p = Array.isArray(params) ? params.slice() : (params == null ? [] : [params]);
1231
+ // Same fail-fast literal + placeholder-count contract as Query.whereRaw
1232
+ // (stable SafeSqlError code); b.sql re-guards at the terminal.
1233
+ if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql_, "WhereBuilder.raw");
1234
+ if (safeSql.countPlaceholders(sql_) !== p.length) {
990
1235
  throw new Error("WhereBuilder.raw: placeholder count mismatch");
991
1236
  }
992
- this._parts.push({ joiner: "AND", sql: "(" + sql + ")", params: p });
1237
+ this._parts.push({ joiner: "AND", kind: "raw", sql: sql_, params: p, opts: opts });
993
1238
  return this;
994
1239
  }
1240
+ // Replay the recorded parts onto a b.sql Predicate. The first part
1241
+ // leads the group (its joiner is the group's first leaf); each later
1242
+ // part AND/OR-joins per its recorded joiner. b.sql performs identifier
1243
+ // quoting, value binding, and IN-list expansion.
1244
+ replay(pred) {
1245
+ for (var i = 0; i < this._parts.length; i++) {
1246
+ _replayPart(pred, this._parts[i], this._parts[i].joiner === "OR" && i > 0);
1247
+ }
1248
+ }
995
1249
  build() {
1250
+ // Back-compat shim for any external reader that called build() to get
1251
+ // a { sql, params } pair. Replay onto a transient b.sql SELECT's
1252
+ // predicate and extract. Returns { sql: "", params: [] } when empty.
996
1253
  if (this._parts.length === 0) return { sql: "", params: [] };
997
- var sql = this._parts[0].sql;
998
- var params = this._parts[0].params.slice();
999
- for (var i = 1; i < this._parts.length; i += 1) {
1000
- sql = sql + " " + this._parts[i].joiner + " " + this._parts[i].sql;
1001
- for (var j = 0; j < this._parts[i].params.length; j += 1) {
1002
- params.push(this._parts[i].params[j]);
1003
- }
1004
- }
1005
- return { sql: sql, params: params };
1254
+ var self = this;
1255
+ var built = sql.select("t", { dialect: "sqlite" })
1256
+ .whereGroup(function (g) { self.replay(g); })
1257
+ .toSql();
1258
+ // Strip the "SELECT * FROM t WHERE (" prefix + trailing ")".
1259
+ var m = /WHERE \((.*)\)$/.exec(built.sql);
1260
+ return { sql: m ? m[1] : "", params: built.params };
1006
1261
  }
1007
1262
  }
1008
1263
 
1009
- // Count `?` placeholders outside string literals + comments.
1010
- // Tracks SQL single-quoted, double-quoted, line-comment, and block-
1011
- // comment state to avoid counting `?` characters that are part of
1012
- // literal text the SQL engine never interprets as a binding marker.
1013
- // Refuse raw SQL fragments that embed a single-quoted string
1014
- // literal. A whereRaw / WhereBuilder.raw fragment is meant to be a
1015
- // STATIC template whose every value is bound through a `?` placeholder;
1016
- // an embedded `'...'` literal is the signature of operator input
1017
- // concatenated into the query (CWE-89 / CWE-564 — concat into a
1018
- // query builder). Double-quoted identifiers (`"col"`), line comments,
1264
+ // Refuse a raw SQL fragment that embeds a single-quoted string literal.
1265
+ // A whereRaw / WhereBuilder.raw fragment is a STATIC template whose every
1266
+ // value binds through a `?` placeholder; an embedded `'...'` literal is
1267
+ // the signature of operator input concatenated into the query builder
1268
+ // (CWE-89 / CWE-564). Double-quoted identifiers (`"col"`), line comments,
1019
1269
  // and block comments are skipped. Operators with a deliberate static
1020
- // literal pass `{ allowLiterals: true }`. Shares the quote/comment
1021
- // scanning shape with _countPlaceholders.
1022
- function _assertRawNoStringLiteral(sql, where) {
1270
+ // literal pass `{ allowLiterals: true }`. db-query runs this eagerly at
1271
+ // the chain-build boundary so the operator-facing `sql/raw-literal`
1272
+ // SafeSqlError contract is stable; b.sql's whereRaw re-guards the same
1273
+ // fragment at the terminal (b.guardSql + the emission-time validator).
1274
+ // Single linear pass, no backtracking regex; shares the scan shape with
1275
+ // b.safeSql.countPlaceholders.
1276
+ function _assertRawNoStringLiteral(rawSql, where) {
1023
1277
  var i = 0;
1024
- var len = sql.length;
1278
+ var len = rawSql.length;
1025
1279
  while (i < len) {
1026
- var ch = sql.charAt(i);
1027
- var next = i + 1 < len ? sql.charAt(i + 1) : "";
1280
+ var ch = rawSql.charAt(i);
1281
+ var next = i + 1 < len ? rawSql.charAt(i + 1) : "";
1028
1282
  if (ch === '"') {
1029
1283
  i += 1;
1030
1284
  while (i < len) {
1031
- if (sql.charAt(i) === '"') {
1032
- if (sql.charAt(i + 1) === '"') { i += 2; continue; }
1285
+ if (rawSql.charAt(i) === '"') {
1286
+ if (rawSql.charAt(i + 1) === '"') { i += 2; continue; }
1033
1287
  i += 1; break;
1034
1288
  }
1035
1289
  i += 1;
@@ -1037,12 +1291,12 @@ function _assertRawNoStringLiteral(sql, where) {
1037
1291
  continue;
1038
1292
  }
1039
1293
  if (ch === "-" && next === "-") {
1040
- while (i < len && sql.charAt(i) !== "\n") i += 1;
1294
+ while (i < len && rawSql.charAt(i) !== "\n") i += 1;
1041
1295
  continue;
1042
1296
  }
1043
1297
  if (ch === "/" && next === "*") {
1044
1298
  i += 2;
1045
- while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
1299
+ while (i < len && !(rawSql.charAt(i) === "*" && rawSql.charAt(i + 1) === "/")) i += 1;
1046
1300
  i += 2;
1047
1301
  continue;
1048
1302
  }
@@ -1057,40 +1311,47 @@ function _assertRawNoStringLiteral(sql, where) {
1057
1311
  }
1058
1312
  }
1059
1313
 
1060
- function _countPlaceholders(sql) {
1061
- var count = 0;
1062
- var i = 0;
1063
- var len = sql.length;
1064
- while (i < len) {
1065
- var ch = sql.charAt(i);
1066
- var next = i + 1 < len ? sql.charAt(i + 1) : "";
1067
- if (ch === "'" || ch === '"') {
1068
- var quote = ch;
1069
- i += 1;
1070
- while (i < len) {
1071
- if (sql.charAt(i) === quote) {
1072
- // SQL doubles the quote char to escape it within a literal.
1073
- if (sql.charAt(i + 1) === quote) { i += 2; continue; }
1074
- i += 1; break;
1075
- }
1076
- i += 1;
1077
- }
1078
- continue;
1079
- }
1080
- if (ch === "-" && next === "-") {
1081
- while (i < len && sql.charAt(i) !== "\n") i += 1;
1082
- continue;
1083
- }
1084
- if (ch === "/" && next === "*") {
1085
- i += 2;
1086
- while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
1087
- i += 2;
1088
- continue;
1089
- }
1090
- if (ch === "?") count += 1;
1091
- i += 1;
1314
+ // Apply one recorded WhereBuilder part onto a b.sql Predicate. `or`
1315
+ // selects the OR-joining method (after the first leaf in a group); the
1316
+ // first leaf ignores its joiner (it leads the group). NOT IN and LIKE
1317
+ // are the two ops with a behavior the bare structured Predicate does not
1318
+ // expose 1:1: NOT IN has no orWhere* form, and the WhereBuilder LIKE is a
1319
+ // caller-controlled-wildcard LIKE (the value binds verbatim — no
1320
+ // auto-escape, matching the pre-b.sql WhereBuilder semantics, distinct
1321
+ // from .search() which escapes). Both compose through the guarded raw /
1322
+ // group surface without weakening anything.
1323
+ function _replayPart(pred, part, or) {
1324
+ if (part.kind === "raw") {
1325
+ if (or) pred.orWhereRaw(part.sql, part.params, part.opts);
1326
+ else pred.whereRaw(part.sql, part.params, part.opts);
1327
+ return;
1328
+ }
1329
+ if (part.op === "LIKE") {
1330
+ // Verbatim LIKE — caller controls the wildcards (no escape clause),
1331
+ // exactly as the pre-migration WhereBuilder emitted `"f" LIKE ?`. The
1332
+ // identifier quoting follows the predicate's OWN dialect (the builder
1333
+ // it replays onto), so the LIKE column matches the surrounding query's
1334
+ // quoting on mysql (backtick) as well as sqlite/postgres (double-quote).
1335
+ var likeDialect = (pred && typeof pred._dialect === "function") ? pred._dialect() : "sqlite";
1336
+ var likeSql = safeSql.quoteIdentifier(part.field, likeDialect, { allowReserved: true }) + " LIKE ?";
1337
+ if (or) pred.orWhereRaw(likeSql, [part.value]);
1338
+ else pred.whereRaw(likeSql, [part.value]);
1339
+ return;
1340
+ }
1341
+ if (part.op === "IN") {
1342
+ if (or) pred.orWhereIn(part.field, part.value);
1343
+ else pred.whereIn(part.field, part.value);
1344
+ return;
1345
+ }
1346
+ if (part.op === "NOT IN") {
1347
+ // b.sql exposes no orWhereNotIn; emit an OR NOT-IN leaf as a
1348
+ // single-member OR group so the join precedence is preserved.
1349
+ if (or) pred.orWhereGroup(function (g) { g.whereNotIn(part.field, part.value); });
1350
+ else pred.whereNotIn(part.field, part.value);
1351
+ return;
1092
1352
  }
1093
- return count;
1353
+ if (or) pred.orWhereOp(part.field, part.op, part.value);
1354
+ else pred.whereOp(part.field, part.op, part.value);
1094
1355
  }
1095
1356
 
1096
1357
  function _validateField(field) {