@blamejs/core 0.14.27 → 0.15.0

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 +4 -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 +107 -74
  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 +218 -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 +73 -24
  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 +497 -255
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +176 -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 +287 -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 +109 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +55 -17
  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,12 @@ class Query {
283
359
  return this._addCondition(fieldOrObj, op, value);
284
360
  }
285
361
 
286
- _addCondition(field, op, value) {
362
+ // Resolve a (field, op, value) predicate through the framework gates
363
+ // (JSONB value guard, sealed-field → derived-hash rewrite, column
364
+ // membership) and return the post-rewrite { field, op, value } that
365
+ // b.sql will emit. Shared by _addCondition and the WhereBuilder so the
366
+ // gates run identically whether the leaf is top-level or grouped.
367
+ _resolvePredicate(field, op, value) {
287
368
  if (!ALLOWED_OPS.has(op)) {
288
369
  throw new Error("invalid where operator: " + op);
289
370
  }
@@ -343,43 +424,54 @@ class Query {
343
424
  field = lookup.field;
344
425
  value = lookup.value;
345
426
  }
346
- cryptoField && _validateField(field);
427
+ _validateField(field);
347
428
  // Gate the post-sealed-rewrite physical column (derived-hash
348
429
  // columns are declared physical columns, so the rewrite target
349
430
  // passes membership).
350
431
  this._assertColumnMember(field, "where");
351
432
  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.
433
+ // node:sqlite ? does not support array-binding; b.sql expands the
434
+ // IN-list to (?, ?, ?) and binds each element. Validate the shape
435
+ // here so the failure is db-query's clear message, not a builder
436
+ // error deeper in the stack.
356
437
  if (!Array.isArray(value) || value.length === 0) {
357
438
  throw new Error("where IN requires a non-empty array of values");
358
439
  }
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
440
  }
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;
441
+ return { field: field, op: op, value: value };
442
+ }
443
+
444
+ // Apply a resolved predicate onto a b.sql Predicate using the given
445
+ // joiner ("AND" via where* / "OR" via orWhere*). LIKE auto-escape,
446
+ // IN-list expansion, IS NULL, and JSONB emission are all owned by
447
+ // b.sql's _cmp from here.
448
+ _emitPredicate(pred, joiner, field, op, value) {
449
+ if (op === "IN") {
450
+ if (joiner === "OR") pred.orWhereIn(field, value);
451
+ else pred.whereIn(field, value);
452
+ return;
377
453
  }
378
- this._where.push('"' + field + '" ' + op + " ?");
379
- this._whereParams.push(value);
454
+ if (joiner === "OR") pred.orWhereOp(field, op, value);
455
+ else pred.whereOp(field, op, value);
456
+ }
457
+
458
+ _addCondition(field, op, value) {
459
+ var resolved = this._resolvePredicate(field, op, value);
460
+ var self = this;
461
+ this._pushLeaf("AND", function (pred) {
462
+ self._emitPredicate(pred, "AND", resolved.field, resolved.op, resolved.value);
463
+ });
380
464
  return this;
381
465
  }
382
466
 
467
+ // Append a WHERE leaf. `apply(pred)` replays it onto a b.sql Predicate
468
+ // (AND-joined at the chain level — the leaf's own apply decides AND vs
469
+ // OR internally). orWhere() rewrites the last leaf rather than
470
+ // appending, to preserve `(prev OR new)` grouping precedence.
471
+ _pushLeaf(joiner, apply) {
472
+ this._conditions.push({ joiner: joiner, apply: apply });
473
+ }
474
+
383
475
  _isSealedField(field) {
384
476
  var sealed = cryptoField.getSealedFields(this._cryptoFieldKey());
385
477
  return sealed.indexOf(field) !== -1;
@@ -400,31 +492,35 @@ class Query {
400
492
 
401
493
  // whereRaw — append a parenthesized raw SQL fragment with positional
402
494
  // 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) {
495
+ // .where() (AND-joined). The fragment must NOT contain operator-
496
+ // supplied SQL — it's caller-controlled text used to build expressions
497
+ // the chainable .where() can't express (compound OR, row-value
498
+ // comparison for cursor pagination, etc.). b.sql's whereRaw guards the
499
+ // fragment (b.guardSql + embedded-literal + placeholder-count); the
500
+ // count + literal validation that db-query historically did inline now
501
+ // lives in that one choke-point.
502
+ whereRaw(sql_, params, opts) {
503
+ if (typeof sql_ !== "string" || sql_.length === 0) {
410
504
  throw new Error("whereRaw: sql must be a non-empty string");
411
505
  }
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);
506
+ var p = Array.isArray(params) ? params.slice() : (params == null ? [] : [params]);
507
+ // Fail-fast at the chain-build boundary (matching the pre-b.sql
508
+ // contract the operator catches a bad fragment at the whereRaw call,
509
+ // not deep inside a terminal). The embedded-literal + placeholder-count
510
+ // refusals keep db-query's stable SafeSqlError `sql/raw-literal` /
511
+ // explicit count-mismatch contract; b.sql's whereRaw (applied at the
512
+ // terminal) is the additional emission-time guard (b.guardSql, stacked-
513
+ // statement, encoding). allowLiterals opts the operator out of the
514
+ // literal refusal for a static, operator-controlled literal.
515
+ if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql_, "whereRaw");
516
+ var holders = safeSql.countPlaceholders(sql_);
422
517
  if (holders !== p.length) {
423
518
  throw new Error("whereRaw: " + holders + " placeholder(s) in sql but " +
424
519
  p.length + " param(s) supplied");
425
520
  }
426
- this._where.push("(" + sql + ")");
427
- for (var i = 0; i < p.length; i++) this._whereParams.push(p[i]);
521
+ this._pushLeaf("AND", function (pred) {
522
+ pred.whereRaw(sql_, p, opts);
523
+ });
428
524
  return this;
429
525
  }
430
526
 
@@ -476,49 +572,46 @@ class Query {
476
572
  return this;
477
573
  }
478
574
 
479
- // ---- Build SELECT components ----
480
-
481
- _whereClause() {
482
- return this._where.length === 0 ? "" : " WHERE " + this._where.join(" AND ");
483
- }
575
+ // ---- Build SELECT components on a b.sql builder ----
484
576
 
485
- _orderLimitOffset() {
486
- var s = "";
577
+ // Apply the recorded projection / order / limit / offset onto a b.sql
578
+ // SELECT builder. Projection columns + orderBy fields already passed
579
+ // _validateField + the column gate at record time.
580
+ _applySelectClauses(qb) {
581
+ if (this._select) qb.columns(this._select);
487
582
  if (this._orderBy) {
488
583
  var entries = Array.isArray(this._orderBy) ? this._orderBy : [this._orderBy];
489
- var fragments = [];
490
584
  for (var i = 0; i < entries.length; i++) {
491
- fragments.push('"' + entries[i].field + '" ' + entries[i].direction);
585
+ qb.orderBy(entries[i].field, entries[i].direction === "DESC" ? "desc" : "asc");
492
586
  }
493
- s += " ORDER BY " + fragments.join(", ");
494
587
  }
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(", ");
588
+ if (this._limit !== null) qb.limit(this._limit);
589
+ if (this._offset !== null) qb.offset(this._offset);
590
+ return qb;
503
591
  }
504
592
 
505
593
  // ---- Terminal methods (sync) ----
506
594
 
507
595
  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);
596
+ var qb = sql.select(this._table, this._sqlOpts());
597
+ this._applyConditions(qb);
598
+ this._applySelectClauses(qb);
599
+ qb.limit(1);
600
+ var built = qb.toSql();
601
+ var stmt = this._db.prepare(built.sql);
602
+ var row = stmt.get.apply(stmt, built.params);
512
603
  // 4th arg (dbHandle) lets unsealRow fetch + unwrap the row-scoped
513
604
  // K_row for vault.row: cells (declarePerRowKey tables).
514
605
  return row ? cryptoField.unsealRow(this._cryptoFieldKey(), row, undefined, this._db) : null;
515
606
  }
516
607
 
517
608
  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);
609
+ var qb = sql.select(this._table, this._sqlOpts());
610
+ this._applyConditions(qb);
611
+ this._applySelectClauses(qb);
612
+ var built = qb.toSql();
613
+ var stmt = this._db.prepare(built.sql);
614
+ var rows = stmt.all.apply(stmt, built.params);
522
615
  var out = new Array(rows.length);
523
616
  var key = this._cryptoFieldKey();
524
617
  var dbHandle = this._db;
@@ -535,8 +628,10 @@ class Query {
535
628
  // StreamLimit ceiling enforced from the module-level db
536
629
  // config; per-call opts.streamLimit overrides for one-off bumps.
537
630
  stream(opts) {
538
- var sql = "SELECT " + this._projection() + " FROM " + this._quotedTable() +
539
- this._whereClause() + this._orderLimitOffset();
631
+ var qb = sql.select(this._table, this._sqlOpts());
632
+ this._applyConditions(qb);
633
+ this._applySelectClauses(qb);
634
+ var built = qb.toSql();
540
635
  var perCallLimit;
541
636
  // db.js exports getStreamLimit so this module reads the live
542
637
  // ceiling without bouncing through the lib's circular load.
@@ -550,11 +645,11 @@ class Query {
550
645
  }
551
646
  perCallLimit = opts.streamLimit;
552
647
  }
553
- var stmt = this._db.prepare(sql);
648
+ var stmt = this._db.prepare(built.sql);
554
649
  var key = this._cryptoFieldKey();
555
650
  var dbHandle = this._db;
556
651
  var iter;
557
- try { iter = stmt.iterate.apply(stmt, this._whereParams); }
652
+ try { iter = stmt.iterate.apply(stmt, built.params); }
558
653
  catch (e) {
559
654
  var r = new Readable({ objectMode: true, read: function () {} });
560
655
  setImmediate(function () { r.destroy(e); });
@@ -583,9 +678,11 @@ class Query {
583
678
  }
584
679
 
585
680
  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);
681
+ var qb = sql.select(this._table, this._sqlOpts()).count("*", "n");
682
+ this._applyConditions(qb);
683
+ var built = qb.toSql();
684
+ var stmt = this._db.prepare(built.sql);
685
+ var row = stmt.get.apply(stmt, built.params);
589
686
  return row ? row.n : 0;
590
687
  }
591
688
 
@@ -605,7 +702,7 @@ class Query {
605
702
  // (vault.row: cells). rowId MUST be withId._id — the same value
606
703
  // b.subject.eraseHard / b.retention destroy on, so a later shred
607
704
  // makes these cells undecryptable. Materialize stores the random
608
- // row-secret AAD-sealed in _blamejs_per_row_keys.
705
+ // row-secret AAD-sealed in the per-row-key store.
609
706
  var sealOpts;
610
707
  var cfKey = this._cryptoFieldKey();
611
708
  if (cryptoField.hasPerRowKey(cfKey)) {
@@ -613,13 +710,9 @@ class Query {
613
710
  sealOpts = { kRow: kRow, rowId: withId._id };
614
711
  }
615
712
  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);
713
+ var built = sql.insert(this._table, this._sqlOpts()).values(sealed).toSql();
714
+ var insertStmt = this._db.prepare(built.sql);
715
+ insertStmt.run.apply(insertStmt, built.params);
623
716
  // Return the original row with _id filled in (plaintext, never sealed)
624
717
  return Object.assign({}, withId);
625
718
  }
@@ -646,7 +739,7 @@ class Query {
646
739
  if (!changes || typeof changes !== "object") {
647
740
  throw new Error("update requires a changes object");
648
741
  }
649
- if (this._where.length === 0) {
742
+ if (!this._hasConditions()) {
650
743
  throw new Error("refusing unconditional update — call where(...) first");
651
744
  }
652
745
  // Residency gates on the plaintext change set — an UPDATE that
@@ -670,27 +763,106 @@ class Query {
670
763
  setKeys.forEach(_validateField);
671
764
  var selfUpd = this;
672
765
  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
766
 
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();
767
+ // No engine ships a portable UPDATE ... LIMIT, so a single-row update
768
+ // resolves exactly one row then writes it. The shape is dialect-aware
769
+ // (sqlite rowid sub-select / postgres PK sub-select / mysql
770
+ // resolve-then-write_buildSingleRowWrite). A null result means the
771
+ // WHERE matched no row, so there is nothing to update (0 changes).
772
+ var built;
682
773
  if (single) {
683
- sql = "UPDATE " + qt + " SET " + setClause +
684
- " WHERE rowid = (SELECT rowid FROM " + qt + " WHERE " + whereSql + " LIMIT 1)";
774
+ built = this._buildSingleRowWrite(sealed);
775
+ if (built === null) return 0;
685
776
  } else {
686
- sql = "UPDATE " + qt + " SET " + setClause + " WHERE " + whereSql + limit;
777
+ var qb = sql.update(this._table, this._sqlOpts()).set(sealed);
778
+ this._applyConditions(qb);
779
+ built = qb.toSql();
687
780
  }
688
- var allParams = setValues.concat(this._whereParams);
689
- var updStmt = this._db.prepare(sql);
690
- var info = updStmt.run.apply(updStmt, allParams);
781
+ var updStmt = this._db.prepare(built.sql);
782
+ var info = updStmt.run.apply(updStmt, built.params);
691
783
  return info.changes;
692
784
  }
693
785
 
786
+ // The single-row-write row locator, by dialect. No engine ships
787
+ // UPDATE ... LIMIT portably (node:sqlite is built without
788
+ // SQLITE_ENABLE_UPDATE_DELETE_LIMIT), so the single-row idiom is a
789
+ // sub-SELECT that resolves exactly one row then matches it:
790
+ //
791
+ // sqlite — the implicit `rowid` system column (every non-WITHOUT-
792
+ // ROWID table has one); `WHERE "rowid" = (SELECT "rowid"
793
+ // FROM t WHERE ... LIMIT 1)`.
794
+ // postgres — the table's PRIMARY KEY (`_id`, the db.from() convention).
795
+ // Postgres accepts LIMIT in a scalar subquery, so the same
796
+ // `= (SELECT "_id" ... LIMIT 1)` shape works — and using the
797
+ // real, UNIQUE `_id` column keeps b.sql's quote-by-
798
+ // construction intact (ctid is an unquotable system column
799
+ // that would force a raw-identifier escape and is unstable
800
+ // across VACUUM).
801
+ // mysql — also the PRIMARY KEY, but MySQL refuses LIMIT in a
802
+ // subquery that directly references the same table in an
803
+ // `IN`/`=` predicate; wrapping the inner SELECT in a derived
804
+ // table (`... IN (SELECT "_id" FROM (SELECT "_id" ... LIMIT
805
+ // 1) AS _s)`) is the standard work-around.
806
+ //
807
+ // The inner SELECT is composed through b.sql (same table + conditions)
808
+ // and spliced via whereSub — passing the inner BUILDER (not concatenated
809
+ // SQL) so b.sql concatenates the sub-query's sql + params itself and the
810
+ // final statement still runs through b.sql's output validator.
811
+ _rowLocatorColumn(dialect) {
812
+ return dialect === "sqlite" ? "rowid" : this._pkColumn();
813
+ }
814
+
815
+ // The PRIMARY KEY column for single-row writes on non-sqlite dialects.
816
+ // db.from() tables key on `_id` (auto-generated when absent on insert);
817
+ // an operator running a table with a different PK overrides it via the
818
+ // `primaryKey` construction opt.
819
+ _pkColumn() {
820
+ return this._primaryKey || "_id";
821
+ }
822
+
823
+ _buildSingleRowWrite(sealed) {
824
+ if (this._dialect() === "mysql") {
825
+ // MySQL forbids referencing the UPDATE/DELETE target table in a
826
+ // subquery (error 1093), so the single-statement sub-SELECT idiom
827
+ // the other dialects use is unavailable. Resolve the one row's PK in
828
+ // a prior SELECT, then write `WHERE pk = ?` with the resolved value
829
+ // bound — every value still binds, the identifier still quotes by
830
+ // construction, and the write is a single validated statement with no
831
+ // self-referential subquery. Returns null when no row matched.
832
+ var pkVal = this._resolveSinglePk();
833
+ if (pkVal === null) return null;
834
+ return sql.update(this._table, this._sqlOpts())
835
+ .set(sealed)
836
+ .where(this._pkColumn(), pkVal)
837
+ .toSql();
838
+ }
839
+ var col = this._rowLocatorColumn(this._dialect());
840
+ var inner = sql.select(this._table, this._sqlOpts()).columns([col]);
841
+ this._applyConditions(inner);
842
+ inner.limit(1);
843
+ return sql.update(this._table, this._sqlOpts())
844
+ .set(sealed)
845
+ .whereSub(col, "=", inner)
846
+ .toSql();
847
+ }
848
+
849
+ // Resolve the PK of exactly one row matching the recorded WHERE (LIMIT
850
+ // 1). Used by the MySQL single-row write path, where a self-referential
851
+ // subquery is rejected by the engine. The SELECT is a clean, fully-bound
852
+ // b.sql statement; returns the PK value, or null when nothing matched.
853
+ _resolveSinglePk() {
854
+ var pk = this._pkColumn();
855
+ var pick = sql.select(this._table, this._sqlOpts()).columns([pk]);
856
+ this._applyConditions(pick);
857
+ pick.limit(1);
858
+ var built = pick.toSql();
859
+ var stmt = this._db.prepare(built.sql);
860
+ var row = stmt.get.apply(stmt, built.params);
861
+ if (!row) return null;
862
+ var v = row[pk];
863
+ return (v === undefined || v === null) ? null : v;
864
+ }
865
+
694
866
  // Per-row-key UPDATE. Sealed columns on a declarePerRowKey table are
695
867
  // K_row cells (vault.row:), so each affected row must be re-sealed
696
868
  // under its OWN K_row — a single set-based UPDATE can't carry per-row
@@ -699,11 +871,12 @@ class Query {
699
871
  // under it (derived hashes computed from plaintext as usual), and
700
872
  // UPDATE that single row by _id. `single` stops after the first row.
701
873
  _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);
874
+ var idSelect = sql.select(this._table, this._sqlOpts()).columns(["_id"]);
875
+ this._applyConditions(idSelect);
876
+ if (single) idSelect.limit(1);
877
+ var idBuilt = idSelect.toSql();
878
+ var idStmt = this._db.prepare(idBuilt.sql);
879
+ var idRows = idStmt.all.apply(idStmt, idBuilt.params);
707
880
  var changed = 0;
708
881
  for (var r = 0; r < idRows.length; r++) {
709
882
  var rowId = idRows[r]._id;
@@ -717,10 +890,10 @@ class Query {
717
890
  setKeys.forEach(_validateField);
718
891
  var selfUpd = this;
719
892
  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]));
893
+ var built = sql.update(this._table, this._sqlOpts())
894
+ .set(sealed).where("_id", rowId).toSql();
895
+ var updStmt = this._db.prepare(built.sql);
896
+ var info = updStmt.run.apply(updStmt, built.params);
724
897
  changed += (info && info.changes) || 0;
725
898
  }
726
899
  return changed;
@@ -737,9 +910,9 @@ class Query {
737
910
  // Atomic counter increment.
738
911
  //
739
912
  // `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
913
+ // `UPDATE table SET col = COALESCE(col, 0) + ? WHERE ...` so concurrent
914
+ // writers can't collide on a fetch/mutate/store sequence (which would
915
+ // lose increments under racing transactions). Pass a negative delta to
743
916
  // decrement.
744
917
  //
745
918
  // Returns the number of rows changed (matches updateMany shape).
@@ -753,19 +926,22 @@ class Query {
753
926
  if (typeof delta !== "number" || !Number.isFinite(delta) || !Number.isInteger(delta)) {
754
927
  throw new Error("increment(column, delta): delta must be a finite integer (default 1)");
755
928
  }
756
- if (this._where.length === 0) {
929
+ if (!this._hasConditions()) {
757
930
  throw new Error("refusing unconditional increment — call where(...) first");
758
931
  }
759
- var whereSql = this._where.join(" AND ");
760
- var qt = this._quotedTable();
761
- var qc = '"' + column + '"';
762
932
  // Use COALESCE so a NULL counter starts at 0 instead of producing
763
933
  // 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);
934
+ // operation under SQLite's NULL-arithmetic rules). The quoted column
935
+ // expression is built by b.safeSql under the active dialect so the
936
+ // increment RHS references the same quoted identifier b.sql's set
937
+ // target uses (double-quote on sqlite/postgres, backtick on mysql).
938
+ var qc = safeSql.quoteIdentifier(column, this._dialect(), { allowReserved: true });
939
+ var qb = sql.update(this._table, this._sqlOpts())
940
+ .setRaw(column, "COALESCE(" + qc + ", 0) + ?", [delta]);
941
+ this._applyConditions(qb);
942
+ var built = qb.toSql();
943
+ var stmt = this._db.prepare(built.sql);
944
+ var info = stmt.run.apply(stmt, built.params);
769
945
  return info.changes;
770
946
  }
771
947
 
@@ -785,10 +961,10 @@ class Query {
785
961
  }
786
962
  var sub = new WhereBuilder(this);
787
963
  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]);
964
+ if (sub._parts.length === 0) return this;
965
+ this._pushLeaf("AND", function (pred) {
966
+ pred.whereGroup(function (g) { sub.replay(g); });
967
+ });
792
968
  return this;
793
969
  }
794
970
 
@@ -796,36 +972,61 @@ class Query {
796
972
  // `.where(a).orWhere(b)` produces `WHERE (a) OR (b)` rather than
797
973
  // `WHERE (a) AND (b)`. Accepts the same arg shapes as `.where`:
798
974
  // object-literal map, `(field, value)`, `(field, op, value)`, or a
799
- // `(qb) => ...` closure.
975
+ // `(qb) => ...` closure. Replays as `(prevLeaf OR newLeaf)` so the
976
+ // grouping precedence matches the pre-b.sql `( prev OR ( new ) )` form.
800
977
  orWhere(fieldOrObjOrFn, op, value) {
801
- if (this._where.length === 0) {
978
+ if (this._conditions.length === 0) {
802
979
  throw new Error("orWhere(...): no prior where(...) — start the chain with where(...)");
803
980
  }
981
+ var argc = arguments.length;
982
+ var prevLeaf = this._conditions.pop();
983
+ var orApply;
804
984
  if (typeof fieldOrObjOrFn === "function") {
805
985
  var sub = new WhereBuilder(this);
806
986
  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]);
987
+ if (sub._parts.length === 0) {
988
+ // Empty OR closure — restore the prior leaf untouched.
989
+ this._conditions.push(prevLeaf);
990
+ return this;
991
+ }
992
+ orApply = function (pred) {
993
+ pred.orWhereGroup(function (g) { sub.replay(g); });
994
+ };
995
+ } else if (fieldOrObjOrFn !== null && typeof fieldOrObjOrFn === "object" &&
996
+ !Array.isArray(fieldOrObjOrFn)) {
997
+ // Object map all equalities OR'd as one group leaf.
998
+ var self = this;
999
+ var resolvedList = Object.keys(fieldOrObjOrFn).map(function (k) {
1000
+ return self._resolvePredicate(k, "=", fieldOrObjOrFn[k]);
1001
+ });
1002
+ orApply = function (pred) {
1003
+ pred.orWhereGroup(function (g) {
1004
+ for (var i = 0; i < resolvedList.length; i++) {
1005
+ self._emitPredicate(g, "AND", resolvedList[i].field, resolvedList[i].op,
1006
+ resolvedList[i].value);
1007
+ }
1008
+ });
1009
+ };
821
1010
  } 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]);
1011
+ // 2-arg orWhere(field, value) is the equality shorthand; 3-arg
1012
+ // orWhere(field, op, value) carries an explicit operator. Mirror
1013
+ // .where()'s arguments.length discrimination so a 2-arg value of
1014
+ // (e.g.) the number 5 is never mistaken for an operator.
1015
+ var resolved = (argc === 2)
1016
+ ? this._resolvePredicate(fieldOrObjOrFn, "=", op)
1017
+ : this._resolvePredicate(fieldOrObjOrFn, op, value);
1018
+ var selfP = this;
1019
+ orApply = function (pred) {
1020
+ selfP._emitPredicate(pred, "OR", resolved.field, resolved.op, resolved.value);
1021
+ };
1022
+ }
1023
+ // Re-push a single leaf that emits ( prevLeaf OR newLeaf ).
1024
+ this._pushLeaf("AND", function (pred) {
1025
+ pred.whereGroup(function (g) {
1026
+ prevLeaf.apply(g);
1027
+ orApply(g);
1028
+ });
1029
+ });
829
1030
  return this;
830
1031
  }
831
1032
 
@@ -851,22 +1052,24 @@ class Query {
851
1052
  }
852
1053
  if (term.length === 0) return this;
853
1054
  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]);
1055
+ if (match !== "exact" && match !== "prefix" && match !== "substring") {
1056
+ throw new Error("search: opts.match must be 'substring' | 'prefix' | 'exact'");
1057
+ }
1058
+ // b.sql's whereLike owns the wildcard handling end-to-end: it escapes
1059
+ // the user's `%` / `_` metacharacters with `~`, adds the LIVE wrapping
1060
+ // wildcard per mode, and emits `"field" LIKE ? ESCAPE '~'` (a
1061
+ // builder-emitted ESCAPE clause, so no raw-fragment guard refusal). An
1062
+ // OR group across every search field; the first leaf leads, the rest
1063
+ // OR-join.
1064
+ var fieldList = fields.slice();
1065
+ this._pushLeaf("AND", function (pred) {
1066
+ pred.whereGroup(function (g) {
1067
+ for (var i = 0; i < fieldList.length; i++) {
1068
+ if (i === 0) g.whereLike(fieldList[i], term, match);
1069
+ else g.orWhereLike(fieldList[i], term, match);
1070
+ }
1071
+ });
1072
+ });
870
1073
  return this;
871
1074
  }
872
1075
 
@@ -907,20 +1110,41 @@ class Query {
907
1110
  }
908
1111
 
909
1112
  _delete(single) {
910
- if (this._where.length === 0) {
1113
+ if (!this._hasConditions()) {
911
1114
  throw new Error("refusing unconditional delete — call where(...) first");
912
1115
  }
913
- var whereSql = this._where.join(" AND ");
914
- var sql;
915
- var qt = this._quotedTable();
1116
+ var built;
916
1117
  if (single) {
917
- sql = "DELETE FROM " + qt +
918
- " WHERE rowid = (SELECT rowid FROM " + qt + " WHERE " + whereSql + " LIMIT 1)";
1118
+ // No engine ships a portable DELETE ... LIMIT, so single-row delete
1119
+ // mirrors the single-row update idiom: sqlite splices a rowid
1120
+ // sub-select, postgres a PK sub-select (both via b.sql whereSub, the
1121
+ // inner builder object — b.sql concatenates the sub-query's sql +
1122
+ // params, no hand-rolled string), and mysql resolves the one PK in a
1123
+ // prior SELECT then deletes `WHERE pk = ?` (the engine forbids a
1124
+ // subquery referencing the DELETE target table). A null PK means the
1125
+ // WHERE matched nothing — 0 rows deleted.
1126
+ if (this._dialect() === "mysql") {
1127
+ var pkVal = this._resolveSinglePk();
1128
+ if (pkVal === null) return 0;
1129
+ built = sql.delete(this._table, this._sqlOpts())
1130
+ .where(this._pkColumn(), pkVal)
1131
+ .toSql();
1132
+ } else {
1133
+ var col = this._rowLocatorColumn(this._dialect());
1134
+ var inner = sql.select(this._table, this._sqlOpts()).columns([col]);
1135
+ this._applyConditions(inner);
1136
+ inner.limit(1);
1137
+ built = sql.delete(this._table, this._sqlOpts())
1138
+ .whereSub(col, "=", inner)
1139
+ .toSql();
1140
+ }
919
1141
  } else {
920
- sql = "DELETE FROM " + qt + " WHERE " + whereSql;
1142
+ var dqb = sql.delete(this._table, this._sqlOpts());
1143
+ this._applyConditions(dqb);
1144
+ built = dqb.toSql();
921
1145
  }
922
- var delStmt = this._db.prepare(sql);
923
- var info = delStmt.run.apply(delStmt, this._whereParams);
1146
+ var delStmt = this._db.prepare(built.sql);
1147
+ var info = delStmt.run.apply(delStmt, built.params);
924
1148
  return info.changes;
925
1149
  }
926
1150
  }
@@ -934,11 +1158,13 @@ class Query {
934
1158
  // `.orGte` / `.orLt` / `.orLte` / `.orIn` / `.orLike` ORs an
935
1159
  // expression. `.raw(sql, params)` AND's an arbitrary fragment.
936
1160
  //
937
- // `.build()` returns `{ sql, params }`. Empty builder `{ sql: "",
938
- // params: [] }`.
1161
+ // Each part is recorded structurally ({ joiner, kind, ... }) and replayed
1162
+ // onto a b.sql Predicate via replay(pred) — b.sql owns the quoting +
1163
+ // binding + LIKE escape + IN-list expansion. The owning Query runs the
1164
+ // column-membership gate as each part is recorded.
939
1165
  class WhereBuilder {
940
1166
  constructor(gate) {
941
- this._parts = []; // [{ joiner: "AND"|"OR", sql: "...", params: [...] }]
1167
+ this._parts = []; // [{ joiner, kind: "cmp"|"raw", ... }]
942
1168
  // The owning Query, so grouped/OR sub-expressions enforce the
943
1169
  // same column-membership gate as the top-level chain.
944
1170
  this._gate = gate || null;
@@ -949,19 +1175,17 @@ class WhereBuilder {
949
1175
  }
950
1176
  _validateField(field);
951
1177
  if (this._gate) this._gate._assertColumnMember(field, "whereGroup");
952
- var qf = '"' + field + '"';
953
1178
  if (op === "IN" || op === "NOT IN") {
954
1179
  if (!Array.isArray(value) || value.length === 0) {
955
1180
  throw new Error("WhereBuilder: " + op + " requires a non-empty array of values");
956
1181
  }
957
- var placeholders = value.map(function () { return "?"; }).join(", ");
958
- this._parts.push({ joiner: joiner, sql: qf + " " + op + " (" + placeholders + ")", params: value.slice() });
1182
+ this._parts.push({ joiner: joiner, kind: "cmp", field: field, op: op, value: value.slice() });
959
1183
  return this;
960
1184
  }
961
- if (!ALLOWED_OPS.has(op)) {
1185
+ if (!ALLOWED_OPS.has(op) && op !== "NOT IN") {
962
1186
  throw new Error("WhereBuilder: invalid operator '" + op + "'");
963
1187
  }
964
- this._parts.push({ joiner: joiner, sql: qf + " " + op + " ?", params: [value] });
1188
+ this._parts.push({ joiner: joiner, kind: "cmp", field: field, op: op, value: value });
965
1189
  return this;
966
1190
  }
967
1191
  eq(f, v) { return this._push("AND", f, "=", v); }
@@ -980,56 +1204,67 @@ class WhereBuilder {
980
1204
  orLte(f, v) { return this._push("OR", f, "<=", v); }
981
1205
  orIn(f, vs) { return this._push("OR", f, "IN", vs); }
982
1206
  orLike(f, v) { return this._push("OR", f, "LIKE", v); }
983
- raw(sql, params, opts) {
984
- if (typeof sql !== "string" || sql.length === 0) {
1207
+ raw(sql_, params, opts) {
1208
+ if (typeof sql_ !== "string" || sql_.length === 0) {
985
1209
  throw new Error("WhereBuilder.raw: sql must be a non-empty string");
986
1210
  }
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) {
1211
+ var p = Array.isArray(params) ? params.slice() : (params == null ? [] : [params]);
1212
+ // Same fail-fast literal + placeholder-count contract as Query.whereRaw
1213
+ // (stable SafeSqlError code); b.sql re-guards at the terminal.
1214
+ if (!(opts && opts.allowLiterals === true)) _assertRawNoStringLiteral(sql_, "WhereBuilder.raw");
1215
+ if (safeSql.countPlaceholders(sql_) !== p.length) {
990
1216
  throw new Error("WhereBuilder.raw: placeholder count mismatch");
991
1217
  }
992
- this._parts.push({ joiner: "AND", sql: "(" + sql + ")", params: p });
1218
+ this._parts.push({ joiner: "AND", kind: "raw", sql: sql_, params: p, opts: opts });
993
1219
  return this;
994
1220
  }
1221
+ // Replay the recorded parts onto a b.sql Predicate. The first part
1222
+ // leads the group (its joiner is the group's first leaf); each later
1223
+ // part AND/OR-joins per its recorded joiner. b.sql performs identifier
1224
+ // quoting, value binding, and IN-list expansion.
1225
+ replay(pred) {
1226
+ for (var i = 0; i < this._parts.length; i++) {
1227
+ _replayPart(pred, this._parts[i], this._parts[i].joiner === "OR" && i > 0);
1228
+ }
1229
+ }
995
1230
  build() {
1231
+ // Back-compat shim for any external reader that called build() to get
1232
+ // a { sql, params } pair. Replay onto a transient b.sql SELECT's
1233
+ // predicate and extract. Returns { sql: "", params: [] } when empty.
996
1234
  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 };
1235
+ var self = this;
1236
+ var built = sql.select("t", { dialect: "sqlite" })
1237
+ .whereGroup(function (g) { self.replay(g); })
1238
+ .toSql();
1239
+ // Strip the "SELECT * FROM t WHERE (" prefix + trailing ")".
1240
+ var m = /WHERE \((.*)\)$/.exec(built.sql);
1241
+ return { sql: m ? m[1] : "", params: built.params };
1006
1242
  }
1007
1243
  }
1008
1244
 
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,
1245
+ // Refuse a raw SQL fragment that embeds a single-quoted string literal.
1246
+ // A whereRaw / WhereBuilder.raw fragment is a STATIC template whose every
1247
+ // value binds through a `?` placeholder; an embedded `'...'` literal is
1248
+ // the signature of operator input concatenated into the query builder
1249
+ // (CWE-89 / CWE-564). Double-quoted identifiers (`"col"`), line comments,
1019
1250
  // 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) {
1251
+ // literal pass `{ allowLiterals: true }`. db-query runs this eagerly at
1252
+ // the chain-build boundary so the operator-facing `sql/raw-literal`
1253
+ // SafeSqlError contract is stable; b.sql's whereRaw re-guards the same
1254
+ // fragment at the terminal (b.guardSql + the emission-time validator).
1255
+ // Single linear pass, no backtracking regex; shares the scan shape with
1256
+ // b.safeSql.countPlaceholders.
1257
+ function _assertRawNoStringLiteral(rawSql, where) {
1023
1258
  var i = 0;
1024
- var len = sql.length;
1259
+ var len = rawSql.length;
1025
1260
  while (i < len) {
1026
- var ch = sql.charAt(i);
1027
- var next = i + 1 < len ? sql.charAt(i + 1) : "";
1261
+ var ch = rawSql.charAt(i);
1262
+ var next = i + 1 < len ? rawSql.charAt(i + 1) : "";
1028
1263
  if (ch === '"') {
1029
1264
  i += 1;
1030
1265
  while (i < len) {
1031
- if (sql.charAt(i) === '"') {
1032
- if (sql.charAt(i + 1) === '"') { i += 2; continue; }
1266
+ if (rawSql.charAt(i) === '"') {
1267
+ if (rawSql.charAt(i + 1) === '"') { i += 2; continue; }
1033
1268
  i += 1; break;
1034
1269
  }
1035
1270
  i += 1;
@@ -1037,12 +1272,12 @@ function _assertRawNoStringLiteral(sql, where) {
1037
1272
  continue;
1038
1273
  }
1039
1274
  if (ch === "-" && next === "-") {
1040
- while (i < len && sql.charAt(i) !== "\n") i += 1;
1275
+ while (i < len && rawSql.charAt(i) !== "\n") i += 1;
1041
1276
  continue;
1042
1277
  }
1043
1278
  if (ch === "/" && next === "*") {
1044
1279
  i += 2;
1045
- while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
1280
+ while (i < len && !(rawSql.charAt(i) === "*" && rawSql.charAt(i + 1) === "/")) i += 1;
1046
1281
  i += 2;
1047
1282
  continue;
1048
1283
  }
@@ -1057,40 +1292,47 @@ function _assertRawNoStringLiteral(sql, where) {
1057
1292
  }
1058
1293
  }
1059
1294
 
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;
1295
+ // Apply one recorded WhereBuilder part onto a b.sql Predicate. `or`
1296
+ // selects the OR-joining method (after the first leaf in a group); the
1297
+ // first leaf ignores its joiner (it leads the group). NOT IN and LIKE
1298
+ // are the two ops with a behavior the bare structured Predicate does not
1299
+ // expose 1:1: NOT IN has no orWhere* form, and the WhereBuilder LIKE is a
1300
+ // caller-controlled-wildcard LIKE (the value binds verbatim — no
1301
+ // auto-escape, matching the pre-b.sql WhereBuilder semantics, distinct
1302
+ // from .search() which escapes). Both compose through the guarded raw /
1303
+ // group surface without weakening anything.
1304
+ function _replayPart(pred, part, or) {
1305
+ if (part.kind === "raw") {
1306
+ if (or) pred.orWhereRaw(part.sql, part.params, part.opts);
1307
+ else pred.whereRaw(part.sql, part.params, part.opts);
1308
+ return;
1309
+ }
1310
+ if (part.op === "LIKE") {
1311
+ // Verbatim LIKE — caller controls the wildcards (no escape clause),
1312
+ // exactly as the pre-migration WhereBuilder emitted `"f" LIKE ?`. The
1313
+ // identifier quoting follows the predicate's OWN dialect (the builder
1314
+ // it replays onto), so the LIKE column matches the surrounding query's
1315
+ // quoting on mysql (backtick) as well as sqlite/postgres (double-quote).
1316
+ var likeDialect = (pred && typeof pred._dialect === "function") ? pred._dialect() : "sqlite";
1317
+ var likeSql = safeSql.quoteIdentifier(part.field, likeDialect, { allowReserved: true }) + " LIKE ?";
1318
+ if (or) pred.orWhereRaw(likeSql, [part.value]);
1319
+ else pred.whereRaw(likeSql, [part.value]);
1320
+ return;
1321
+ }
1322
+ if (part.op === "IN") {
1323
+ if (or) pred.orWhereIn(part.field, part.value);
1324
+ else pred.whereIn(part.field, part.value);
1325
+ return;
1326
+ }
1327
+ if (part.op === "NOT IN") {
1328
+ // b.sql exposes no orWhereNotIn; emit an OR NOT-IN leaf as a
1329
+ // single-member OR group so the join precedence is preserved.
1330
+ if (or) pred.orWhereGroup(function (g) { g.whereNotIn(part.field, part.value); });
1331
+ else pred.whereNotIn(part.field, part.value);
1332
+ return;
1092
1333
  }
1093
- return count;
1334
+ if (or) pred.orWhereOp(part.field, part.op, part.value);
1335
+ else pred.whereOp(part.field, part.op, part.value);
1094
1336
  }
1095
1337
 
1096
1338
  function _validateField(field) {