@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/sql.js ADDED
@@ -0,0 +1,3885 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.sql
4
+ * @nav Validation
5
+ * @title SQL Builder
6
+ * @order 90
7
+ * @featured true
8
+ *
9
+ * @intro
10
+ * Chainable SQL builder that makes hand-rolled SQL impossible. Every
11
+ * table and column name is quoted by construction through
12
+ * `b.safeSql`; every value is a bound `?` placeholder, never
13
+ * string-interpolated. The builder emits BARE logical table names and
14
+ * `?` placeholders - `b.clusterStorage` rewrites bare framework tables
15
+ * to their cluster-prefixed names and translates `?` to `$N` for
16
+ * Postgres at execute time - so one query text runs unchanged against
17
+ * the local SQLite single-node backend and the operator-supplied
18
+ * external Postgres / MySQL in cluster mode.
19
+ *
20
+ * The terminal call is `.toSql()` returning `{ sql, params }`. Pass
21
+ * that straight to `b.clusterStorage.execute(sql, params)`. The
22
+ * builder never touches the database itself - it is a pure SQL-string
23
+ * composer, which keeps it free of the residency / sealed-column
24
+ * write-path concerns that `db.from(...)` (the executing query
25
+ * builder, `lib/db-query.js`) owns.
26
+ *
27
+ * Only `upsert` emits dialect-final syntax (Postgres / SQLite
28
+ * `ON CONFLICT ... DO UPDATE`, MySQL `ON DUPLICATE KEY UPDATE`); every
29
+ * other verb stays `?`-placeholder + double-quote and defers the
30
+ * dialect rewrite to `b.clusterStorage`. Joins, common-table
31
+ * expressions, scalar and `IN`/`EXISTS` subqueries, grouping,
32
+ * aggregates, and `RETURNING` are all composable. DDL builders
33
+ * (`createTable` / `createIndex` / `alterTable` / `dropTable`) reuse
34
+ * the framework's own type map so operator app-schema tables get the
35
+ * same quote-by-construction guarantee the framework tables get.
36
+ *
37
+ * Safety defaults are not opt-in: `update` and `delete` THROW without a
38
+ * `where()` unless `allowNoWhere` is set; a column-membership gate
39
+ * refuses unknown columns; `LIKE` auto-escapes `%` / `_` / `\` and
40
+ * emits the matching `ESCAPE`; raw fragments pass through `b.guardSql`
41
+ * (strict by default on the request path) plus the placeholder-count
42
+ * and embedded-literal scanners.
43
+ *
44
+ * @card
45
+ * Chainable SQL builder - every identifier quoted by construction, every value a bound placeholder, dialect-aware upsert.
46
+ */
47
+
48
+ var safeSql = require("./safe-sql");
49
+ var frameworkSchema = require("./framework-schema");
50
+ var safeJson = require("./safe-json");
51
+ var safeJsonPath = require("./safe-jsonpath");
52
+ var lazyRequire = require("./lazy-require");
53
+ var C = require("./constants");
54
+ var { FrameworkError } = require("./framework-error");
55
+
56
+ // Output-validation bounds (enforced by _assertEmittable on every build).
57
+ // Two scopes: statement-level (the whole emitted text + total bind count)
58
+ // and column-level (each individual bound value).
59
+ //
60
+ // MAX_SQL_BYTES: a runaway/DoS ceiling on the emitted statement text - a
61
+ // build this large is a bug (an unbatched bulk insert, a pathological
62
+ // IN-list), never a legitimate single statement. MAX_BIND_PARAMS: the
63
+ // wire-protocol bind-parameter ceiling - Postgres + MySQL cap a statement
64
+ // at 65535 parameters (exceeding it is a hard driver error), and SQLite's
65
+ // default SQLITE_MAX_VARIABLE_NUMBER is 32766 since 3.32; catching it at
66
+ // build surfaces a clear builder error instead of a cryptic driver crash.
67
+ //
68
+ // MAX_PARAM_BYTES: the per-value (column-level) ceiling. A single bound
69
+ // value can be pathologically large WITHOUT tripping MAX_SQL_BYTES, because
70
+ // bound values ride the wire separately - they are not interpolated into
71
+ // the statement text. 64 MiB is MySQL's default max_allowed_packet, the
72
+ // tightest per-value wire boundary across the supported drivers; a value
73
+ // larger than this is a buffer-overflow-class mistake (an unintended whole-
74
+ // file / whole-buffer bind), never a legitimate single column.
75
+ var MAX_SQL_BYTES = C.BYTES.mib(4);
76
+ var MAX_BIND_PARAMS = 65535;
77
+ var MAX_PARAM_BYTES = C.BYTES.mib(64);
78
+
79
+ // b.guardSql is the residual-raw-surface guard (whereRaw / setRaw /
80
+ // having-raw / join-raw / on-raw). It is lazy-required so b.sql does not
81
+ // hard-depend on the guard at module load (the guard module composes
82
+ // gate-contract + db-query helpers and is loaded on first raw use), and
83
+ // so a circular load between the two never wedges boot. The guard is
84
+ // applied by DEFAULT on every raw fragment - strict on the request path
85
+ // - never behind a config flag (security defaults are wired in, not
86
+ // opt-in). Operators with a deliberately benign single-statement read
87
+ // fragment relax via `{ guardProfile: "balanced" }`; the structurally
88
+ // unambiguous refusals (stacked statements, invalid encoding) never
89
+ // relax regardless of profile.
90
+ var guardSql = lazyRequire(function () { return require("./guard-sql"); });
91
+
92
+ /**
93
+ * @primitive b.sql.SqlBuilderError
94
+ * @signature b.sql.SqlBuilderError
95
+ * @since 0.14.29
96
+ * @status stable
97
+ * @related b.safeSql.SafeSqlError, b.sql.select, b.sql.upsert
98
+ *
99
+ * Error thrown by every `b.sql` builder on a bad call shape - an unknown
100
+ * dialect, an invalid identifier, an unconditional `update`/`delete`, a
101
+ * placeholder-count mismatch, an empty value set, a conflicting upsert
102
+ * action, and so on. Extends `FrameworkError` and is always permanent:
103
+ * these are programming / config errors caught at SQL-composition time,
104
+ * well before the query reaches a driver, so retrying never makes them
105
+ * valid. The throw IS the security signal.
106
+ *
107
+ * Carries a stable `.code` with a `sql-builder/` prefix
108
+ * (`sql-builder/bad-dialect`, `sql-builder/no-where`,
109
+ * `sql-builder/placeholder-mismatch`, `sql-builder/empty-values`,
110
+ * `sql-builder/conflict-action`, `sql-builder/unknown-column`, ...) - the
111
+ * slash style mirrors `SafeSqlError`'s codes and stays distinct from the
112
+ * dot-style codes `b.guardSql` raises.
113
+ *
114
+ * @example
115
+ * var b = require("@blamejs/core");
116
+ * try {
117
+ * b.sql.update("users").set({ active: false }).toSql();
118
+ * } catch (e) {
119
+ * e instanceof b.sql.SqlBuilderError; // -> true
120
+ * e.code; // -> "sql-builder/no-where"
121
+ * }
122
+ */
123
+ // Mirrors the in-file error-class convention used by sibling composition
124
+ // modules that subclass FrameworkError directly (safe-sql.js
125
+ // SafeSqlError, cluster-storage.js ClusterStorageError) rather than
126
+ // routing through framework-error.defineClass. An integrator who would
127
+ // rather register it centrally adds
128
+ // `defineClass("SqlBuilderError", { alwaysPermanent: true })` to
129
+ // framework-error.js and re-points this require; the public shape
130
+ // (name / code / permanent / isSqlBuilderError) is identical either way.
131
+ class SqlBuilderError extends FrameworkError {
132
+ constructor(message, code) {
133
+ super(message);
134
+ this.name = "SqlBuilderError";
135
+ this.code = code || "sql-builder/invalid";
136
+ this.permanent = true;
137
+ this.isSqlBuilderError = true;
138
+ }
139
+ }
140
+
141
+ function _err(message, code) {
142
+ return new SqlBuilderError(message, code);
143
+ }
144
+
145
+ // ---- Dialects -------------------------------------------------------
146
+
147
+ var DIALECTS = Object.freeze({ postgres: true, sqlite: true, mysql: true });
148
+
149
+ function _normDialect(dialect) {
150
+ if (dialect === undefined || dialect === null) return "sqlite";
151
+ if (typeof dialect !== "string" || DIALECTS[dialect] !== true) {
152
+ throw _err("dialect must be one of postgres | sqlite | mysql (got " +
153
+ JSON.stringify(dialect) + ")", "sql-builder/bad-dialect");
154
+ }
155
+ return dialect;
156
+ }
157
+
158
+ // MySQL quotes identifiers with backticks; Postgres + SQLite share the
159
+ // SQL-standard double-quote. The quoting below agrees with the framework
160
+ // DDL builder (framework-schema.js), which double-quotes on both backend
161
+ // dialects for the same casing-preservation reason.
162
+ //
163
+ // Validate then wrap an identifier in dialect quotes. The builder accepts
164
+ // reserved-word identifiers BY DESIGN: quoting a name is exactly what
165
+ // makes `from` / `select` / `count` / `key` usable as a real column or
166
+ // table, which is the framework's stated rationale for quote-by-
167
+ // construction (framework-schema.js DDL builder makes the same point).
168
+ // Every identifier the builder emits is quoted through the framework's
169
+ // single identifier primitive - b.safeSql.quoteIdentifier - with
170
+ // allowReserved on: quoting is exactly what makes a SQL-keyword column
171
+ // (e.g. `from`) safe in identifier position, and the builder admits
172
+ // reserved names by design. quoteIdentifier still enforces shape /
173
+ // length / null-byte / sqlite_-prefix rules, so nothing is weakened and
174
+ // the builder composes the primitive rather than reinventing quoting.
175
+ function _quoteId(name, dialect) {
176
+ return safeSql.quoteIdentifier(name, dialect, { allowReserved: true });
177
+ }
178
+
179
+ // ---- DDL logical-type map -------------------------------------------
180
+ //
181
+ // The framework's own DDL builder (framework-schema.js `_types`) is the
182
+ // single source of truth for the two column types that diverge across
183
+ // dialects - the integer and binary tokens. b.sql consumes that map for
184
+ // operator app-schema parity rather than forking it: postgres BIGINT /
185
+ // BYTEA, sqlite INTEGER / BLOB. _types covers postgres + sqlite only
186
+ // (the framework's two backend dialects); MySQL is a b.sql-only DDL
187
+ // target, so its divergent tokens are mapped here. JSON diverges three
188
+ // ways (postgres JSONB / mysql JSON / sqlite TEXT) and is handled in
189
+ // _ddlType; the remaining tokens (TEXT / BOOLEAN / REAL / NUMERIC /
190
+ // TIMESTAMP) resolve uniformly. If framework-schema later exports `_types`, this
191
+ // reads it directly; until then the postgres/sqlite INT/BLOB values are
192
+ // kept byte-identical to framework-schema._types so there is exactly one
193
+ // definition of each token in the shipped tree.
194
+ var _schemaTypes = (typeof frameworkSchema._types === "function")
195
+ ? frameworkSchema._types
196
+ : function (dialect) {
197
+ if (dialect === "postgres") return { INT: "BIGINT", BLOB: "BYTEA" };
198
+ if (dialect === "sqlite") return { INT: "INTEGER", BLOB: "BLOB" };
199
+ throw _err("framework type map has no entry for dialect '" + dialect + "'",
200
+ "sql-builder/bad-dialect");
201
+ };
202
+
203
+ // Logical type vocabulary -> dialect-final SQL type token. INT/BLOB
204
+ // delegate to the framework map (or its MySQL extension); the rest are
205
+ // dialect-invariant. Callers pass a logical name (case-insensitive) OR a
206
+ // verbatim type string - a string the vocabulary does not recognise is
207
+ // emitted as-is so operators can declare a dialect-specific type the map
208
+ // does not enumerate (it is still placed after a quoted column name, so
209
+ // no identifier injection is possible).
210
+ function _ddlType(logical, dialect) {
211
+ if (typeof logical !== "string" || logical.length === 0) {
212
+ throw _err("column type must be a non-empty string", "sql-builder/bad-type");
213
+ }
214
+ var key = logical.toUpperCase();
215
+ var divergent;
216
+ if (key === "INT" || key === "INTEGER" || key === "BIGINT") {
217
+ divergent = (dialect === "mysql") ? { INT: "BIGINT" } : _schemaTypes(dialect);
218
+ return divergent.INT;
219
+ }
220
+ if (key === "BLOB" || key === "BYTEA" || key === "BINARY") {
221
+ divergent = (dialect === "mysql") ? { BLOB: "LONGBLOB" } : _schemaTypes(dialect);
222
+ return divergent.BLOB;
223
+ }
224
+ if (key === "TEXT" || key === "STRING") return "TEXT";
225
+ if (key === "BOOLEAN" || key === "BOOL") return "BOOLEAN";
226
+ if (key === "REAL" || key === "FLOAT" || key === "DOUBLE") return "REAL";
227
+ if (key === "NUMERIC" || key === "DECIMAL") return "NUMERIC";
228
+ if (key === "TIMESTAMP") return "TIMESTAMP";
229
+ if (key === "JSON") {
230
+ return dialect === "postgres" ? "JSONB" : (dialect === "mysql" ? "JSON" : "TEXT");
231
+ }
232
+ // Unrecognised: a verbatim dialect-specific type. Emitted as the
233
+ // operator wrote it (it follows a quoted identifier, so it is in type
234
+ // position, never identifier position).
235
+ return logical;
236
+ }
237
+
238
+ // ---- Operators ------------------------------------------------------
239
+ //
240
+ // Shared with the executing query builder (lib/db-query.js ALLOWED_OPS):
241
+ // comparison, IS/IS NOT, LIKE/NOT LIKE, IN/NOT IN, BETWEEN, and the
242
+ // Postgres JSONB containment + key-existence operators. Operator-supplied
243
+ // op strings are validated against this allowlist so no operator token
244
+ // reaches the SQL except one of these exact strings.
245
+ var ALLOWED_OPS = Object.freeze({
246
+ "=": true, "!=": true, "<>": true, "<": true, "<=": true, ">": true, ">=": true,
247
+ "IS": true, "IS NOT": true, "LIKE": true, "NOT LIKE": true,
248
+ "IN": true, "NOT IN": true, "BETWEEN": true,
249
+ "@>": true, "?": true, "?|": true, "?&": true,
250
+ // sqlite FTS5 full-text match - `<fts-table-or-column> MATCH ?`. The
251
+ // operand (the FTS5 query expression) ALWAYS binds as a single `?`;
252
+ // build-gated to the sqlite dialect in _cmp (no Postgres / MySQL form).
253
+ "MATCH": true,
254
+ });
255
+
256
+ var JOIN_KINDS = Object.freeze({
257
+ INNER: "INNER JOIN", LEFT: "LEFT JOIN", RIGHT: "RIGHT JOIN",
258
+ FULL: "FULL JOIN", CROSS: "CROSS JOIN",
259
+ });
260
+
261
+ // ---- Identifier helpers ---------------------------------------------
262
+
263
+ function _validateColumn(col) {
264
+ if (typeof col !== "string" || col.length === 0) {
265
+ throw _err("column name must be a non-empty string", "sql-builder/bad-column");
266
+ }
267
+ // Routes through safeSql so the shape / length / reserved-word /
268
+ // null-byte rules are the framework's single identifier policy.
269
+ safeSql.validateIdentifier(col, { allowReserved: true });
270
+ return col;
271
+ }
272
+
273
+ // ---- Table reference ------------------------------------------------
274
+ //
275
+ // Bare DEFAULT logical names stay UNQUOTED so clusterStorage.resolveTables
276
+ // can rewrite them to the cluster-prefixed form (a quoted name would not
277
+ // match its bare-identifier scan). A custom prefix or a schema qualifier
278
+ // is validated + quoted at build time - an invalid identifier throws
279
+ // here, at config time, where the operator catches the typo at boot.
280
+ // Two-segment qualified names (schema.table) are the maximum.
281
+ function _normTableRef(name, opts) {
282
+ opts = opts || {};
283
+ if (name instanceof TableRef) return name;
284
+ if (typeof name !== "string" || name.length === 0) {
285
+ throw _err("table name must be a non-empty string", "sql-builder/bad-table");
286
+ }
287
+ var schema = opts.schema || null;
288
+ var table = name;
289
+ if (schema === null && name.indexOf(".") !== -1) {
290
+ var dotParts = name.split(".");
291
+ if (dotParts.length !== 2 || dotParts[0].length === 0 || dotParts[1].length === 0) {
292
+ throw _err("schema-qualified table must be exactly 'schema.table' (got '" +
293
+ name + "')", "sql-builder/bad-table");
294
+ }
295
+ schema = dotParts[0];
296
+ table = dotParts[1];
297
+ }
298
+ return new TableRef(table, {
299
+ schema: schema,
300
+ prefix: opts.prefix !== undefined ? opts.prefix : (opts.tablePrefix || null),
301
+ alias: opts.alias || null,
302
+ quoteName: opts.quoteName === true,
303
+ });
304
+ }
305
+
306
+ /**
307
+ * @primitive b.sql.table
308
+ * @signature b.sql.table(name, opts?)
309
+ * @since 0.14.29
310
+ * @status stable
311
+ * @related b.sql.select, b.clusterStorage.resolveTables
312
+ *
313
+ * Build a table reference. A bare default logical name
314
+ * (`b.sql.table("audit_log")`) stays UNQUOTED in the emitted SQL so
315
+ * `b.clusterStorage` can rewrite it to the cluster-prefixed name. A
316
+ * schema qualifier (`{ schema: "public" }` or the dotted form
317
+ * `"public.users"`) or an operator app-table `prefix` is validated and
318
+ * quoted at build time - a bad identifier throws immediately. The
319
+ * `prefix` here is operator app-table namespacing, distinct from the
320
+ * framework's internal `_blamejs_` prefix; it is prepended to the table
321
+ * name and the whole result is quoted as one identifier. At most two
322
+ * segments (schema.table). An `alias` is quoted and appended for joins.
323
+ *
324
+ * @opts
325
+ * schema: string, // schema qualifier, quoted at build time
326
+ * prefix: string, // operator app-table namespace, prepended then quoted
327
+ * alias: string, // table alias, used to disambiguate joins
328
+ *
329
+ * @example
330
+ * var b = require("@blamejs/core");
331
+ * b.sql.table("audit_log").toString("sqlite");
332
+ * // -> "audit_log" (bare default - clusterStorage rewrites)
333
+ *
334
+ * b.sql.table("users", { schema: "public" }).toString("postgres");
335
+ * // -> '"public"."users"'
336
+ *
337
+ * b.sql.table("orders", { prefix: "shopX_" }).toString("sqlite");
338
+ * // -> '"shopX_orders"'
339
+ */
340
+ function table(name, opts) {
341
+ return _normTableRef(name, opts);
342
+ }
343
+
344
+ class TableRef {
345
+ constructor(name, opts) {
346
+ opts = opts || {};
347
+ if (typeof name !== "string" || name.length === 0) {
348
+ throw _err("table name must be a non-empty string", "sql-builder/bad-table");
349
+ }
350
+ this._schema = opts.schema || null;
351
+ this._prefix = opts.prefix || null;
352
+ this._alias = opts.alias || null;
353
+ // quoteName forces a bare default name to be quoted. The bare-default
354
+ // name normally stays UNQUOTED so b.clusterStorage's resolveTables can
355
+ // rewrite it to the cluster-prefixed form (a quoted name would not
356
+ // match its bare-identifier scan). A LOCAL-only consumer that does NO
357
+ // cluster rewrite (the executing query builder's single-node sqlite
358
+ // path) opts into quoting so a reserved-word / case-sensitive table
359
+ // name still emits a valid `"name"` identifier - quoting is exactly
360
+ // what makes a SQL-keyword table name safe in identifier position.
361
+ this._quoteName = opts.quoteName === true;
362
+ // A custom prefix is validated as an identifier and prepended; the
363
+ // combined name is then a single quoted identifier. The bare default
364
+ // (no prefix, no schema) stays unquoted for clusterStorage.
365
+ if (this._prefix !== null) {
366
+ _validateColumn(this._prefix);
367
+ this._name = this._prefix + name;
368
+ this._bare = false;
369
+ } else {
370
+ this._name = name;
371
+ this._bare = this._schema === null && !this._quoteName;
372
+ }
373
+ if (this._schema !== null) safeSql.validateIdentifier(this._schema, { allowReserved: true });
374
+ if (this._alias !== null) safeSql.validateIdentifier(this._alias, { allowReserved: true });
375
+ // Validate the base name shape even for the bare default - an
376
+ // attacker-influenced logical name still must be a real identifier.
377
+ safeSql.validateIdentifier(this._name, { allowReserved: true });
378
+ }
379
+
380
+ // The reference as it appears in FROM / INTO / UPDATE / JOIN. Bare
381
+ // default names stay unquoted (clusterStorage rewrite target); custom
382
+ // / schema-qualified names are quoted. Alias is never part of the
383
+ // resolution target - added separately where an alias is legal.
384
+ ref(dialect) {
385
+ if (this._schema !== null) {
386
+ return _quoteId(this._schema, dialect) + "." + _quoteId(this._name, dialect);
387
+ }
388
+ if (this._bare) return this._name;
389
+ return _quoteId(this._name, dialect);
390
+ }
391
+
392
+ // ref() plus a quoted alias, for FROM / JOIN where an alias is legal.
393
+ refWithAlias(dialect) {
394
+ var base = this.ref(dialect);
395
+ return this._alias !== null ? base + " " + _quoteId(this._alias, dialect) : base;
396
+ }
397
+
398
+ // The identifier columns are qualified against - the alias when set,
399
+ // else the resolution target.
400
+ qualifier(dialect) {
401
+ if (this._alias !== null) return _quoteId(this._alias, dialect);
402
+ return this.ref(dialect);
403
+ }
404
+
405
+ toString(dialect) {
406
+ return this.refWithAlias(_normDialect(dialect));
407
+ }
408
+ }
409
+
410
+ // ---- Allowlisted SQL function literals ------------------------------
411
+ //
412
+ // A small set of nullary, side-effect-free SQL function tokens an operator
413
+ // commonly wants in INSERT VALUES / SET RHS (a server-side timestamp) but
414
+ // which CANNOT be a bound `?` parameter (a `?` binds a value; these emit a
415
+ // keyword the engine evaluates). Rather than open a raw-fragment hole on
416
+ // the values path, b.sql.fn(name) wraps EXACTLY one of these allowlisted
417
+ // tokens - an unknown name throws, so no arbitrary expression reaches a
418
+ // VALUES / SET position. The token is dialect-checked at emit (NOW() is
419
+ // Postgres / MySQL; CURRENT_TIMESTAMP is portable). It is NOT a value -
420
+ // it consumes no `?` and contributes no param.
421
+ var SQL_FUNCTIONS = Object.freeze({
422
+ "NOW": { sql: "NOW()", dialects: { postgres: true, mysql: true } },
423
+ "CURRENT_TIMESTAMP": { sql: "CURRENT_TIMESTAMP", dialects: { postgres: true, sqlite: true, mysql: true } },
424
+ "CURRENT_DATE": { sql: "CURRENT_DATE", dialects: { postgres: true, sqlite: true, mysql: true } },
425
+ "CURRENT_TIME": { sql: "CURRENT_TIME", dialects: { postgres: true, sqlite: true, mysql: true } },
426
+ });
427
+
428
+ class SqlFunction {
429
+ constructor(name) {
430
+ if (typeof name !== "string") {
431
+ throw _err("b.sql.fn(name): name must be a string", "sql-builder/bad-fn");
432
+ }
433
+ var key = name.toUpperCase();
434
+ if (SQL_FUNCTIONS[key] === undefined) {
435
+ throw _err("b.sql.fn(name): '" + name + "' is not an allowlisted SQL function " +
436
+ "(NOW / CURRENT_TIMESTAMP / CURRENT_DATE / CURRENT_TIME); a bound value uses a ? " +
437
+ "placeholder, an arbitrary expression uses a guarded raw fragment", "sql-builder/bad-fn");
438
+ }
439
+ this._key = key;
440
+ this.isSqlFunction = true;
441
+ }
442
+ // The SQL token for the builder's dialect; throws when the function is
443
+ // not available on that backend (NOW() on sqlite has no portable form).
444
+ toSqlToken(dialect) {
445
+ var def = SQL_FUNCTIONS[this._key];
446
+ if (def.dialects[dialect] !== true) {
447
+ throw _err("b.sql.fn('" + this._key + "') is not available on " + dialect +
448
+ " (use CURRENT_TIMESTAMP for a portable server timestamp)", "sql-builder/fn-unsupported");
449
+ }
450
+ return def.sql;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * @primitive b.sql.fn
456
+ * @signature b.sql.fn(name)
457
+ * @since 0.15.0
458
+ * @status stable
459
+ * @related b.sql.insert, b.sql.update, b.sql.cast
460
+ *
461
+ * Wrap an allowlisted, nullary, side-effect-free SQL function token for use
462
+ * as an INSERT `values()` / UPDATE `set()` right-hand side - a value
463
+ * position that must emit a keyword the engine evaluates server-side (a
464
+ * `NOW()` timestamp) rather than a bound `?` parameter. The allowlist is
465
+ * exactly `NOW` / `CURRENT_TIMESTAMP` / `CURRENT_DATE` / `CURRENT_TIME`; an
466
+ * unknown name throws, so no arbitrary expression reaches a VALUES / SET
467
+ * position. The token is dialect-checked at emit (`NOW()` is Postgres /
468
+ * MySQL; `CURRENT_TIMESTAMP` is portable). The wrapped function consumes
469
+ * no `?` and contributes no param.
470
+ *
471
+ * @example
472
+ * var b = require("@blamejs/core");
473
+ * b.sql.insert("events")
474
+ * .values({ topic: "x", at: b.sql.fn("CURRENT_TIMESTAMP") })
475
+ * .toSql();
476
+ * // -> { sql: 'INSERT INTO events ("topic", "at") VALUES (?, CURRENT_TIMESTAMP)',
477
+ * // params: ["x"] }
478
+ */
479
+ function fn(name) { return new SqlFunction(name); }
480
+
481
+ // ---- Allowlisted column casts ---------------------------------------
482
+ //
483
+ // A `col::type` / `?::type` cast applies an allowlisted target type to a
484
+ // quoted column or a bound `?` placeholder. The cast TYPE is matched
485
+ // against a fixed vocabulary (no operator-supplied type token reaches the
486
+ // SQL), and the LHS is either a quoted identifier or a single bound
487
+ // placeholder - never raw text. Postgres `::` is the canonical form; the
488
+ // same vocabulary maps to a portable form where one exists (jsonb -> json
489
+ // on a non-Postgres backend that has it; interval has no portable cast and
490
+ // is Postgres-only).
491
+ var CAST_TYPES = Object.freeze({
492
+ "jsonb": { postgres: "jsonb", mysql: "json", sqlite: null },
493
+ "json": { postgres: "json", mysql: "json", sqlite: null },
494
+ "interval": { postgres: "interval", mysql: null, sqlite: null },
495
+ "uuid": { postgres: "uuid", mysql: null, sqlite: null },
496
+ "text": { postgres: "text", mysql: "char", sqlite: "text" },
497
+ "int": { postgres: "integer", mysql: "signed", sqlite: "integer" },
498
+ "bigint": { postgres: "bigint", mysql: "signed", sqlite: "integer" },
499
+ "timestamptz": { postgres: "timestamptz", mysql: null, sqlite: null },
500
+ "boolean": { postgres: "boolean", mysql: null, sqlite: null },
501
+ });
502
+
503
+ function _castType(type, dialect) {
504
+ if (typeof type !== "string" || type.length === 0) {
505
+ throw _err("cast type must be a non-empty string", "sql-builder/bad-cast");
506
+ }
507
+ var key = type.toLowerCase();
508
+ if (CAST_TYPES[key] === undefined) {
509
+ throw _err("cast type '" + type + "' is not on the allowlist (jsonb / json / " +
510
+ "interval / uuid / text / int / bigint / timestamptz / boolean)", "sql-builder/bad-cast");
511
+ }
512
+ var target = CAST_TYPES[key][dialect];
513
+ if (target === null || target === undefined) {
514
+ throw _err("cast to '" + type + "' has no portable form on " + dialect +
515
+ " (it is Postgres-only)", "sql-builder/cast-unsupported");
516
+ }
517
+ return target;
518
+ }
519
+
520
+ // Render the dialect-correct cast suffix for a bound `?` placeholder or a
521
+ // quoted column. Postgres uses the `::type` operator; MySQL has no `::`,
522
+ // so a cast there wraps in CAST(<lhs> AS <type>). SQLite is weakly typed -
523
+ // the small set of casts portable to sqlite (text / int) emit
524
+ // CAST(<lhs> AS <type>) too; a sqlite-unsupported cast already threw in
525
+ // _castType.
526
+ function _renderCast(lhs, type, dialect) {
527
+ var target = _castType(type, dialect);
528
+ if (dialect === "postgres") return lhs + "::" + target;
529
+ return "CAST(" + lhs + " AS " + target + ")";
530
+ }
531
+
532
+ // A value wrapped for binding-with-cast: the value binds as a single `?`
533
+ // and the placeholder carries the dialect cast (`?::jsonb` on Postgres).
534
+ class CastValue {
535
+ constructor(value, type) {
536
+ // Eager allowlist-membership check so a typo'd type token fails at the
537
+ // call site (the entry-point THROW tier), not deep inside a later
538
+ // toSql(). The dialect-portability check (interval / uuid are
539
+ // Postgres-only) stays at render time, where the target dialect is known.
540
+ if (typeof type !== "string" || type.length === 0) {
541
+ throw _err("cast type must be a non-empty string", "sql-builder/bad-cast");
542
+ }
543
+ if (CAST_TYPES[type.toLowerCase()] === undefined) {
544
+ throw _err("cast type '" + type + "' is not on the allowlist (jsonb / json / " +
545
+ "interval / uuid / text / int / bigint / timestamptz / boolean)", "sql-builder/bad-cast");
546
+ }
547
+ this.value = value;
548
+ this.type = type;
549
+ this.isCastValue = true;
550
+ }
551
+ }
552
+
553
+ /**
554
+ * @primitive b.sql.cast
555
+ * @signature b.sql.cast(value, type)
556
+ * @since 0.15.0
557
+ * @status stable
558
+ * @related b.sql.insert, b.sql.update, b.sql.fn
559
+ *
560
+ * Wrap a value so it binds as a single `?` placeholder carrying a
561
+ * dialect-correct cast - `?::jsonb` on Postgres, `CAST(? AS json)` on
562
+ * MySQL. The cast TYPE is matched against a fixed allowlist (`jsonb` /
563
+ * `json` / `interval` / `uuid` / `text` / `int` / `bigint` / `timestamptz`
564
+ * / `boolean`); an unknown type, or one with no portable form on the
565
+ * target dialect (`interval` / `uuid` are Postgres-only), throws at build.
566
+ * Use it for an INSERT `values()` / UPDATE `set()` cell that must coerce a
567
+ * bound string into a typed column (a JSON string into a `jsonb` column, a
568
+ * duration string into an `interval`).
569
+ *
570
+ * @example
571
+ * var b = require("@blamejs/core");
572
+ * b.sql.insert("docs", { dialect: "postgres" })
573
+ * .values({ id: 1, meta: b.sql.cast('{"a":1}', "jsonb") })
574
+ * .toSql();
575
+ * // -> { sql: 'INSERT INTO docs ("id", "meta") VALUES (?, ?::jsonb)',
576
+ * // params: [1, '{"a":1}'] }
577
+ */
578
+ function cast(value, type) { return new CastValue(value, type); }
579
+
580
+ // Render a single value cell for an INSERT VALUES / UPDATE SET RHS.
581
+ // Returns { sql, params } where sql is `?` for a bound value, `?::type`
582
+ // for a CastValue, or the dialect function token for a SqlFunction (no
583
+ // param). The single choke-point both insert and update value paths use,
584
+ // so the allowlisted-function / cast handling lives in one place.
585
+ function _renderValueCell(value, dialect) {
586
+ if (value instanceof SqlFunction) {
587
+ return { sql: value.toSqlToken(dialect), params: [] };
588
+ }
589
+ if (value instanceof CastValue) {
590
+ return { sql: _renderCast("?", value.type, dialect), params: [value.value] };
591
+ }
592
+ return { sql: "?", params: [value] };
593
+ }
594
+
595
+ // ---- Value-binding helpers ------------------------------------------
596
+ //
597
+ // Every value is pushed to a params array and represented in the SQL by
598
+ // a `?`. A b.sql builder used as a subquery contributes its placeholders
599
+ // in left-to-right order, so a params array concatenation is always
600
+ // correct as long as fragments are appended in emission order.
601
+
602
+ // A column reference qualified column expression. Accepts "col" or
603
+ // "alias.col" / "table.col" - both segments validated + quoted.
604
+ function _qualifiedColumn(expr, dialect) {
605
+ if (typeof expr !== "string" || expr.length === 0) {
606
+ throw _err("column expression must be a non-empty string", "sql-builder/bad-column");
607
+ }
608
+ if (expr.indexOf(".") !== -1) {
609
+ var parts = expr.split(".");
610
+ if (parts.length !== 2 || parts[0].length === 0 || parts[1].length === 0) {
611
+ throw _err("qualified column must be 'qualifier.column' (got '" + expr + "')",
612
+ "sql-builder/bad-column");
613
+ }
614
+ safeSql.validateIdentifier(parts[0], { allowReserved: true });
615
+ safeSql.validateIdentifier(parts[1], { allowReserved: true });
616
+ return _quoteId(parts[0], dialect) + "." + _quoteId(parts[1], dialect);
617
+ }
618
+ _validateColumn(expr);
619
+ return _quoteId(expr, dialect);
620
+ }
621
+
622
+ // LIKE auto-escape. Escapes %, _, and the escape char itself in an
623
+ // operator-supplied LIKE value so a stray % can't widen the match into a
624
+ // full-table disclosure. The escape char is `~`, NOT backslash: MySQL with
625
+ // the default sql_mode treats backslash as a string-literal escape, so
626
+ // `ESCAPE '\'` reads as an unterminated literal and parse-errors - `~` is
627
+ // parser-mode-independent across SQLite / Postgres / MySQL. Mirrors
628
+ // db-query.js's LIKE handling.
629
+ function _escapeLike(value) {
630
+ return String(value).replace(/[~%_]/g, "~$&");
631
+ }
632
+
633
+ // Compose a sub-builder into a parent statement. The builder quotes
634
+ // identifiers eagerly (at the columns() / where() call), so a sub built with
635
+ // a different dialect than the parent has already baked in the wrong quote
636
+ // char - splicing it would emit mixed quoting the wrong backend mis-reads
637
+ // (a default-sqlite sub's "id" inside a mysql parent, or a mysql sub's `id`
638
+ // inside a postgres parent). Refuse the mismatch loudly at build rather than
639
+ // ship a corrupt statement; the operator builds the sub with the matching
640
+ // { dialect } so the whole statement is one dialect. A sub left at the
641
+ // default (sqlite) composes cleanly into a default (sqlite) parent.
642
+ function _composeSub(subBuilder, parentDialect) {
643
+ if (subBuilder._dialect !== parentDialect) {
644
+ throw _err("sub-query dialect '" + subBuilder._dialect + "' does not match the " +
645
+ "parent statement's dialect '" + parentDialect + "' - build the composed " +
646
+ "sub-query with { dialect: '" + parentDialect + "' } so the whole statement " +
647
+ "is one dialect", "sql-builder/dialect-mismatch");
648
+ }
649
+ return subBuilder.toSql();
650
+ }
651
+
652
+ // The Postgres JSONB operators. Two shared dialect-design gates compose
653
+ // over this set so every emission site (the value-comparison _cmp path,
654
+ // scalar-subquery comparison, join-ON) enforces the same rule.
655
+ var JSONB_OPS = Object.freeze({ "@>": true, "?": true, "?|": true, "?&": true });
656
+
657
+ // Build-time refusal: a JSONB operator on a non-Postgres builder would emit
658
+ // jsonb_exists* / @> to a backend that has neither (downstream regression).
659
+ function _assertJsonbDialect(op, dialect) {
660
+ if (JSONB_OPS[op] === true && dialect !== "postgres") {
661
+ throw _err("the '" + op + "' JSONB operator is Postgres-only (no portable " +
662
+ "SQLite / MySQL equivalent); build this query with { dialect: 'postgres' }",
663
+ "sql-builder/jsonb-postgres-only");
664
+ }
665
+ }
666
+
667
+ // Build-time refusal: a JSONB operator in a position that has no
668
+ // jsonb_exists* rewrite (scalar-subquery comparison, join-ON) - the bare
669
+ // operator would splice in and the wrong backend mis-reads it (and a bare
670
+ // `?` collides with the placeholder marker even on Postgres).
671
+ function _refuseJsonbOp(op, position) {
672
+ if (JSONB_OPS[op] === true) {
673
+ throw _err("the '" + op + "' JSONB operator is not supported in " + position +
674
+ "; use where(col, '" + op + "', value) on a Postgres builder",
675
+ "sql-builder/jsonb-bad-position");
676
+ }
677
+ }
678
+
679
+ // ---- Condition tree (WHERE / HAVING / JOIN-ON) ----------------------
680
+ //
681
+ // A predicate group is an ordered list of leaves joined by AND / OR.
682
+ // Each leaf carries its own SQL fragment + params; nesting is a leaf
683
+ // whose fragment is a parenthesised sub-group. This is the structure
684
+ // every where/having/on clause and every whereGroup closure builds.
685
+
686
+ class Predicate {
687
+ constructor(owner, joinerDefault) {
688
+ this._owner = owner; // the builder, for the column gate
689
+ this._joiner = joinerDefault || "AND";
690
+ this._parts = []; // [{ joiner, sql, params }]
691
+ }
692
+
693
+ _gate(col) {
694
+ if (this._owner && typeof this._owner._assertColumnMember === "function") {
695
+ this._owner._assertColumnMember(col, "where");
696
+ }
697
+ }
698
+
699
+ _dialect() {
700
+ return this._owner ? this._owner._dialect : "sqlite";
701
+ }
702
+
703
+ _add(joiner, sql, params) {
704
+ this._parts.push({ joiner: joiner, sql: sql, params: params || [] });
705
+ return this;
706
+ }
707
+
708
+ // Core comparison. op validated against ALLOWED_OPS; the JSONB key-
709
+ // existence operators emit the jsonb_exists* function family (see below).
710
+ _cmp(joiner, col, op, value) {
711
+ if (ALLOWED_OPS[op] !== true) {
712
+ throw _err("invalid where operator '" + op + "'", "sql-builder/bad-operator");
713
+ }
714
+ this._gate(col);
715
+ var dialect = this._dialect();
716
+ var qc = _qualifiedColumn(col, dialect);
717
+
718
+ // Dialect-design gate: the JSONB containment (@>) + key-existence (?, ?|,
719
+ // ?&) operators are Postgres-only - the JSONB type, the jsonb_exists*
720
+ // functions, and @> containment have no portable SQLite / MySQL form.
721
+ // Emitting them for a non-Postgres backend silently regresses downstream
722
+ // (no such function / unknown operator at execute), so refuse at build.
723
+ _assertJsonbDialect(op, dialect);
724
+
725
+ // JSONB / JSON-path injection guard + placeholder-safe emission
726
+ // (inherited + hardened from the executing query builder). The Postgres
727
+ // JSONB containment (@>) and key-existence (?, ?|, ?&) operators take an
728
+ // operator-supplied operand the engine compares verbatim; route it
729
+ // through safeJsonPath so NUL / control / bidi / zero-width characters a
730
+ // driver might silently strip can't smuggle into the JSON-shape compare.
731
+ //
732
+ // The key-existence operators are emitted as the jsonb_exists* FUNCTION
733
+ // family, not the literal `?` / `?|` / `?&` operator: a literal `?`
734
+ // collides with the `?` bind-placeholder marker, so placeholderize would
735
+ // rewrite the operator itself to `$N` and corrupt the query. The operand
736
+ // always binds via a single `?` placeholder.
737
+ if (op === "@>") {
738
+ if (typeof value === "string") {
739
+ var parsedContainment;
740
+ try { parsedContainment = safeJson.parse(value); }
741
+ catch (e) {
742
+ throw _err("where '@>' value: invalid JSON string: " + ((e && e.message) || String(e)),
743
+ "sql-builder/bad-jsonb-value");
744
+ }
745
+ safeJsonPath.validateContainment(parsedContainment);
746
+ } else {
747
+ safeJsonPath.validateContainment(value);
748
+ // Bind the canonical-shape JSON so the driver sees the bytes we
749
+ // just walked end-to-end.
750
+ value = JSON.stringify(value);
751
+ }
752
+ } else if (op === "?") {
753
+ if (typeof value !== "string") {
754
+ throw _err("where '?' requires a string key (got " + (typeof value) + ")",
755
+ "sql-builder/bad-jsonb-key");
756
+ }
757
+ safeJsonPath.validateKey(value);
758
+ return this._add(joiner, "jsonb_exists(" + qc + ", ?)", [value]);
759
+ } else if (op === "?|" || op === "?&") {
760
+ if (!Array.isArray(value) || value.length === 0) {
761
+ throw _err("'" + op + "' requires a non-empty array of keys", "sql-builder/bad-jsonb-keys");
762
+ }
763
+ for (var ki = 0; ki < value.length; ki += 1) safeJsonPath.validateKey(value[ki]);
764
+ var jsonbExistsFn = op === "?|" ? "jsonb_exists_any" : "jsonb_exists_all";
765
+ return this._add(joiner, jsonbExistsFn + "(" + qc + ", ?)", [value.slice()]);
766
+ }
767
+
768
+ if (op === "IN" || op === "NOT IN") {
769
+ if (value instanceof Builder) {
770
+ var sub = _composeSub(value, this._dialect());
771
+ return this._add(joiner, qc + " " + op + " (" + sub.sql + ")", sub.params);
772
+ }
773
+ if (!Array.isArray(value) || value.length === 0) {
774
+ throw _err(op + " requires a non-empty array of values (or a subquery builder)",
775
+ "sql-builder/empty-in");
776
+ }
777
+ var holders = value.map(function () { return "?"; }).join(", ");
778
+ return this._add(joiner, qc + " " + op + " (" + holders + ")", value.slice());
779
+ }
780
+
781
+ if (op === "BETWEEN") {
782
+ if (!Array.isArray(value) || value.length !== 2) {
783
+ throw _err("BETWEEN requires a [low, high] pair", "sql-builder/bad-between");
784
+ }
785
+ return this._add(joiner, qc + " BETWEEN ? AND ?", [value[0], value[1]]);
786
+ }
787
+
788
+ if ((op === "IS" || op === "IS NOT") && value === null) {
789
+ // IS NULL / IS NOT NULL - no placeholder, no param.
790
+ return this._add(joiner, qc + " " + op + " NULL", []);
791
+ }
792
+
793
+ if ((op === "LIKE" || op === "NOT LIKE") && typeof value === "string") {
794
+ return this._add(joiner, qc + " " + op + " ? ESCAPE '~'", [_escapeLike(value)]);
795
+ }
796
+
797
+ // sqlite FTS5 `<fts-table-or-column> MATCH ?`. The full-text query
798
+ // expression ALWAYS binds as a single `?` - never interpolated - so an
799
+ // operator-supplied search term cannot reshape the statement. MATCH has
800
+ // no portable Postgres / MySQL form (Postgres uses to_tsvector @@
801
+ // to_tsquery; MySQL uses MATCH ... AGAINST with different grammar), so
802
+ // refuse a non-sqlite dialect at build, the config-time tier.
803
+ if (op === "MATCH") {
804
+ if (dialect !== "sqlite") {
805
+ throw _err("the MATCH full-text operator is sqlite-FTS5-only (no portable " +
806
+ "Postgres / MySQL form); build this query with { dialect: 'sqlite' }",
807
+ "sql-builder/match-sqlite-only");
808
+ }
809
+ if (typeof value !== "string" || value.length === 0) {
810
+ throw _err("MATCH requires a non-empty FTS5 query string", "sql-builder/bad-match");
811
+ }
812
+ return this._add(joiner, qc + " MATCH ?", [value]);
813
+ }
814
+
815
+ return this._add(joiner, qc + " " + op + " ?", [value]);
816
+ }
817
+
818
+ // where(field, val) / where(field, op, val) / where({ ... }).
819
+ where(fieldOrObj, op, value) {
820
+ if (fieldOrObj && typeof fieldOrObj === "object" && !(fieldOrObj instanceof Builder)) {
821
+ var self = this;
822
+ Object.keys(fieldOrObj).forEach(function (k) { self._cmp("AND", k, "=", fieldOrObj[k]); });
823
+ return this;
824
+ }
825
+ if (arguments.length === 2) return this._cmp("AND", fieldOrObj, "=", op);
826
+ return this._cmp("AND", fieldOrObj, op, value);
827
+ }
828
+ andWhere(fieldOrObj, op, value) { return this.where(fieldOrObj, op, value); }
829
+ orWhere(fieldOrObj, op, value) {
830
+ if (fieldOrObj && typeof fieldOrObj === "object" && !(fieldOrObj instanceof Builder)) {
831
+ var self = this;
832
+ Object.keys(fieldOrObj).forEach(function (k) { self._cmp("OR", k, "=", fieldOrObj[k]); });
833
+ return this;
834
+ }
835
+ if (arguments.length === 2) return this._cmp("OR", fieldOrObj, "=", op);
836
+ return this._cmp("OR", fieldOrObj, op, value);
837
+ }
838
+
839
+ whereOp(col, op, value) { return this._cmp("AND", col, op, value); }
840
+ orWhereOp(col, op, value) { return this._cmp("OR", col, op, value); }
841
+
842
+ // LIKE with caller-controlled match mode. The structured LIKE in _cmp
843
+ // escapes the WHOLE bound value (so a `%` in the value is a literal
844
+ // percent, never a wildcard) - correct for an exact compare but it
845
+ // can't express a "contains" / "starts-with" search where the wrapping
846
+ // `%` MUST stay a live wildcard while the user's own `%` / `_` stay
847
+ // escaped. This helper bridges that: it escapes the user term's
848
+ // metacharacters with `~` (matching _cmp's escape char) and then adds
849
+ // the LIVE wrapping wildcard per mode, binding the assembled pattern.
850
+ // It emits the SAME `col LIKE ? ESCAPE '~'` form _cmp does - the ESCAPE
851
+ // literal is builder-emitted (not a raw fragment), so it is not subject
852
+ // to the raw-fragment guard's embedded-literal refusal. Mode is
853
+ // "substring" (default, `%term%`), "prefix" (`term%`), or "exact"
854
+ // (`term`, equivalent to a structured LIKE but spelled as a search).
855
+ whereLike(col, term, mode) { return this._like("AND", col, term, mode); }
856
+ orWhereLike(col, term, mode) { return this._like("OR", col, term, mode); }
857
+ _like(joiner, col, term, mode) {
858
+ if (typeof term !== "string") {
859
+ throw _err("whereLike requires a string term (got " + (typeof term) + ")",
860
+ "sql-builder/bad-like-term");
861
+ }
862
+ this._gate(col);
863
+ var qc = _qualifiedColumn(col, this._dialect());
864
+ var escaped = _escapeLike(term);
865
+ var pattern;
866
+ var m = mode || "substring";
867
+ if (m === "exact") pattern = escaped;
868
+ else if (m === "prefix") pattern = escaped + "%";
869
+ else if (m === "substring") pattern = "%" + escaped + "%";
870
+ else throw _err("whereLike mode must be 'substring' | 'prefix' | 'exact'",
871
+ "sql-builder/bad-like-mode");
872
+ return this._add(joiner, qc + " LIKE ? ESCAPE '~'", [pattern]);
873
+ }
874
+
875
+ // sqlite FTS5 full-text MATCH. The target is the FTS virtual-table name
876
+ // (the recommended shape - `<fts> MATCH ?` inside an IN-subquery - since
877
+ // FTS5 MATCH binds to the virtual table, and an aliased / joined column
878
+ // ref is parsed as an ordinary column and fails) or a single FTS column.
879
+ // The query expression binds as one `?`. sqlite-only (enforced in _cmp);
880
+ // the column gate is bypassed because the target is a table identifier,
881
+ // not a member of the builder's declared column set.
882
+ whereMatch(target, expr) {
883
+ if (this._dialect() !== "sqlite") {
884
+ throw _err("whereMatch (FTS5 MATCH) is sqlite-only; build with { dialect: 'sqlite' }",
885
+ "sql-builder/match-sqlite-only");
886
+ }
887
+ if (typeof expr !== "string" || expr.length === 0) {
888
+ throw _err("whereMatch requires a non-empty FTS5 query string", "sql-builder/bad-match");
889
+ }
890
+ return this._add("AND", _qualifiedColumn(target, "sqlite") + " MATCH ?", [expr]);
891
+ }
892
+ orWhereMatch(target, expr) {
893
+ if (this._dialect() !== "sqlite") {
894
+ throw _err("orWhereMatch (FTS5 MATCH) is sqlite-only; build with { dialect: 'sqlite' }",
895
+ "sql-builder/match-sqlite-only");
896
+ }
897
+ if (typeof expr !== "string" || expr.length === 0) {
898
+ throw _err("orWhereMatch requires a non-empty FTS5 query string", "sql-builder/bad-match");
899
+ }
900
+ return this._add("OR", _qualifiedColumn(target, "sqlite") + " MATCH ?", [expr]);
901
+ }
902
+
903
+ // sqlite `<col> IN (SELECT value FROM json_each(?))`. The JSON-array
904
+ // STRING binds as one `?` and sqlite's table-valued json_each unrolls it
905
+ // to a one-column row set - the placeholder-safe way to test membership
906
+ // in a variable-length set without expanding to one `?` per element (and
907
+ // without the Postgres-only `= ANY(?)` array bind). The bound value MUST
908
+ // be a JSON array string (json_each errors at execute on a non-array).
909
+ // sqlite-only (json_each is a sqlite extension); the column is gated +
910
+ // quoted by construction.
911
+ whereInJsonEach(col, jsonArrayString) {
912
+ if (this._dialect() !== "sqlite") {
913
+ throw _err("whereInJsonEach (json_each table-valued function) is sqlite-only; " +
914
+ "use whereInArray on Postgres", "sql-builder/json-each-sqlite-only");
915
+ }
916
+ if (typeof jsonArrayString !== "string" || jsonArrayString.length === 0) {
917
+ throw _err("whereInJsonEach requires a JSON-array string", "sql-builder/bad-json-each");
918
+ }
919
+ this._gate(col);
920
+ var qc = _qualifiedColumn(col, "sqlite");
921
+ return this._add("AND", qc + " IN (SELECT value FROM json_each(?))", [jsonArrayString]);
922
+ }
923
+
924
+ whereIn(col, values) { return this._cmp("AND", col, "IN", values); }
925
+ whereNotIn(col, values) { return this._cmp("AND", col, "NOT IN", values); }
926
+ orWhereIn(col, values) { return this._cmp("OR", col, "IN", values); }
927
+
928
+ // Postgres `col = ANY(?)` - the whole array binds as ONE parameter
929
+ // (a single `?`), in contrast to `whereIn` which expands to one `?`
930
+ // per element. This is the form a Postgres driver wants for a
931
+ // variable-length id set without a placeholder explosion (and it
932
+ // stays a single bind under the 65535-param wire ceiling). On a
933
+ // non-Postgres dialect there is no `= ANY(array)` value form, so it
934
+ // degrades to the portable expanded `IN (?, ?, ...)` automatically -
935
+ // every element still binds, the contract is identical. The array is
936
+ // bound, never interpolated.
937
+ whereInArray(col, values) { return this._inArray("AND", col, values); }
938
+ orWhereInArray(col, values) { return this._inArray("OR", col, values); }
939
+ _inArray(joiner, col, values) {
940
+ if (!Array.isArray(values) || values.length === 0) {
941
+ throw _err("whereInArray requires a non-empty array of values", "sql-builder/empty-in");
942
+ }
943
+ this._gate(col);
944
+ var qc = _qualifiedColumn(col, this._dialect());
945
+ if (this._dialect() === "postgres") {
946
+ // The whole array is one bound value (one `?`); the driver expands
947
+ // it to a Postgres array operand at execute.
948
+ return this._add(joiner, qc + " = ANY(?)", [values.slice()]);
949
+ }
950
+ var holders = values.map(function () { return "?"; }).join(", ");
951
+ return this._add(joiner, qc + " IN (" + holders + ")", values.slice());
952
+ }
953
+
954
+ whereNull(col) { return this._cmp("AND", col, "IS", null); }
955
+ whereNotNull(col) { return this._cmp("AND", col, "IS NOT", null); }
956
+ orWhereNull(col) { return this._cmp("OR", col, "IS", null); }
957
+
958
+ whereBetween(col, low, high) { return this._cmp("AND", col, "BETWEEN", [low, high]); }
959
+
960
+ // Nested group: where(qb => qb.where(...).orWhere(...)) -> "( ... )".
961
+ whereGroup(closure) { return this._group("AND", closure); }
962
+ orWhereGroup(closure) { return this._group("OR", closure); }
963
+ _group(joiner, closure) {
964
+ if (typeof closure !== "function") {
965
+ throw _err("whereGroup(closure): expected a function", "sql-builder/bad-closure");
966
+ }
967
+ var sub = new Predicate(this._owner, "AND");
968
+ closure(sub);
969
+ var built = sub.build();
970
+ if (!built.sql) return this;
971
+ return this._add(joiner, "(" + built.sql + ")", built.params);
972
+ }
973
+
974
+ // Subquery EXISTS / NOT EXISTS.
975
+ whereExists(subBuilder) { return this._exists("AND", "EXISTS", subBuilder); }
976
+ whereNotExists(subBuilder) { return this._exists("AND", "NOT EXISTS", subBuilder); }
977
+ orWhereExists(subBuilder) { return this._exists("OR", "EXISTS", subBuilder); }
978
+ _exists(joiner, kw, subBuilder) {
979
+ if (!(subBuilder instanceof Builder)) {
980
+ throw _err(kw + " requires a b.sql subquery builder", "sql-builder/bad-subquery");
981
+ }
982
+ var sub = _composeSub(subBuilder, this._dialect());
983
+ return this._add(joiner, kw + " (" + sub.sql + ")", sub.params);
984
+ }
985
+
986
+ // Scalar-subquery comparison: whereSub("col", "=", subBuilder).
987
+ whereSub(col, op, subBuilder) {
988
+ if (ALLOWED_OPS[op] !== true) {
989
+ throw _err("invalid where operator '" + op + "'", "sql-builder/bad-operator");
990
+ }
991
+ // JSONB operators have no jsonb_exists* rewrite in scalar-subquery
992
+ // position (only the value-comparison where() path emits it), so refuse
993
+ // them here rather than splice a bare ?/?|/?&/@> a backend mis-reads.
994
+ _refuseJsonbOp(op, "a scalar-subquery comparison");
995
+ if (!(subBuilder instanceof Builder)) {
996
+ throw _err("whereSub requires a b.sql subquery builder", "sql-builder/bad-subquery");
997
+ }
998
+ this._gate(col);
999
+ var sub = _composeSub(subBuilder, this._dialect());
1000
+ return this._add("AND", _qualifiedColumn(col, this._dialect()) + " " + op +
1001
+ " (" + sub.sql + ")", sub.params);
1002
+ }
1003
+
1004
+ // Raw fragment, guarded by b.guardSql + the embedded-literal +
1005
+ // placeholder-count scanners. The fragment must be a value expression
1006
+ // (no statement terminator, no verb, no string literal) and every
1007
+ // value must be a `?` bound through params.
1008
+ whereRaw(sql, params, opts) { return this._raw("AND", sql, params, opts); }
1009
+ orWhereRaw(sql, params, opts) { return this._raw("OR", sql, params, opts); }
1010
+ _raw(joiner, sql, params, opts) {
1011
+ var checked = _checkRawFragment(sql, params, opts, "whereRaw");
1012
+ return this._add(joiner, "(" + checked.sql + ")", checked.params);
1013
+ }
1014
+
1015
+ build() {
1016
+ if (this._parts.length === 0) return { sql: "", params: [] };
1017
+ var sql = this._parts[0].sql;
1018
+ var params = this._parts[0].params.slice();
1019
+ for (var i = 1; i < this._parts.length; i++) {
1020
+ sql += " " + this._parts[i].joiner + " " + this._parts[i].sql;
1021
+ for (var j = 0; j < this._parts[i].params.length; j++) params.push(this._parts[i].params[j]);
1022
+ }
1023
+ return { sql: sql, params: params };
1024
+ }
1025
+
1026
+ get length() { return this._parts.length; }
1027
+ }
1028
+
1029
+ // ---- Raw-fragment guard ---------------------------------------------
1030
+ //
1031
+ // The single choke-point every raw escape hatch (whereRaw / setRaw /
1032
+ // havingRaw / joinRaw / on-raw / conflictWhere) passes through. Three
1033
+ // layers, all on by default:
1034
+ // 1. b.guardSql.validate - RFC/CVE defense for hostile raw SQL
1035
+ // (stacked statements, comment-smuggling, file/exec/exfil
1036
+ // primitives, invalid encoding). Strict profile on the request
1037
+ // path; { ok:false } refuses the fragment.
1038
+ // 2. embedded-literal refusal - a '...' literal is the signature of
1039
+ // operator input concatenated into the fragment; refuse unless the
1040
+ // caller explicitly opts in for a static operator-controlled
1041
+ // literal.
1042
+ // 3. placeholder-count - the number of `?` must equal params.length,
1043
+ // so no value is silently unbound or over-supplied.
1044
+ function _checkRawFragment(sql, params, opts, where) {
1045
+ opts = opts || {};
1046
+ if (typeof sql !== "string" || sql.length === 0) {
1047
+ throw _err(where + ": sql must be a non-empty string", "sql-builder/bad-raw");
1048
+ }
1049
+ var p = Array.isArray(params) ? params.slice() : (params == null ? [] : [params]);
1050
+
1051
+ // Guard against hostile raw SQL via b.guardSql.validate - the direct
1052
+ // content checker that returns { ok, issues }. Default strict (request
1053
+ // path); a benign single-statement read fragment can relax via
1054
+ // guardProfile, but the structurally unambiguous classes (stacked
1055
+ // statements, invalid encoding) refuse under every profile. The
1056
+ // fragment context requires the fragment to be a value expression
1057
+ // (any statement terminator / verb / dangerous token refuses).
1058
+ // allowLiterals propagates to b.guardSql too: its own embedded-string-
1059
+ // literal rule honours the same opt (a static operator-controlled
1060
+ // literal the caller deliberately allows), so a fragment opted in here
1061
+ // must not be refused by the guard's literal rule while the local
1062
+ // _assertRawNoStringLiteral pass is skipped - the two literal checks
1063
+ // stay consistent. The structurally unambiguous classes (stacked
1064
+ // statements, invalid encoding, dangerous primitives) refuse regardless.
1065
+ var profile = opts.guardProfile || "strict";
1066
+ var g = guardSql();
1067
+ if (g && typeof g.validate === "function") {
1068
+ var result = g.validate(sql, {
1069
+ profile: profile, context: "fragment", allowLiterals: opts.allowLiterals === true,
1070
+ });
1071
+ if (result && result.ok === false) {
1072
+ var first = (result.issues && result.issues[0]) || {};
1073
+ throw _err(where + ": raw fragment refused by b.guardSql (" +
1074
+ (first.code || "policy") + (first.snippet ? ": " + first.snippet : "") + ")",
1075
+ "sql-builder/guard-refused");
1076
+ }
1077
+ }
1078
+
1079
+ // Embedded-literal + placeholder-count scan (single linear pass over
1080
+ // the fragment, comment / quoted-identifier aware).
1081
+ if (opts.allowLiterals !== true) _assertRawNoStringLiteral(sql, where);
1082
+ // A bare Postgres JSONB key-existence operator (?| / ?&) in a raw fragment
1083
+ // is corrupted by clusterStorage.placeholderize (the ? is rewritten to $N
1084
+ // -> "tags $1| keys"); _countPlaceholders also miscounts the ? as a bind.
1085
+ // Refuse it - the structured where(col, '?|', keys) path emits the
1086
+ // placeholder-safe jsonb_exists_any() form instead.
1087
+ _assertNoRawJsonbKeyOp(sql, where);
1088
+ var holders = _countPlaceholders(sql);
1089
+ if (holders !== p.length) {
1090
+ throw _err(where + ": " + holders + " placeholder(s) in sql but " + p.length +
1091
+ " param(s) supplied", "sql-builder/placeholder-mismatch");
1092
+ }
1093
+ return { sql: sql, params: p };
1094
+ }
1095
+
1096
+ // Refuse a raw fragment that embeds a single-quoted string literal. A
1097
+ // '...' literal is the signature of operator input concatenated into the
1098
+ // builder (CWE-89). Double-quoted identifiers, line comments, and block
1099
+ // comments are skipped. Single linear pass, no backtracking regex. Same
1100
+ // shape as db-query.js's scanner.
1101
+ function _assertRawNoStringLiteral(sql, where) {
1102
+ var i = 0;
1103
+ var len = sql.length;
1104
+ while (i < len) {
1105
+ var ch = sql.charAt(i);
1106
+ var next = i + 1 < len ? sql.charAt(i + 1) : "";
1107
+ if (ch === '"') {
1108
+ i += 1;
1109
+ while (i < len) {
1110
+ if (sql.charAt(i) === '"') {
1111
+ if (sql.charAt(i + 1) === '"') { i += 2; continue; }
1112
+ i += 1; break;
1113
+ }
1114
+ i += 1;
1115
+ }
1116
+ continue;
1117
+ }
1118
+ if (ch === "-" && next === "-") {
1119
+ while (i < len && sql.charAt(i) !== "\n") i += 1;
1120
+ continue;
1121
+ }
1122
+ if (ch === "/" && next === "*") {
1123
+ i += 2;
1124
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
1125
+ i += 2;
1126
+ continue;
1127
+ }
1128
+ if (ch === "'") {
1129
+ throw _err(where + ": raw SQL must not contain a string literal ('...') - bind " +
1130
+ "every value with a ? placeholder, or pass { allowLiterals: true } when the " +
1131
+ "literal is static and operator-controlled", "sql-builder/raw-literal");
1132
+ }
1133
+ i += 1;
1134
+ }
1135
+ }
1136
+
1137
+ // Refuse the two-char Postgres JSONB key-existence tokens (?| / ?&) in a raw
1138
+ // fragment. They are unambiguous (a `?` placeholder is never immediately
1139
+ // followed by `|` / `&`), can't be expressed safely under the ?->$N
1140
+ // placeholderize rewrite, and have a placeholder-safe structured form
1141
+ // (where(col, '?|', keys) -> jsonb_exists_any). Quote/comment-aware so a
1142
+ // `?|` inside an allowLiterals fragment's literal or comment is ignored.
1143
+ function _assertNoRawJsonbKeyOp(sql, where) {
1144
+ var i = 0;
1145
+ var len = sql.length;
1146
+ while (i < len) {
1147
+ var ch = sql.charAt(i);
1148
+ var next = i + 1 < len ? sql.charAt(i + 1) : "";
1149
+ if (ch === "'" || ch === '"' || ch === "`") {
1150
+ var q = ch;
1151
+ i += 1;
1152
+ while (i < len) {
1153
+ if (sql.charAt(i) === q) {
1154
+ if (sql.charAt(i + 1) === q) { i += 2; continue; }
1155
+ i += 1; break;
1156
+ }
1157
+ i += 1;
1158
+ }
1159
+ continue;
1160
+ }
1161
+ if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
1162
+ if (ch === "/" && next === "*") {
1163
+ i += 2;
1164
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
1165
+ i += 2;
1166
+ continue;
1167
+ }
1168
+ if (ch === "?" && (next === "|" || next === "&")) {
1169
+ throw _err(where + ": raw SQL must not contain the Postgres JSONB key-existence " +
1170
+ "operator '?" + next + "' (it collides with the ? bind placeholder) - use the " +
1171
+ "structured where(col, '?" + next + "', keys) form", "sql-builder/raw-jsonb-op");
1172
+ }
1173
+ i += 1;
1174
+ }
1175
+ }
1176
+
1177
+ // Placeholder counting (quote/comment-aware) is the scanner shared with the
1178
+ // residency write-gate; composed from safe-sql so the skip rules live in one
1179
+ // place. The output validator below uses it for placeholder/param parity.
1180
+ var _countPlaceholders = safeSql.countPlaceholders;
1181
+
1182
+ // Translate the builder's `?` placeholders to a dialect's positional form
1183
+ // (Postgres `$1..$N`; SQLite / MySQL keep `?`). Quote / comment-aware single
1184
+ // pass - a `?` inside a string literal, a quoted identifier, or a comment is
1185
+ // NOT a placeholder and is left untouched. This is the toExternalSql terminal
1186
+ // for code that hands the SQL to an operator-supplied driver directly (no
1187
+ // clusterStorage in the path to do the rewrite). The translator lives here -
1188
+ // the builder owns its driver-final output rendering - rather than reaching
1189
+ // into clusterStorage (which transitively requires this module).
1190
+ function _toPositional(sql, dialect) {
1191
+ if (dialect !== "postgres") return sql;
1192
+ var out = "";
1193
+ var n = 0;
1194
+ var i = 0;
1195
+ var len = sql.length;
1196
+ while (i < len) {
1197
+ var c = sql.charAt(i);
1198
+ var nx = i + 1 < len ? sql.charAt(i + 1) : "";
1199
+ if (c === "'" || c === '"' || c === "`") {
1200
+ out += c;
1201
+ i += 1;
1202
+ while (i < len) {
1203
+ var q = sql.charAt(i);
1204
+ if (q === c) {
1205
+ if (sql.charAt(i + 1) === c) { out += c + c; i += 2; continue; }
1206
+ out += c; i += 1; break;
1207
+ }
1208
+ out += q; i += 1;
1209
+ }
1210
+ continue;
1211
+ }
1212
+ if (c === "-" && nx === "-") {
1213
+ while (i < len && sql.charAt(i) !== "\n") { out += sql.charAt(i); i += 1; }
1214
+ continue;
1215
+ }
1216
+ if (c === "/" && nx === "*") {
1217
+ out += "/*"; i += 2;
1218
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) { out += sql.charAt(i); i += 1; }
1219
+ if (i < len) { out += "*/"; i += 2; }
1220
+ continue;
1221
+ }
1222
+ if (c === "?") { n += 1; out += "$" + n; i += 1; continue; }
1223
+ out += c;
1224
+ i += 1;
1225
+ }
1226
+ return out;
1227
+ }
1228
+
1229
+ /**
1230
+ * @primitive b.sql.toExternalSql
1231
+ * @signature b.sql.toExternalSql(builtOrBuilder, dialect)
1232
+ * @since 0.15.0
1233
+ * @status stable
1234
+ * @related b.sql.select, b.sql.createTable, b.clusterStorage.placeholderize
1235
+ *
1236
+ * Translate a built statement to a driver's positional placeholder form for
1237
+ * code that hands the SQL to an operator-supplied driver DIRECTLY (no
1238
+ * `b.clusterStorage` in the path to rewrite). Accepts either a chainable
1239
+ * builder (any `b.sql.select` / `insert` / `update` / `delete` / `upsert`,
1240
+ * via its own `.toExternalSql()` method) OR a plain `{ sql, params }` result
1241
+ * from a DDL builder (`createTable` / `createIndex` / `alterTable` /
1242
+ * `dropTable` / the RLS + catalog builders). Postgres gets `$1..$N`; SQLite
1243
+ * and MySQL keep `?`. The `?`-by-construction invariant is unchanged - only
1244
+ * the emitted text differs at the last step.
1245
+ *
1246
+ * @example
1247
+ * var b = require("@blamejs/core");
1248
+ * var ddl = b.sql.toExternalSql(
1249
+ * b.sql.createIndex("idx_pending", "outbox", ["next_attempt_at"],
1250
+ * { dialect: "postgres", where: "status = 'pending'" }),
1251
+ * "postgres");
1252
+ * // -> { sql: 'CREATE INDEX IF NOT EXISTS "idx_pending" ON outbox ' +
1253
+ * // '("next_attempt_at") WHERE status = \'pending\'', params: [] }
1254
+ */
1255
+ function toExternalSql(builtOrBuilder, dialect) {
1256
+ if (builtOrBuilder instanceof Builder) return builtOrBuilder.toExternalSql(dialect);
1257
+ if (builtOrBuilder && typeof builtOrBuilder.sql === "string" &&
1258
+ Array.isArray(builtOrBuilder.params)) {
1259
+ var d = _normDialect(dialect);
1260
+ return { sql: _toPositional(builtOrBuilder.sql, d), params: builtOrBuilder.params };
1261
+ }
1262
+ throw _err("b.sql.toExternalSql expects a b.sql builder or a { sql, params } result",
1263
+ "sql-builder/bad-external-input");
1264
+ }
1265
+
1266
+ // Final output gate - every verb's toSql() routes through _emit() before
1267
+ // returning, so a builder bug or a tainted identifier can never reach the
1268
+ // driver. Three invariants over the assembled statement, reusing the same
1269
+ // quote/comment-aware scan:
1270
+ // 1. placeholder <-> param parity - a `?` without its bound param (or a
1271
+ // param without its `?`) silently shifts values into the wrong
1272
+ // columns, the highest-severity builder bug.
1273
+ // 2. exactly one statement - no top-level `;` outside literals/comments
1274
+ // (a stacked statement has no place in a single built statement).
1275
+ // 3. balanced parentheses at the top level (structural well-formedness).
1276
+ // String literals only legitimately appear via whereRaw { allowLiterals }
1277
+ // (already gated at fragment time), so the literal check stays there.
1278
+ function _assertEmittable(sql, params) {
1279
+ // ---- shape ----
1280
+ // A builder bug must never emit a non-string / empty statement, and
1281
+ // params must be an array - a misshapen output hides a real defect
1282
+ // rather than failing loudly at the driver.
1283
+ if (typeof sql !== "string" || sql.length === 0) {
1284
+ throw _err("toSql: emitted SQL must be a non-empty string (builder bug)",
1285
+ "sql-builder/empty-sql");
1286
+ }
1287
+ if (!Array.isArray(params)) {
1288
+ throw _err("toSql: params must be an array (builder bug)",
1289
+ "sql-builder/bad-params-shape");
1290
+ }
1291
+ // ---- size ----
1292
+ // Runaway / DoS ceiling on the statement text. A statement this large
1293
+ // is a bug (an unbatched bulk write, a pathological IN-list), never a
1294
+ // legitimate single query.
1295
+ if (sql.length > MAX_SQL_BYTES) {
1296
+ throw _err("toSql: emitted SQL is " + sql.length + " bytes, over the " +
1297
+ MAX_SQL_BYTES + "-byte cap - batch the operation rather than building " +
1298
+ "one oversized statement", "sql-builder/sql-too-large");
1299
+ }
1300
+ // ---- text validity (boundary-escape) ----
1301
+ // A NUL byte truncates the statement at C-string-based drivers and can't
1302
+ // be stored in Postgres text; lone UTF-16 surrogates encode to invalid
1303
+ // UTF-8 on the wire (the CVE-2025-1094 encoding-escape class). Neither
1304
+ // can legitimately appear in builder-emitted SQL.
1305
+ if (sql.indexOf("\u0000") !== -1) {
1306
+ throw _err("toSql: emitted SQL contains a NUL byte - rejected " +
1307
+ "(statement-truncation / boundary-escape risk)", "sql-builder/null-byte-sql");
1308
+ }
1309
+ if (typeof sql.isWellFormed === "function" && !sql.isWellFormed()) {
1310
+ throw _err("toSql: emitted SQL contains invalid Unicode (lone " +
1311
+ "surrogates) - rejected (would encode to invalid UTF-8 on the wire)",
1312
+ "sql-builder/invalid-encoding-sql");
1313
+ }
1314
+ var n = params.length;
1315
+ // ---- bind-parameter ceiling ----
1316
+ // The wire-protocol limit (Postgres/MySQL 65535; SQLite 32766). Past it
1317
+ // is a hard driver error; catch it here with a clear message. The usual
1318
+ // cause is an unbounded IN-list / bulk insert that should be chunked.
1319
+ if (n > MAX_BIND_PARAMS) {
1320
+ throw _err("toSql: " + n + " bind parameters exceeds the " + MAX_BIND_PARAMS +
1321
+ "-parameter wire limit - chunk the values (batch the IN-list / rows)",
1322
+ "sql-builder/too-many-params");
1323
+ }
1324
+ // ---- param-element shape ----
1325
+ // A param that is `undefined` / a function / a symbol is a caller
1326
+ // mistake (a typo'd variable, a method reference passed by accident);
1327
+ // drivers coerce these ambiguously (undefined -> NULL, function ->
1328
+ // "[Function]"), silently storing the wrong value. Bind a concrete
1329
+ // value (string / number / boolean / null / bigint / Buffer / Date /
1330
+ // a JSON-serializable object). null is valid SQL NULL.
1331
+ for (var pi = 0; pi < n; pi += 1) {
1332
+ var pv = params[pi];
1333
+ var pt = typeof pv;
1334
+ if (pv === undefined || pt === "function" || pt === "symbol") {
1335
+ throw _err("toSql: param[" + pi + "] is " +
1336
+ (pv === undefined ? "undefined" : pt) + " - bind a concrete value " +
1337
+ "(string / number / boolean / null / bigint / Buffer / Date / object); " +
1338
+ "use null for SQL NULL", "sql-builder/bad-param-value");
1339
+ }
1340
+ // ---- column-level (per-value) size boundary ----
1341
+ // Only strings / Buffers can carry an unbounded payload; everything
1342
+ // else (number / boolean / bigint / Date / null) is fixed-small. A
1343
+ // single value over the per-value ceiling is a buffer-overflow-class
1344
+ // mistake (a whole file / whole buffer bound by accident), distinct
1345
+ // from the total-statement and total-param caps above.
1346
+ if (pt === "string" || Buffer.isBuffer(pv)) {
1347
+ var vbytes = pt === "string" ? Buffer.byteLength(pv, "utf8") : pv.length;
1348
+ if (vbytes > MAX_PARAM_BYTES) {
1349
+ throw _err("toSql: param[" + pi + "] is " + vbytes + " bytes, over the " +
1350
+ MAX_PARAM_BYTES + "-byte per-value ceiling - stream large blobs " +
1351
+ "through chunked storage rather than binding one oversized column",
1352
+ "sql-builder/param-too-large");
1353
+ }
1354
+ }
1355
+ if (pt === "string") {
1356
+ // A bound string still rides the wire. A NUL byte cannot be stored
1357
+ // in a Postgres text column and truncates C-string-based drivers; a
1358
+ // lone UTF-16 surrogate encodes to invalid UTF-8 on the wire (the
1359
+ // text values that "jump out of boundaries"). Reject both here so a
1360
+ // malformed value fails loudly at build time, not as a corrupt store.
1361
+ if (pv.indexOf("\u0000") !== -1) {
1362
+ throw _err("toSql: param[" + pi + "] contains a NUL byte - rejected " +
1363
+ "(text-column / driver truncation, boundary-escape risk)",
1364
+ "sql-builder/null-byte-param");
1365
+ }
1366
+ if (typeof pv.isWellFormed === "function" && !pv.isWellFormed()) {
1367
+ throw _err("toSql: param[" + pi + "] contains invalid Unicode (lone " +
1368
+ "surrogates) - rejected (would encode to invalid UTF-8 on the wire)",
1369
+ "sql-builder/invalid-encoding-param");
1370
+ }
1371
+ }
1372
+ }
1373
+ // ---- placeholder <-> param parity ----
1374
+ var holders = _countPlaceholders(sql);
1375
+ if (holders !== n) {
1376
+ throw _err("toSql: placeholder/param count mismatch - " + holders +
1377
+ " '?' placeholder(s) but " + n + " param(s); emitting this would " +
1378
+ "misalign bound values across columns", "sql-builder/param-mismatch");
1379
+ }
1380
+ var i = 0;
1381
+ var len = sql.length;
1382
+ var depth = 0;
1383
+ while (i < len) {
1384
+ var ch = sql.charAt(i);
1385
+ var next = i + 1 < len ? sql.charAt(i + 1) : "";
1386
+ if (ch === "'" || ch === '"' || ch === "`") {
1387
+ // Quote context. A string literal / quoted identifier escapes its own
1388
+ // quote char by DOUBLING it; an UNTERMINATED quote is the signature of
1389
+ // a quote-jump breakout - a value or identifier whose embedded quote
1390
+ // escaped its context and ran to the end of the statement. (Backtick
1391
+ // covers MySQL identifier quoting; ' and " cover string literals and
1392
+ // ANSI / Postgres / SQLite identifiers.)
1393
+ var q = ch;
1394
+ var closed = false;
1395
+ i += 1;
1396
+ while (i < len) {
1397
+ if (sql.charAt(i) === q) {
1398
+ if (sql.charAt(i + 1) === q) { i += 2; continue; }
1399
+ i += 1; closed = true; break;
1400
+ }
1401
+ i += 1;
1402
+ }
1403
+ if (!closed) {
1404
+ throw _err("toSql: unterminated " +
1405
+ (q === "'" ? "string literal" : "quoted identifier") +
1406
+ " in emitted SQL - a quote escaped its context " +
1407
+ "(quote-jump / breakout risk)", "sql-builder/unterminated-quote");
1408
+ }
1409
+ continue;
1410
+ }
1411
+ if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
1412
+ if (ch === "/" && next === "*") {
1413
+ i += 2;
1414
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
1415
+ i += 2;
1416
+ continue;
1417
+ }
1418
+ if (ch === "(") { depth += 1; }
1419
+ else if (ch === ")") { depth -= 1; }
1420
+ else if (ch === ";") {
1421
+ throw _err("toSql: builder emitted a top-level ';' - exactly one " +
1422
+ "statement per build; a stacked statement is never valid output",
1423
+ "sql-builder/stacked-statement");
1424
+ }
1425
+ i += 1;
1426
+ }
1427
+ if (depth !== 0) {
1428
+ throw _err("toSql: unbalanced parentheses in emitted SQL (builder bug)",
1429
+ "sql-builder/unbalanced");
1430
+ }
1431
+ }
1432
+
1433
+ // Terminal wrapper: validate then return the { sql, params } shape every
1434
+ // verb's toSql() emits.
1435
+ function _emit(sql, params) {
1436
+ _assertEmittable(sql, params);
1437
+ return { sql: sql, params: params };
1438
+ }
1439
+
1440
+ // ---- WITH (CTE) -----------------------------------------------------
1441
+ //
1442
+ // A CTE is a name + a subquery (a b.sql Builder whose toSql() composes,
1443
+ // params concatenated in CTE order before the main statement's params)
1444
+ // OR a guarded raw fragment. A statement carries an ordered list of
1445
+ // CTEs; withRecursive marks the WITH clause RECURSIVE.
1446
+
1447
+ function _cteFragment(cte, dialect) {
1448
+ var name = _quoteId(cte.name, dialect);
1449
+ if (cte.builder instanceof Builder) {
1450
+ // Render the CTE body under the OUTER statement's dialect, not the
1451
+ // sub-builder's own (default sqlite), so the name + body quote
1452
+ // consistently in one statement.
1453
+ var sub = _composeSub(cte.builder, dialect);
1454
+ return { sql: name + " AS (" + sub.sql + ")", params: sub.params };
1455
+ }
1456
+ // Raw CTE body - guarded like any raw fragment but allowed to be a
1457
+ // full SELECT/INSERT/UPDATE/DELETE statement (migration context).
1458
+ var checked = _checkRawFragment(cte.sql, cte.params, { guardProfile: cte.guardProfile || "balanced" },
1459
+ "with");
1460
+ return { sql: name + " AS (" + checked.sql + ")", params: checked.params };
1461
+ }
1462
+
1463
+ function _renderWith(ctes, recursive, dialect) {
1464
+ if (!ctes || ctes.length === 0) return { sql: "", params: [] };
1465
+ var fragments = [];
1466
+ var params = [];
1467
+ for (var i = 0; i < ctes.length; i++) {
1468
+ var f = _cteFragment(ctes[i], dialect);
1469
+ fragments.push(f.sql);
1470
+ for (var j = 0; j < f.params.length; j++) params.push(f.params[j]);
1471
+ }
1472
+ return {
1473
+ sql: "WITH " + (recursive ? "RECURSIVE " : "") + fragments.join(", ") + " ",
1474
+ params: params,
1475
+ };
1476
+ }
1477
+
1478
+ // ---- Base Builder ---------------------------------------------------
1479
+ //
1480
+ // Holds the shared dialect, table, CTE list, and column-membership gate.
1481
+ // Each verb is a subclass with its own clause set + toSql().
1482
+
1483
+ class Builder {
1484
+ constructor(verb, tableNameOrRef, opts) {
1485
+ opts = opts || {};
1486
+ this._verb = verb;
1487
+ this._dialect = _normDialect(opts.dialect);
1488
+ this._table = _normTableRef(tableNameOrRef, opts);
1489
+ this._ctes = [];
1490
+ this._cteRecursive = false;
1491
+
1492
+ // Column-membership gate. When the operator declares allowedColumns
1493
+ // (or a schema-declared set), an unknown column is refused before it
1494
+ // interpolates as an identifier (ORDER-BY / disclosure injection).
1495
+ this._allowedColumns = null;
1496
+ if (opts.allowedColumns) {
1497
+ if (!Array.isArray(opts.allowedColumns) || opts.allowedColumns.length === 0) {
1498
+ throw _err("allowedColumns must be a non-empty array", "sql-builder/bad-allowed-columns");
1499
+ }
1500
+ opts.allowedColumns.forEach(_validateColumn);
1501
+ this._allowedColumns = new Set(opts.allowedColumns);
1502
+ }
1503
+ this._columnGateMode = opts.columnGateMode || (this._allowedColumns ? "reject" : "off");
1504
+ }
1505
+
1506
+ // Restrict columns to an explicit subset (chainable form of the opt).
1507
+ allowedColumns(cols) {
1508
+ if (!Array.isArray(cols) || cols.length === 0) {
1509
+ throw _err("allowedColumns(cols): expected a non-empty array", "sql-builder/bad-allowed-columns");
1510
+ }
1511
+ cols.forEach(_validateColumn);
1512
+ this._allowedColumns = new Set(cols);
1513
+ if (this._columnGateMode === "off") this._columnGateMode = "reject";
1514
+ return this;
1515
+ }
1516
+
1517
+ columnGate(mode) {
1518
+ if (mode !== "reject" && mode !== "warn" && mode !== "off") {
1519
+ throw _err("columnGate mode must be 'reject' | 'warn' | 'off'", "sql-builder/bad-gate-mode");
1520
+ }
1521
+ this._columnGateMode = mode;
1522
+ return this;
1523
+ }
1524
+
1525
+ // Assert a column is a member of the gate set before it is quoted into
1526
+ // SQL. Always enforces an explicit allowedColumns set; "warn" mode
1527
+ // permits unknown columns (no audit sink here - this is a pure string
1528
+ // builder), "off" / no set skips. A qualified "alias.col" gates on the
1529
+ // bare column segment.
1530
+ _assertColumnMember(col, where) {
1531
+ if (this._columnGateMode === "off" || this._allowedColumns === null) return;
1532
+ var bare = col.indexOf(".") !== -1 ? col.split(".").pop() : col;
1533
+ if (this._allowedColumns.has(bare)) return;
1534
+ if (this._columnGateMode === "warn") return;
1535
+ throw _err("column '" + col + "' is not in the allowedColumns set" +
1536
+ (where ? " (" + where + ")" : ""), "sql-builder/unknown-column");
1537
+ }
1538
+
1539
+ // ---- WITH (shared by every verb) ----
1540
+ with(name, subqueryOrRaw, params, opts) {
1541
+ return this._pushCte(false, name, subqueryOrRaw, params, opts);
1542
+ }
1543
+ withRecursive(name, subqueryOrRaw, params, opts) {
1544
+ return this._pushCte(true, name, subqueryOrRaw, params, opts);
1545
+ }
1546
+ _pushCte(recursive, name, subqueryOrRaw, params, opts) {
1547
+ _validateColumn(name);
1548
+ if (recursive) this._cteRecursive = true;
1549
+ if (subqueryOrRaw instanceof Builder) {
1550
+ this._ctes.push({ name: name, builder: subqueryOrRaw });
1551
+ } else if (typeof subqueryOrRaw === "string") {
1552
+ this._ctes.push({
1553
+ name: name, sql: subqueryOrRaw, params: params,
1554
+ guardProfile: (opts && opts.guardProfile) || "balanced",
1555
+ });
1556
+ } else {
1557
+ throw _err("with(name, ...): second arg must be a b.sql builder or a raw SQL string",
1558
+ "sql-builder/bad-cte");
1559
+ }
1560
+ return this;
1561
+ }
1562
+
1563
+ // Subclasses implement _render() -> { sql, params } WITHOUT the WITH
1564
+ // prefix; toSql() prepends the rendered CTE clause.
1565
+ toSql() {
1566
+ var body = this._render();
1567
+ if (this._ctes.length === 0) return body;
1568
+ var withClause = _renderWith(this._ctes, this._cteRecursive, this._dialect);
1569
+ return {
1570
+ sql: withClause.sql + body.sql,
1571
+ params: withClause.params.concat(body.params),
1572
+ };
1573
+ }
1574
+
1575
+ // Driver-final form for code that targets an operator-supplied driver
1576
+ // DIRECTLY (b.externalDb.query / a transaction client), with no
1577
+ // b.clusterStorage in the path to rewrite placeholders. The builder
1578
+ // always composes `?` placeholders by construction; this terminal
1579
+ // translates them to the dialect's positional form at the boundary:
1580
+ // `$1..$N` for Postgres, left as `?` for SQLite / MySQL. The translation
1581
+ // is the SAME quote/comment-aware single pass clusterStorage uses, so a
1582
+ // `?` inside a string literal / quoted identifier / comment is never
1583
+ // rewritten. The `?`-by-construction invariant is unchanged - only the
1584
+ // emitted text differs at the very last step.
1585
+ toExternalSql(dialect) {
1586
+ var built = this.toSql();
1587
+ var d = _normDialect(dialect || this._dialect);
1588
+ return { sql: _toPositional(built.sql, d), params: built.params };
1589
+ }
1590
+ }
1591
+
1592
+ // ---- SELECT ---------------------------------------------------------
1593
+
1594
+ class SelectBuilder extends Builder {
1595
+ constructor(tableNameOrRef, opts) {
1596
+ super("select", tableNameOrRef, opts);
1597
+ this._projection = []; // [{ sql, params }] - column / aggregate / scalar-subquery
1598
+ this._distinct = false;
1599
+ this._joins = []; // [{ sql, params }]
1600
+ this._where = new Predicate(this, "AND");
1601
+ this._groupBy = [];
1602
+ this._having = new Predicate(this, "AND");
1603
+ this._orderBy = [];
1604
+ this._limit = null;
1605
+ this._offset = null;
1606
+ this._lockMode = null; // null | "UPDATE" | "SHARE"
1607
+ this._lockSkipLocked = false;
1608
+ this._lockNoWait = false;
1609
+ }
1610
+
1611
+ distinct() { this._distinct = true; return this; }
1612
+
1613
+ // Projection: array of column names (or "alias.col"); each quoted.
1614
+ // Empty / unset -> "*".
1615
+ columns(cols) {
1616
+ if (!Array.isArray(cols)) throw _err("columns() expects an array", "sql-builder/bad-columns");
1617
+ var self = this;
1618
+ cols.forEach(function (c) {
1619
+ self._assertColumnMember(c, "select");
1620
+ self._projection.push({ sql: _qualifiedColumn(c, self._dialect), params: [] });
1621
+ });
1622
+ return this;
1623
+ }
1624
+ select(cols) { return this.columns(cols); }
1625
+
1626
+ // A guarded raw projection expression - a constant `1` presence sentinel
1627
+ // (`SELECT 1 ... WHERE ...` for an existence probe), a function-call
1628
+ // projection, or any value expression the structured column / aggregate
1629
+ // helpers don't cover. It rides the same b.guardSql raw-fragment gate as
1630
+ // whereRaw (no statement terminator, no embedded string literal unless
1631
+ // allowLiterals); any value binds via a `?` carried in params.
1632
+ selectRaw(expr, params, opts) {
1633
+ var checked = _checkRawFragment(expr, params, opts, "selectRaw");
1634
+ this._projection.push({ sql: checked.sql, params: checked.params });
1635
+ return this;
1636
+ }
1637
+
1638
+ // Aggregate helpers. alias is quoted; the aggregated column is quoted
1639
+ // (or "*" for count()).
1640
+ count(col, alias) { return this._agg("COUNT", col || "*", alias, false); }
1641
+ countDistinct(col, alias) { return this._agg("COUNT", col, alias, true); }
1642
+ max(col, alias) { return this._agg("MAX", col, alias, false); }
1643
+ min(col, alias) { return this._agg("MIN", col, alias, false); }
1644
+ sum(col, alias) { return this._agg("SUM", col, alias, false); }
1645
+ avg(col, alias) { return this._agg("AVG", col, alias, false); }
1646
+ _agg(fn, col, alias, distinct) {
1647
+ var inner;
1648
+ if (col === "*") {
1649
+ inner = "*";
1650
+ } else {
1651
+ this._assertColumnMember(col, fn.toLowerCase());
1652
+ inner = (distinct ? "DISTINCT " : "") + _qualifiedColumn(col, this._dialect);
1653
+ }
1654
+ var sql = fn + "(" + inner + ")";
1655
+ if (alias) { _validateColumn(alias); sql += " AS " + _quoteId(alias, this._dialect); }
1656
+ this._projection.push({ sql: sql, params: [] });
1657
+ return this;
1658
+ }
1659
+
1660
+ // Scalar subquery in the projection: selectSub(subBuilder, "alias").
1661
+ selectSub(subBuilder, alias) {
1662
+ if (!(subBuilder instanceof Builder)) {
1663
+ throw _err("selectSub requires a b.sql subquery builder", "sql-builder/bad-subquery");
1664
+ }
1665
+ _validateColumn(alias);
1666
+ var sub = _composeSub(subBuilder, this._dialect);
1667
+ this._projection.push({
1668
+ sql: "(" + sub.sql + ") AS " + _quoteId(alias, this._dialect),
1669
+ params: sub.params,
1670
+ });
1671
+ return this;
1672
+ }
1673
+
1674
+ // ---- JOINs ----
1675
+ join(tbl, onLeft, op, onRight) { return this._join("INNER", tbl, onLeft, op, onRight); }
1676
+ innerJoin(tbl, onLeft, op, onRight) { return this._join("INNER", tbl, onLeft, op, onRight); }
1677
+ leftJoin(tbl, onLeft, op, onRight) { return this._join("LEFT", tbl, onLeft, op, onRight); }
1678
+ rightJoin(tbl, onLeft, op, onRight) { return this._join("RIGHT", tbl, onLeft, op, onRight); }
1679
+ fullJoin(tbl, onLeft, op, onRight) { return this._join("FULL", tbl, onLeft, op, onRight); }
1680
+ crossJoin(tbl) { return this._join("CROSS", tbl, null, null, null); }
1681
+ _join(kind, tbl, onLeft, op, onRight) {
1682
+ var ref = _normTableRef(tbl, {});
1683
+ var clause = JOIN_KINDS[kind] + " " + ref.refWithAlias(this._dialect);
1684
+ if (kind !== "CROSS") {
1685
+ if (typeof onLeft !== "string" || typeof onRight !== "string") {
1686
+ throw _err(kind + " join requires onLeft + onRight column expressions",
1687
+ "sql-builder/bad-join-on");
1688
+ }
1689
+ var joinOp = op || "=";
1690
+ // The ON operator is a comparison; validate against the same
1691
+ // allowlist. Both operands are column expressions (quoted), never
1692
+ // bound values - a join condition compares columns, not literals.
1693
+ if (ALLOWED_OPS[joinOp] !== true) {
1694
+ throw _err("invalid join ON operator '" + joinOp + "'", "sql-builder/bad-operator");
1695
+ }
1696
+ // A JSONB operator in a join ON has no jsonb_exists* rewrite and a bare
1697
+ // `?` collides with the placeholder marker; refuse it here.
1698
+ _refuseJsonbOp(joinOp, "a join ON clause");
1699
+ clause += " ON " + _qualifiedColumn(onLeft, this._dialect) + " " + joinOp + " " +
1700
+ _qualifiedColumn(onRight, this._dialect);
1701
+ }
1702
+ this._joins.push({ sql: clause, params: [] });
1703
+ return this;
1704
+ }
1705
+
1706
+ // Raw join (guarded) - the full "<KIND> JOIN <tbl> ON <raw>" escape
1707
+ // hatch for join conditions the column-pair form can't express.
1708
+ joinRaw(sql, params, opts) {
1709
+ var checked = _checkRawFragment(sql, params, opts, "joinRaw");
1710
+ this._joins.push({ sql: checked.sql, params: checked.params });
1711
+ return this;
1712
+ }
1713
+
1714
+ // ---- WHERE (delegated to the Predicate) ----
1715
+ // where / andWhere / orWhere forward `arguments` rather than fixed
1716
+ // positional params: the Predicate distinguishes the 2-arg
1717
+ // where(field, value) shorthand from the 3-arg where(field, op, value)
1718
+ // form by arguments.length, so a fixed (a, b, c) signature here would
1719
+ // make a 2-arg call look like 3 (binding the value as the operator).
1720
+ where() { this._where.where.apply(this._where, arguments); return this; }
1721
+ andWhere() { this._where.andWhere.apply(this._where, arguments); return this; }
1722
+ orWhere() { this._where.orWhere.apply(this._where, arguments); return this; }
1723
+ whereOp(col, op, value) { this._where.whereOp(col, op, value); return this; }
1724
+ orWhereOp(col, op, value) { this._where.orWhereOp(col, op, value); return this; }
1725
+ whereIn(col, values) { this._where.whereIn(col, values); return this; }
1726
+ whereNotIn(col, values) { this._where.whereNotIn(col, values); return this; }
1727
+ orWhereIn(col, values) { this._where.orWhereIn(col, values); return this; }
1728
+ whereInArray(col, values) { this._where.whereInArray(col, values); return this; }
1729
+ orWhereInArray(col, values) { this._where.orWhereInArray(col, values); return this; }
1730
+ whereInJsonEach(col, jsonArrayString) { this._where.whereInJsonEach(col, jsonArrayString); return this; }
1731
+ whereMatch(target, expr) { this._where.whereMatch(target, expr); return this; }
1732
+ orWhereMatch(target, expr) { this._where.orWhereMatch(target, expr); return this; }
1733
+ whereNull(col) { this._where.whereNull(col); return this; }
1734
+ whereNotNull(col) { this._where.whereNotNull(col); return this; }
1735
+ orWhereNull(col) { this._where.orWhereNull(col); return this; }
1736
+ whereLike(col, term, mode) { this._where.whereLike(col, term, mode); return this; }
1737
+ orWhereLike(col, term, mode) { this._where.orWhereLike(col, term, mode); return this; }
1738
+ whereBetween(col, low, high) { this._where.whereBetween(col, low, high); return this; }
1739
+ whereGroup(closure) { this._where.whereGroup(closure); return this; }
1740
+ orWhereGroup(closure) { this._where.orWhereGroup(closure); return this; }
1741
+ whereExists(sub) { this._where.whereExists(sub); return this; }
1742
+ whereNotExists(sub) { this._where.whereNotExists(sub); return this; }
1743
+ whereSub(col, op, sub) { this._where.whereSub(col, op, sub); return this; }
1744
+ whereRaw(sql, params, opts) { this._where.whereRaw(sql, params, opts); return this; }
1745
+ orWhereRaw(sql, params, opts) { this._where.orWhereRaw(sql, params, opts); return this; }
1746
+
1747
+ // ---- Row locking (Postgres / MySQL 8+) ----
1748
+ // FOR UPDATE [SKIP LOCKED] - the competing-consumer claim idiom. SKIP
1749
+ // LOCKED lets parallel workers each grab a disjoint row set without
1750
+ // blocking on each other's locks (the at-least-once outbox / job-queue
1751
+ // claim). It is Postgres / MySQL-only; SQLite is a single writer and
1752
+ // has no row lock, so the builder REFUSES forUpdate on a sqlite dialect
1753
+ // at build (emitting unsupported syntax would be a silent driver error)
1754
+ // - the caller branches on dialect and uses a plain transaction-scoped
1755
+ // SELECT for sqlite, exactly as the publisher does.
1756
+ forUpdate(opts) { return this._lock("UPDATE", opts); }
1757
+ forShare(opts) { return this._lock("SHARE", opts); }
1758
+ _lock(mode, opts) {
1759
+ opts = opts || {};
1760
+ if (this._dialect === "sqlite") {
1761
+ throw _err("forUpdate / forShare row locking is Postgres / MySQL-only " +
1762
+ "(SQLite is a single writer with no row lock); branch on dialect and use a " +
1763
+ "transaction-scoped SELECT for sqlite", "sql-builder/lock-unsupported");
1764
+ }
1765
+ this._lockMode = mode;
1766
+ this._lockSkipLocked = opts.skipLocked === true;
1767
+ this._lockNoWait = opts.noWait === true;
1768
+ if (this._lockSkipLocked && this._lockNoWait) {
1769
+ throw _err("forUpdate: skipLocked and noWait are mutually exclusive", "sql-builder/bad-lock");
1770
+ }
1771
+ return this;
1772
+ }
1773
+
1774
+ // ---- GROUP BY / HAVING ----
1775
+ groupBy(cols) {
1776
+ var arr = Array.isArray(cols) ? cols : [cols];
1777
+ var self = this;
1778
+ arr.forEach(function (c) {
1779
+ self._assertColumnMember(c, "groupBy");
1780
+ self._groupBy.push(_qualifiedColumn(c, self._dialect));
1781
+ });
1782
+ return this;
1783
+ }
1784
+ having() { this._having.where.apply(this._having, arguments); return this; }
1785
+ orHaving() { this._having.orWhere.apply(this._having, arguments); return this; }
1786
+ havingRaw(sql, params, opts) { this._having.whereRaw(sql, params, opts); return this; }
1787
+
1788
+ // ---- ORDER BY / LIMIT / OFFSET ----
1789
+ orderBy(col, direction) {
1790
+ this._assertColumnMember(col, "orderBy");
1791
+ var dir = (direction || "asc").toLowerCase();
1792
+ if (dir !== "asc" && dir !== "desc") {
1793
+ throw _err("orderBy direction must be 'asc' or 'desc'", "sql-builder/bad-direction");
1794
+ }
1795
+ this._orderBy.push(_qualifiedColumn(col, this._dialect) + " " + dir.toUpperCase());
1796
+ return this;
1797
+ }
1798
+ limit(n) {
1799
+ if (!Number.isInteger(n) || n < 0) {
1800
+ throw _err("limit must be a non-negative integer", "sql-builder/bad-limit");
1801
+ }
1802
+ this._limit = n;
1803
+ return this;
1804
+ }
1805
+ offset(n) {
1806
+ if (!Number.isInteger(n) || n < 0) {
1807
+ throw _err("offset must be a non-negative integer", "sql-builder/bad-offset");
1808
+ }
1809
+ this._offset = n;
1810
+ return this;
1811
+ }
1812
+
1813
+ _render() {
1814
+ var dialect = this._dialect;
1815
+ var params = [];
1816
+ var projSql;
1817
+ if (this._projection.length === 0) {
1818
+ projSql = "*";
1819
+ } else {
1820
+ var pieces = [];
1821
+ for (var p = 0; p < this._projection.length; p++) {
1822
+ pieces.push(this._projection[p].sql);
1823
+ for (var pp = 0; pp < this._projection[p].params.length; pp++) {
1824
+ params.push(this._projection[p].params[pp]);
1825
+ }
1826
+ }
1827
+ projSql = pieces.join(", ");
1828
+ }
1829
+
1830
+ var sql = "SELECT " + (this._distinct ? "DISTINCT " : "") + projSql +
1831
+ " FROM " + this._table.refWithAlias(dialect);
1832
+
1833
+ for (var j = 0; j < this._joins.length; j++) {
1834
+ sql += " " + this._joins[j].sql;
1835
+ for (var jp = 0; jp < this._joins[j].params.length; jp++) params.push(this._joins[j].params[jp]);
1836
+ }
1837
+
1838
+ var w = this._where.build();
1839
+ if (w.sql) { sql += " WHERE " + w.sql; for (var wi = 0; wi < w.params.length; wi++) params.push(w.params[wi]); }
1840
+
1841
+ if (this._groupBy.length > 0) sql += " GROUP BY " + this._groupBy.join(", ");
1842
+
1843
+ var h = this._having.build();
1844
+ if (h.sql) { sql += " HAVING " + h.sql; for (var hi = 0; hi < h.params.length; hi++) params.push(h.params[hi]); }
1845
+
1846
+ if (this._orderBy.length > 0) sql += " ORDER BY " + this._orderBy.join(", ");
1847
+ if (this._limit !== null) sql += " LIMIT " + this._limit;
1848
+ if (this._offset !== null) sql += " OFFSET " + this._offset;
1849
+
1850
+ if (this._lockMode !== null) {
1851
+ sql += " FOR " + this._lockMode;
1852
+ if (this._lockSkipLocked) sql += " SKIP LOCKED";
1853
+ else if (this._lockNoWait) sql += " NOWAIT";
1854
+ }
1855
+
1856
+ return _emit(sql, params);
1857
+ }
1858
+ }
1859
+
1860
+ // ---- INSERT ---------------------------------------------------------
1861
+
1862
+ class InsertBuilder extends Builder {
1863
+ constructor(tableNameOrRef, opts) {
1864
+ super("insert", tableNameOrRef, opts);
1865
+ this._columns = null;
1866
+ this._rows = []; // array of value arrays, aligned to _columns
1867
+ this._returning = null;
1868
+ }
1869
+
1870
+ columns(cols) {
1871
+ if (!Array.isArray(cols) || cols.length === 0) {
1872
+ throw _err("columns() expects a non-empty array", "sql-builder/bad-columns");
1873
+ }
1874
+ var self = this;
1875
+ cols.forEach(function (c) { self._assertColumnMember(c, "insert"); _validateColumn(c); });
1876
+ this._columns = cols.slice();
1877
+ return this;
1878
+ }
1879
+
1880
+ // values(obj) - one row from a column->value map (sets _columns from
1881
+ // the keys if not already set). values([obj, obj]) - multiple rows.
1882
+ // values(array) - one row aligned to a prior columns() call.
1883
+ values(rowOrRows) {
1884
+ if (Array.isArray(rowOrRows) && rowOrRows.length > 0 && typeof rowOrRows[0] === "object" &&
1885
+ rowOrRows[0] !== null && !Array.isArray(rowOrRows[0])) {
1886
+ // Array of row objects.
1887
+ var self = this;
1888
+ rowOrRows.forEach(function (r) { self._addRowObject(r); });
1889
+ return this;
1890
+ }
1891
+ if (Array.isArray(rowOrRows)) {
1892
+ // A single positional row aligned to columns().
1893
+ if (this._columns === null) {
1894
+ throw _err("values(array) requires a prior columns([...]) call", "sql-builder/no-columns");
1895
+ }
1896
+ if (rowOrRows.length !== this._columns.length) {
1897
+ throw _err("values(array): " + rowOrRows.length + " values but " +
1898
+ this._columns.length + " columns", "sql-builder/value-count");
1899
+ }
1900
+ this._rows.push(rowOrRows.slice());
1901
+ return this;
1902
+ }
1903
+ if (rowOrRows && typeof rowOrRows === "object") {
1904
+ this._addRowObject(rowOrRows);
1905
+ return this;
1906
+ }
1907
+ throw _err("values() requires a row object, an array of row objects, or a value array",
1908
+ "sql-builder/bad-values");
1909
+ }
1910
+
1911
+ _addRowObject(obj) {
1912
+ var keys = Object.keys(obj);
1913
+ if (keys.length === 0) throw _err("insert row object is empty", "sql-builder/empty-values");
1914
+ if (this._columns === null) {
1915
+ this.columns(keys);
1916
+ }
1917
+ var self = this;
1918
+ var row = this._columns.map(function (c) {
1919
+ if (!Object.prototype.hasOwnProperty.call(obj, c)) {
1920
+ throw _err("insert row is missing column '" + c + "'", "sql-builder/missing-column");
1921
+ }
1922
+ return obj[c];
1923
+ });
1924
+ // Reject extra keys not in the column set (silent-drop would lose data).
1925
+ keys.forEach(function (k) {
1926
+ if (self._columns.indexOf(k) === -1) {
1927
+ throw _err("insert row has column '" + k + "' not in the column set", "sql-builder/extra-column");
1928
+ }
1929
+ });
1930
+ this._rows.push(row);
1931
+ }
1932
+
1933
+ returning(cols) { this._returning = _normReturning(cols); return this; }
1934
+
1935
+ _render() {
1936
+ if (this._columns === null || this._rows.length === 0) {
1937
+ throw _err("insert requires columns + at least one values() row", "sql-builder/empty-values");
1938
+ }
1939
+ var dialect = this._dialect;
1940
+ var quotedCols = this._columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
1941
+ var holders = [];
1942
+ var params = [];
1943
+ // Each cell renders to `?` (bound), `?::type` (cast), or an allowlisted
1944
+ // SQL function token (NOW() / CURRENT_TIMESTAMP - no param). A
1945
+ // SqlFunction / CastValue cell is identical across rows for a given
1946
+ // column, but the cell is resolved per-row so a multi-row insert can
1947
+ // mix a literal in one row and a function in another.
1948
+ for (var r = 0; r < this._rows.length; r++) {
1949
+ var cells = [];
1950
+ for (var v = 0; v < this._rows[r].length; v++) {
1951
+ var rendered = _renderValueCell(this._rows[r][v], dialect);
1952
+ cells.push(rendered.sql);
1953
+ for (var rp = 0; rp < rendered.params.length; rp++) params.push(rendered.params[rp]);
1954
+ }
1955
+ holders.push("(" + cells.join(", ") + ")");
1956
+ }
1957
+ var sql = "INSERT INTO " + this._table.ref(dialect) + " (" + quotedCols + ") VALUES " +
1958
+ holders.join(", ");
1959
+ sql += _renderReturning(this._returning, dialect);
1960
+ return _emit(sql, params);
1961
+ }
1962
+ }
1963
+
1964
+ // ---- UPDATE ---------------------------------------------------------
1965
+
1966
+ class UpdateBuilder extends Builder {
1967
+ constructor(tableNameOrRef, opts) {
1968
+ super("update", tableNameOrRef, opts);
1969
+ this._set = []; // [{ sql, params }]
1970
+ this._where = new Predicate(this, "AND");
1971
+ this._returning = null;
1972
+ this._allowNoWhere = false;
1973
+ }
1974
+
1975
+ // set(obj) - column->value assignments. set(col, value) - single
1976
+ // assignment. A value may be a bound literal, a b.sql.cast(...) (binds
1977
+ // `?::type`), or a b.sql.fn(...) allowlisted SQL function (emits the
1978
+ // token, no param) - all routed through the single _renderValueCell
1979
+ // choke-point. A SqlFunction / CastValue is itself an object, so the
1980
+ // object-form detection excludes them explicitly.
1981
+ set(colOrObj, value) {
1982
+ var self = this;
1983
+ if (colOrObj && typeof colOrObj === "object" &&
1984
+ !(colOrObj instanceof SqlFunction) && !(colOrObj instanceof CastValue)) {
1985
+ var keys = Object.keys(colOrObj);
1986
+ if (keys.length === 0) throw _err("set object is empty", "sql-builder/empty-set");
1987
+ keys.forEach(function (k) {
1988
+ self._assertColumnMember(k, "update");
1989
+ var cell = _renderValueCell(colOrObj[k], self._dialect);
1990
+ self._set.push({ sql: _quoteId(k, self._dialect) + " = " + cell.sql, params: cell.params });
1991
+ });
1992
+ return this;
1993
+ }
1994
+ this._assertColumnMember(colOrObj, "update");
1995
+ var cell1 = _renderValueCell(value, this._dialect);
1996
+ this._set.push({ sql: _quoteId(colOrObj, this._dialect) + " = " + cell1.sql, params: cell1.params });
1997
+ return this;
1998
+ }
1999
+
2000
+ // setRaw(col, rawExpr, params) - assign a guarded raw expression
2001
+ // (e.g. "count" = "count" + ?). The column is quoted; the expression
2002
+ // is guarded + placeholder-checked.
2003
+ setRaw(col, expr, params, opts) {
2004
+ this._assertColumnMember(col, "update");
2005
+ var checked = _checkRawFragment(expr, params, opts, "setRaw");
2006
+ this._set.push({
2007
+ sql: _quoteId(col, this._dialect) + " = " + checked.sql,
2008
+ params: checked.params,
2009
+ });
2010
+ return this;
2011
+ }
2012
+
2013
+ allowNoWhere() { this._allowNoWhere = true; return this; }
2014
+
2015
+ where() { this._where.where.apply(this._where, arguments); return this; }
2016
+ andWhere() { this._where.andWhere.apply(this._where, arguments); return this; }
2017
+ orWhere() { this._where.orWhere.apply(this._where, arguments); return this; }
2018
+ whereOp(col, op, value) { this._where.whereOp(col, op, value); return this; }
2019
+ orWhereOp(col, op, value) { this._where.orWhereOp(col, op, value); return this; }
2020
+ whereIn(col, values) { this._where.whereIn(col, values); return this; }
2021
+ whereNotIn(col, values) { this._where.whereNotIn(col, values); return this; }
2022
+ orWhereIn(col, values) { this._where.orWhereIn(col, values); return this; }
2023
+ whereInArray(col, values) { this._where.whereInArray(col, values); return this; }
2024
+ orWhereInArray(col, values) { this._where.orWhereInArray(col, values); return this; }
2025
+ whereInJsonEach(col, jsonArrayString) { this._where.whereInJsonEach(col, jsonArrayString); return this; }
2026
+ whereMatch(target, expr) { this._where.whereMatch(target, expr); return this; }
2027
+ whereNull(col) { this._where.whereNull(col); return this; }
2028
+ whereNotNull(col) { this._where.whereNotNull(col); return this; }
2029
+ orWhereNull(col) { this._where.orWhereNull(col); return this; }
2030
+ whereLike(col, term, mode) { this._where.whereLike(col, term, mode); return this; }
2031
+ orWhereLike(col, term, mode) { this._where.orWhereLike(col, term, mode); return this; }
2032
+ whereSub(col, op, sub) { this._where.whereSub(col, op, sub); return this; }
2033
+ whereExists(sub) { this._where.whereExists(sub); return this; }
2034
+ whereNotExists(sub) { this._where.whereNotExists(sub); return this; }
2035
+ whereGroup(closure) { this._where.whereGroup(closure); return this; }
2036
+ orWhereGroup(closure) { this._where.orWhereGroup(closure); return this; }
2037
+ whereRaw(sql, params, opts) { this._where.whereRaw(sql, params, opts); return this; }
2038
+ orWhereRaw(sql, params, opts) { this._where.orWhereRaw(sql, params, opts); return this; }
2039
+
2040
+ returning(cols) { this._returning = _normReturning(cols); return this; }
2041
+
2042
+ _render() {
2043
+ if (this._set.length === 0) throw _err("update requires a set(...) call", "sql-builder/empty-set");
2044
+ if (this._where.length === 0 && !this._allowNoWhere) {
2045
+ throw _err("refusing unconditional update - call where(...) first or allowNoWhere()",
2046
+ "sql-builder/no-where");
2047
+ }
2048
+ var dialect = this._dialect;
2049
+ var params = [];
2050
+ var setPieces = [];
2051
+ for (var s = 0; s < this._set.length; s++) {
2052
+ setPieces.push(this._set[s].sql);
2053
+ for (var sp = 0; sp < this._set[s].params.length; sp++) params.push(this._set[s].params[sp]);
2054
+ }
2055
+ var sql = "UPDATE " + this._table.ref(dialect) + " SET " + setPieces.join(", ");
2056
+ var w = this._where.build();
2057
+ if (w.sql) { sql += " WHERE " + w.sql; for (var wi = 0; wi < w.params.length; wi++) params.push(w.params[wi]); }
2058
+ sql += _renderReturning(this._returning, dialect);
2059
+ return _emit(sql, params);
2060
+ }
2061
+ }
2062
+
2063
+ // ---- DELETE ---------------------------------------------------------
2064
+
2065
+ class DeleteBuilder extends Builder {
2066
+ constructor(tableNameOrRef, opts) {
2067
+ super("delete", tableNameOrRef, opts);
2068
+ this._where = new Predicate(this, "AND");
2069
+ this._returning = null;
2070
+ this._allowNoWhere = false;
2071
+ }
2072
+
2073
+ allowNoWhere() { this._allowNoWhere = true; return this; }
2074
+
2075
+ where() { this._where.where.apply(this._where, arguments); return this; }
2076
+ andWhere() { this._where.andWhere.apply(this._where, arguments); return this; }
2077
+ orWhere() { this._where.orWhere.apply(this._where, arguments); return this; }
2078
+ whereOp(col, op, value) { this._where.whereOp(col, op, value); return this; }
2079
+ orWhereOp(col, op, value) { this._where.orWhereOp(col, op, value); return this; }
2080
+ whereIn(col, values) { this._where.whereIn(col, values); return this; }
2081
+ whereNotIn(col, values) { this._where.whereNotIn(col, values); return this; }
2082
+ orWhereIn(col, values) { this._where.orWhereIn(col, values); return this; }
2083
+ whereInArray(col, values) { this._where.whereInArray(col, values); return this; }
2084
+ orWhereInArray(col, values) { this._where.orWhereInArray(col, values); return this; }
2085
+ whereInJsonEach(col, jsonArrayString) { this._where.whereInJsonEach(col, jsonArrayString); return this; }
2086
+ whereMatch(target, expr) { this._where.whereMatch(target, expr); return this; }
2087
+ whereNull(col) { this._where.whereNull(col); return this; }
2088
+ whereNotNull(col) { this._where.whereNotNull(col); return this; }
2089
+ orWhereNull(col) { this._where.orWhereNull(col); return this; }
2090
+ whereLike(col, term, mode) { this._where.whereLike(col, term, mode); return this; }
2091
+ orWhereLike(col, term, mode) { this._where.orWhereLike(col, term, mode); return this; }
2092
+ whereSub(col, op, sub) { this._where.whereSub(col, op, sub); return this; }
2093
+ whereExists(sub) { this._where.whereExists(sub); return this; }
2094
+ whereNotExists(sub) { this._where.whereNotExists(sub); return this; }
2095
+ whereGroup(closure) { this._where.whereGroup(closure); return this; }
2096
+ orWhereGroup(closure) { this._where.orWhereGroup(closure); return this; }
2097
+ whereRaw(sql, params, opts) { this._where.whereRaw(sql, params, opts); return this; }
2098
+ orWhereRaw(sql, params, opts) { this._where.orWhereRaw(sql, params, opts); return this; }
2099
+
2100
+ returning(cols) { this._returning = _normReturning(cols); return this; }
2101
+
2102
+ _render() {
2103
+ if (this._where.length === 0 && !this._allowNoWhere) {
2104
+ throw _err("refusing unconditional delete - call where(...) first or allowNoWhere()",
2105
+ "sql-builder/no-where");
2106
+ }
2107
+ var dialect = this._dialect;
2108
+ var params = [];
2109
+ var sql = "DELETE FROM " + this._table.ref(dialect);
2110
+ var w = this._where.build();
2111
+ if (w.sql) { sql += " WHERE " + w.sql; for (var wi = 0; wi < w.params.length; wi++) params.push(w.params[wi]); }
2112
+ sql += _renderReturning(this._returning, dialect);
2113
+ return _emit(sql, params);
2114
+ }
2115
+ }
2116
+
2117
+ // ---- UPSERT (the dialect-divergence centrepiece) --------------------
2118
+ //
2119
+ // The one verb that must emit dialect-final syntax - placeholderize +
2120
+ // resolveTables cannot synthesise a conflict clause.
2121
+ //
2122
+ // Postgres / SQLite:
2123
+ // INSERT INTO t (cols) VALUES (?...) ON CONFLICT (keys)
2124
+ // DO UPDATE SET col = EXCLUDED.col [WHERE <guard>] [RETURNING ...]
2125
+ // | DO NOTHING
2126
+ //
2127
+ // MySQL:
2128
+ // INSERT INTO t (cols) VALUES (?...) ON DUPLICATE KEY UPDATE
2129
+ // col = VALUES(col) (or IF(<guard>, VALUES(col), col)
2130
+ // when conflictWhere is present -
2131
+ // MySQL has no per-statement WHERE
2132
+ // on the conflict action)
2133
+ // No WHERE, no RETURNING. A readbackSql SELECT is auto-emitted so the
2134
+ // caller can fetch the upserted row the way RETURNING would have
2135
+ // surfaced it.
2136
+ //
2137
+ // All three conflict actions are required: doUpdate (re-bind specific
2138
+ // columns, optionally to an expression), doUpdateFromExcluded (set the
2139
+ // listed columns to the proposed row's values), and doNothing.
2140
+ class UpsertBuilder extends Builder {
2141
+ constructor(tableNameOrRef, opts) {
2142
+ super("upsert", tableNameOrRef, opts);
2143
+ this._columns = null;
2144
+ this._values = null; // single row, aligned to _columns
2145
+ this._conflictKeys = null;
2146
+ this._action = null; // "update" | "update-excluded" | "nothing"
2147
+ this._updateCols = null; // for update-excluded: [col, ...]
2148
+ this._updateExprs = null; // for update: { col: "?" | rawExpr } map, ordered
2149
+ this._updateParams = null; // params for the update expressions
2150
+ this._conflictWhere = null; // { sql, params } guarded fragment
2151
+ this._returning = null;
2152
+ }
2153
+
2154
+ columns(cols) {
2155
+ if (!Array.isArray(cols) || cols.length === 0) {
2156
+ throw _err("columns() expects a non-empty array", "sql-builder/bad-columns");
2157
+ }
2158
+ var self = this;
2159
+ cols.forEach(function (c) { self._assertColumnMember(c, "upsert"); _validateColumn(c); });
2160
+ this._columns = cols.slice();
2161
+ return this;
2162
+ }
2163
+
2164
+ values(obj) {
2165
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
2166
+ throw _err("upsert values() requires a single row object", "sql-builder/bad-values");
2167
+ }
2168
+ var keys = Object.keys(obj);
2169
+ if (keys.length === 0) throw _err("upsert row object is empty", "sql-builder/empty-values");
2170
+ if (this._columns === null) this.columns(keys);
2171
+ var self = this;
2172
+ this._values = this._columns.map(function (c) {
2173
+ if (!Object.prototype.hasOwnProperty.call(obj, c)) {
2174
+ throw _err("upsert row is missing column '" + c + "'", "sql-builder/missing-column");
2175
+ }
2176
+ return obj[c];
2177
+ });
2178
+ keys.forEach(function (k) {
2179
+ if (self._columns.indexOf(k) === -1) {
2180
+ throw _err("upsert row has column '" + k + "' not in the column set", "sql-builder/extra-column");
2181
+ }
2182
+ });
2183
+ return this;
2184
+ }
2185
+
2186
+ onConflict(keyCols) {
2187
+ var arr = Array.isArray(keyCols) ? keyCols : [keyCols];
2188
+ if (arr.length === 0) throw _err("onConflict requires at least one key column", "sql-builder/bad-conflict");
2189
+ arr.forEach(_validateColumn);
2190
+ this._conflictKeys = arr.slice();
2191
+ return this;
2192
+ }
2193
+
2194
+ // DO UPDATE SET col = EXCLUDED.col for each listed column.
2195
+ doUpdateFromExcluded(cols) {
2196
+ if (!Array.isArray(cols) || cols.length === 0) {
2197
+ throw _err("doUpdateFromExcluded requires a non-empty column array", "sql-builder/conflict-action");
2198
+ }
2199
+ var self = this;
2200
+ cols.forEach(function (c) { self._assertColumnMember(c, "upsert"); _validateColumn(c); });
2201
+ this._action = "update-excluded";
2202
+ this._updateCols = cols.slice();
2203
+ return this;
2204
+ }
2205
+
2206
+ // DO UPDATE SET col = <value-or-expr>. An array of columns sets each
2207
+ // to EXCLUDED.col (Postgres/SQLite) / VALUES(col) (MySQL). An object
2208
+ // { col: "?" } re-binds the column to a supplied param; { col: rawExpr }
2209
+ // sets it to a guarded raw expression. Pass exprParams for any `?` in
2210
+ // the object's expressions, in column order.
2211
+ doUpdate(colsOrMap, exprParams) {
2212
+ if (Array.isArray(colsOrMap)) return this.doUpdateFromExcluded(colsOrMap);
2213
+ if (!colsOrMap || typeof colsOrMap !== "object") {
2214
+ throw _err("doUpdate requires a column array or a { col: expr } map", "sql-builder/conflict-action");
2215
+ }
2216
+ var keys = Object.keys(colsOrMap);
2217
+ if (keys.length === 0) throw _err("doUpdate map is empty", "sql-builder/conflict-action");
2218
+ var self = this;
2219
+ keys.forEach(function (c) { self._assertColumnMember(c, "upsert"); _validateColumn(c); });
2220
+ this._action = "update";
2221
+ this._updateExprs = colsOrMap;
2222
+ this._updateParams = Array.isArray(exprParams) ? exprParams.slice()
2223
+ : (exprParams == null ? [] : [exprParams]);
2224
+ return this;
2225
+ }
2226
+
2227
+ doNothing() { this._action = "nothing"; return this; }
2228
+
2229
+ // The fenced WHERE on the conflict action (Postgres/SQLite); on MySQL
2230
+ // it folds into IF(<guard>, VALUES(col), col). Guarded raw fragment.
2231
+ //
2232
+ // opts.guardColumn names the column the fence protects - the column the
2233
+ // guard expression compares against (e.g. a monotonic fencing token).
2234
+ // On MySQL it is emitted LAST in the SET list so the IF on every other
2235
+ // column evaluates the guard against this column's PRE-UPDATE value
2236
+ // (MySQL evaluates the SET list left to right and a later assignment in
2237
+ // the same statement sees earlier columns' already-updated values - the
2238
+ // IF-eval-order hazard). Ignored on Postgres / SQLite, which apply the
2239
+ // WHERE atomically. When omitted the SET list keeps its declared order
2240
+ // (correct whenever the guard does not also appear as a SET target).
2241
+ conflictWhere(sql, params, opts) {
2242
+ var checked = _checkRawFragment(sql, params, opts, "conflictWhere");
2243
+ var guardColumn = opts && opts.guardColumn;
2244
+ if (guardColumn !== undefined && guardColumn !== null) {
2245
+ _validateColumn(guardColumn);
2246
+ checked.guardColumn = guardColumn;
2247
+ }
2248
+ this._conflictWhere = checked;
2249
+ return this;
2250
+ }
2251
+
2252
+ returning(cols) { this._returning = _normReturning(cols); return this; }
2253
+
2254
+ // Render the VALUES tuple through the same _renderValueCell choke-point
2255
+ // INSERT uses, so an upsert VALUES cell may be a bound literal, a
2256
+ // b.sql.cast(...) (`?::type`), or a b.sql.fn(...) allowlisted function
2257
+ // (`NOW()` / `CURRENT_TIMESTAMP`, no param). Without this the wrapper
2258
+ // objects would leak straight into params (a SqlFunction / CastValue is
2259
+ // an object, not a scalar) and the driver would mis-bind them.
2260
+ _renderValuesTuple(dialect) {
2261
+ var cells = [];
2262
+ var params = [];
2263
+ for (var i = 0; i < this._values.length; i += 1) {
2264
+ var rendered = _renderValueCell(this._values[i], dialect);
2265
+ cells.push(rendered.sql);
2266
+ for (var p = 0; p < rendered.params.length; p += 1) params.push(rendered.params[p]);
2267
+ }
2268
+ return { sql: cells.join(", "), params: params };
2269
+ }
2270
+
2271
+ _render() {
2272
+ if (this._columns === null || this._values === null) {
2273
+ throw _err("upsert requires columns + values()", "sql-builder/empty-values");
2274
+ }
2275
+ if (this._action === null) {
2276
+ throw _err("upsert requires a conflict action - doUpdate(...) / " +
2277
+ "doUpdateFromExcluded(...) / doNothing()", "sql-builder/conflict-action");
2278
+ }
2279
+ if (this._action !== "nothing" && this._conflictKeys === null && this._dialect !== "mysql") {
2280
+ throw _err("upsert doUpdate requires onConflict(keys) on " + this._dialect,
2281
+ "sql-builder/bad-conflict");
2282
+ }
2283
+ return this._dialect === "mysql" ? this._renderMysql() : this._renderStandard();
2284
+ }
2285
+
2286
+ // Postgres + SQLite: ON CONFLICT (keys) DO UPDATE ... [WHERE] [RETURNING].
2287
+ _renderStandard() {
2288
+ var dialect = this._dialect;
2289
+ var quotedCols = this._columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
2290
+ var tuple = this._renderValuesTuple(dialect);
2291
+ var params = tuple.params;
2292
+
2293
+ var sql = "INSERT INTO " + this._table.ref(dialect) + " (" + quotedCols + ") VALUES (" +
2294
+ tuple.sql + ")";
2295
+
2296
+ if (this._action === "nothing") {
2297
+ sql += " ON CONFLICT" + this._conflictTarget(dialect) + " DO NOTHING";
2298
+ } else {
2299
+ var setClause = this._buildStandardSet(dialect);
2300
+ sql += " ON CONFLICT" + this._conflictTarget(dialect) + " DO UPDATE SET " + setClause.sql;
2301
+ for (var i = 0; i < setClause.params.length; i++) params.push(setClause.params[i]);
2302
+ if (this._conflictWhere) {
2303
+ sql += " WHERE " + this._conflictWhere.sql;
2304
+ for (var w = 0; w < this._conflictWhere.params.length; w++) params.push(this._conflictWhere.params[w]);
2305
+ }
2306
+ }
2307
+ sql += _renderReturning(this._returning, dialect);
2308
+ return _emit(sql, params);
2309
+ }
2310
+
2311
+ _conflictTarget(dialect) {
2312
+ if (this._conflictKeys === null) return "";
2313
+ var keys = this._conflictKeys.map(function (k) { return _quoteId(k, dialect); }).join(", ");
2314
+ return " (" + keys + ")";
2315
+ }
2316
+
2317
+ _buildStandardSet(dialect) {
2318
+ var pieces = [];
2319
+ var params = [];
2320
+ if (this._action === "update-excluded") {
2321
+ for (var i = 0; i < this._updateCols.length; i++) {
2322
+ var c = this._updateCols[i];
2323
+ pieces.push(_quoteId(c, dialect) + " = EXCLUDED." + _quoteId(c, dialect));
2324
+ }
2325
+ } else {
2326
+ // action === "update": { col: expr } map. "?" re-binds to a param;
2327
+ // any other string is a guarded raw expression.
2328
+ var keys = Object.keys(this._updateExprs);
2329
+ var paramCursor = 0;
2330
+ for (var k = 0; k < keys.length; k++) {
2331
+ var col = keys[k];
2332
+ var expr = this._updateExprs[col];
2333
+ if (expr === "?") {
2334
+ pieces.push(_quoteId(col, dialect) + " = ?");
2335
+ params.push(this._updateParams[paramCursor]);
2336
+ paramCursor += 1;
2337
+ } else if (typeof expr === "string") {
2338
+ // Guarded raw expression (e.g. "EXCLUDED.\"count\" + 1"). Its
2339
+ // own `?` placeholders draw from _updateParams in order.
2340
+ var remaining = this._updateParams.slice(paramCursor);
2341
+ var needed = _countPlaceholders(expr);
2342
+ var exprParams = remaining.slice(0, needed);
2343
+ var checked = _checkRawFragment(expr, exprParams, { allowLiterals: false }, "doUpdate");
2344
+ pieces.push(_quoteId(col, dialect) + " = " + checked.sql);
2345
+ for (var ep = 0; ep < checked.params.length; ep++) params.push(checked.params[ep]);
2346
+ paramCursor += needed;
2347
+ } else {
2348
+ throw _err("doUpdate expression for '" + col + "' must be '?' or a raw SQL string",
2349
+ "sql-builder/conflict-action");
2350
+ }
2351
+ }
2352
+ }
2353
+ return { sql: pieces.join(", "), params: params };
2354
+ }
2355
+
2356
+ // MySQL: ON DUPLICATE KEY UPDATE col = VALUES(col). No WHERE, no
2357
+ // RETURNING. conflictWhere folds into IF(<guard>, VALUES(col), col).
2358
+ // The guard column is emitted LAST so MySQL's left-to-right evaluation
2359
+ // of the SET list sees the other columns' pre-guard values when the
2360
+ // guard references them (the IF-eval-order hazard). A readbackSql
2361
+ // SELECT is returned alongside so the caller can fetch the row that
2362
+ // RETURNING would have surfaced.
2363
+ _renderMysql() {
2364
+ var dialect = "mysql";
2365
+ var quotedCols = this._columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
2366
+ var tuple = this._renderValuesTuple(dialect);
2367
+ var params = tuple.params;
2368
+
2369
+ var sql = "INSERT INTO " + this._table.ref(dialect) + " (" + quotedCols + ") VALUES (" +
2370
+ tuple.sql + ")";
2371
+
2372
+ if (this._action === "nothing") {
2373
+ // MySQL has no DO NOTHING; the idiom is to no-op a key column.
2374
+ // Assign the first conflict / first column to itself so the row is
2375
+ // left unchanged on duplicate.
2376
+ var noopCol = (this._conflictKeys && this._conflictKeys[0]) || this._columns[0];
2377
+ sql += " ON DUPLICATE KEY UPDATE " + _quoteId(noopCol, dialect) + " = " +
2378
+ _quoteId(noopCol, dialect);
2379
+ } else {
2380
+ var setBuild = this._buildMysqlSet(dialect);
2381
+ sql += " ON DUPLICATE KEY UPDATE " + setBuild.sql;
2382
+ for (var i = 0; i < setBuild.params.length; i++) params.push(setBuild.params[i]);
2383
+ }
2384
+
2385
+ var out = _emit(sql, params);
2386
+ // RETURNING is unavailable on MySQL upsert - emit a readback SELECT
2387
+ // keyed on the conflict columns so the caller fetches the row. Validate
2388
+ // it through the same output gate.
2389
+ if (this._returning !== null) {
2390
+ var rb = this._buildReadback(dialect);
2391
+ _assertEmittable(rb.sql, rb.params);
2392
+ out.readbackSql = rb;
2393
+ }
2394
+ return out;
2395
+ }
2396
+
2397
+ _buildMysqlSet(dialect) {
2398
+ var guardSqlText = this._conflictWhere ? this._conflictWhere.sql : null;
2399
+ var guardParams = this._conflictWhere ? this._conflictWhere.params : [];
2400
+
2401
+ // Resolve the ordered (col, assignment-RHS) list WITHOUT the guard
2402
+ // wrap first; then, when a guard is present, wrap each RHS in
2403
+ // IF(<guard>, <rhs>, col) and order the guard column (a column the
2404
+ // guard references, if it is itself a set target) LAST.
2405
+ var assignments = []; // [{ col, rhs, rhsParams }]
2406
+ if (this._action === "update-excluded") {
2407
+ for (var i = 0; i < this._updateCols.length; i++) {
2408
+ var c = this._updateCols[i];
2409
+ assignments.push({ col: c, rhs: "VALUES(" + _quoteId(c, dialect) + ")", rhsParams: [] });
2410
+ }
2411
+ } else {
2412
+ var keys = Object.keys(this._updateExprs);
2413
+ var paramCursor = 0;
2414
+ for (var k = 0; k < keys.length; k++) {
2415
+ var col = keys[k];
2416
+ var expr = this._updateExprs[col];
2417
+ if (expr === "?") {
2418
+ assignments.push({ col: col, rhs: "?", rhsParams: [this._updateParams[paramCursor]] });
2419
+ paramCursor += 1;
2420
+ } else if (typeof expr === "string") {
2421
+ var needed = _countPlaceholders(expr);
2422
+ var exprParams = this._updateParams.slice(paramCursor, paramCursor + needed);
2423
+ var checked = _checkRawFragment(expr, exprParams, { allowLiterals: false }, "doUpdate");
2424
+ assignments.push({ col: col, rhs: checked.sql, rhsParams: checked.params });
2425
+ paramCursor += needed;
2426
+ } else {
2427
+ throw _err("doUpdate expression for '" + col + "' must be '?' or a raw SQL string",
2428
+ "sql-builder/conflict-action");
2429
+ }
2430
+ }
2431
+ }
2432
+
2433
+ var pieces = [];
2434
+ var params = [];
2435
+ if (guardSqlText === null) {
2436
+ for (var a = 0; a < assignments.length; a++) {
2437
+ pieces.push(_quoteId(assignments[a].col, dialect) + " = " + assignments[a].rhs);
2438
+ for (var ap = 0; ap < assignments[a].rhsParams.length; ap++) params.push(assignments[a].rhsParams[ap]);
2439
+ }
2440
+ return { sql: pieces.join(", "), params: params };
2441
+ }
2442
+
2443
+ // Guarded: col = IF(<guard>, <rhs>, col). The guard's own params are
2444
+ // bound once per assignment (the guard expression repeats per SET
2445
+ // target in MySQL's UPDATE list). The guard column - the column the
2446
+ // fenced comparison protects - is emitted last so the IF on the
2447
+ // other columns evaluates against this column's pre-update value.
2448
+ var guardColName = this._conflictWhere && this._conflictWhere.guardColumn
2449
+ ? this._conflictWhere.guardColumn : null;
2450
+ var ordered = assignments.slice();
2451
+ if (guardColName) {
2452
+ ordered.sort(function (x, y) {
2453
+ var xg = x.col === guardColName ? 1 : 0;
2454
+ var yg = y.col === guardColName ? 1 : 0;
2455
+ return xg - yg;
2456
+ });
2457
+ }
2458
+ for (var o = 0; o < ordered.length; o++) {
2459
+ var qc = _quoteId(ordered[o].col, dialect);
2460
+ pieces.push(qc + " = IF(" + guardSqlText + ", " + ordered[o].rhs + ", " + qc + ")");
2461
+ for (var gp = 0; gp < guardParams.length; gp++) params.push(guardParams[gp]);
2462
+ for (var rp = 0; rp < ordered[o].rhsParams.length; rp++) params.push(ordered[o].rhsParams[rp]);
2463
+ }
2464
+ return { sql: pieces.join(", "), params: params };
2465
+ }
2466
+
2467
+ // Readback SELECT for the MySQL upsert path - fetch the upserted row by
2468
+ // its conflict key(s) bound to the proposed values, projecting the
2469
+ // RETURNING column list (or "*").
2470
+ _buildReadback(dialect) {
2471
+ var keys = this._conflictKeys || [];
2472
+ if (keys.length === 0) {
2473
+ // No declared conflict key - read back by the full proposed row's
2474
+ // first column as a best-effort key.
2475
+ keys = [this._columns[0]];
2476
+ }
2477
+ var proj = (this._returning === "*" || this._returning === null)
2478
+ ? "*"
2479
+ : this._returning.map(function (c) { return _quoteId(c, dialect); }).join(", ");
2480
+ var sql = "SELECT " + proj + " FROM " + this._table.ref(dialect);
2481
+ var params = [];
2482
+ var conds = [];
2483
+ for (var i = 0; i < keys.length; i++) {
2484
+ var idx = this._columns.indexOf(keys[i]);
2485
+ if (idx === -1) {
2486
+ throw _err("upsert readback: conflict key '" + keys[i] + "' is not in the value set",
2487
+ "sql-builder/bad-conflict");
2488
+ }
2489
+ conds.push(_quoteId(keys[i], dialect) + " = ?");
2490
+ params.push(this._values[idx]);
2491
+ }
2492
+ sql += " WHERE " + conds.join(" AND ");
2493
+ return { sql: sql, params: params };
2494
+ }
2495
+ }
2496
+
2497
+ // RETURNING normalization - "*" or an array of validated columns.
2498
+ function _normReturning(cols) {
2499
+ if (cols === "*" || cols === undefined || cols === null) return "*";
2500
+ var arr = Array.isArray(cols) ? cols : [cols];
2501
+ arr.forEach(_validateColumn);
2502
+ return arr.slice();
2503
+ }
2504
+
2505
+ function _renderReturning(returning, dialect) {
2506
+ if (returning === null) return "";
2507
+ // MySQL / MariaDB do not support RETURNING on INSERT / UPDATE / DELETE.
2508
+ // Emitting it would parse-error at the driver; refuse at build with a
2509
+ // clear message so the operator runs an explicit read-back SELECT.
2510
+ // (The upsert verb's MySQL path already auto-emits a readback instead of
2511
+ // reaching here.)
2512
+ if (dialect === "mysql") {
2513
+ throw _err("RETURNING is not supported on MySQL for this verb - run a " +
2514
+ "read-back SELECT on the affected key instead", "sql-builder/returning-unsupported");
2515
+ }
2516
+ if (returning === "*") return " RETURNING *";
2517
+ return " RETURNING " + returning.map(function (c) { return _quoteId(c, dialect); }).join(", ");
2518
+ }
2519
+
2520
+ // ---- DDL builders ---------------------------------------------------
2521
+ //
2522
+ // Operator app-schema parity: createTable / createIndex / alterTable /
2523
+ // dropTable, dialect-aware and quote-by-construction, reusing the
2524
+ // framework's own type vocabulary (no fork of the type map). DDL is
2525
+ // declarative - these return { sql } (no params) since DDL binds no
2526
+ // values.
2527
+
2528
+ /**
2529
+ * @primitive b.sql.createTable
2530
+ * @signature b.sql.createTable(name, columns, opts?)
2531
+ * @since 0.14.29
2532
+ * @status stable
2533
+ * @related b.sql.createIndex, b.sql.alterTable, b.sql.dropTable
2534
+ *
2535
+ * Build a `CREATE TABLE` statement with every identifier quoted by
2536
+ * construction and every column type drawn from the framework's own
2537
+ * type map (so an operator app-schema table is portable across the same
2538
+ * dialects the framework tables are). `columns` is an array of column
2539
+ * specs; each `{ name, type, constraints?, primaryKey?, notNull?,
2540
+ * unique?, default? }`. The `type` is a logical name (`int` / `text` /
2541
+ * `blob` / `boolean` / `real` / `numeric` / `timestamp` / `json`) mapped
2542
+ * to the dialect token, or a verbatim dialect type string. Emits
2543
+ * `IF NOT EXISTS` by default so re-running is idempotent.
2544
+ *
2545
+ * @opts
2546
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
2547
+ * ifNotExists: boolean, // default true
2548
+ * primaryKey: array, // composite PK column list (table-level)
2549
+ *
2550
+ * @example
2551
+ * var b = require("@blamejs/core");
2552
+ * b.sql.createTable("widget", [
2553
+ * { name: "id", type: "int", primaryKey: true },
2554
+ * { name: "name", type: "text", notNull: true },
2555
+ * ], { dialect: "postgres" }).sql;
2556
+ * // -> 'CREATE TABLE IF NOT EXISTS widget ("id" BIGINT PRIMARY KEY, "name" TEXT NOT NULL)'
2557
+ * // (the bare default table name is the clusterStorage rewrite
2558
+ * // target; pass a prefix or schema to quote it)
2559
+ */
2560
+ function createTable(name, columns, opts) {
2561
+ opts = opts || {};
2562
+ var dialect = _normDialect(opts.dialect);
2563
+ var ref = _normTableRef(name, opts);
2564
+ if (!Array.isArray(columns) || columns.length === 0) {
2565
+ throw _err("createTable requires a non-empty columns array", "sql-builder/bad-columns");
2566
+ }
2567
+ var pieces = columns.map(function (c) {
2568
+ if (typeof c !== "object" || c === null || typeof c.name !== "string") {
2569
+ throw _err("createTable column must be { name, type, ... }", "sql-builder/bad-column");
2570
+ }
2571
+ _validateColumn(c.name);
2572
+ var qn = _quoteId(c.name, dialect);
2573
+ // Auto-increment / identity PK. This MUST diverge by dialect or an app
2574
+ // developed on the default sqlite dialect (where INTEGER PRIMARY KEY is
2575
+ // a rowid alias that auto-increments implicitly) breaks on the
2576
+ // postgres / mysql backend the builder advertises portability to (a
2577
+ // plain BIGINT PRIMARY KEY there does NOT default a value). postgres ->
2578
+ // BIGSERIAL (implies the int type + sequence default); sqlite -> INTEGER
2579
+ // PRIMARY KEY AUTOINCREMENT (MUST be INTEGER, not BIGINT); mysql ->
2580
+ // BIGINT AUTO_INCREMENT. An identity column is the primary key and takes
2581
+ // no DEFAULT.
2582
+ if (c.autoIncrement || c.serial) {
2583
+ if (c.default !== undefined) {
2584
+ throw _err("createTable: auto-increment column '" + c.name +
2585
+ "' cannot also declare a default", "sql-builder/bad-column");
2586
+ }
2587
+ var idDef;
2588
+ if (dialect === "postgres") idDef = qn + " BIGSERIAL PRIMARY KEY";
2589
+ else if (dialect === "mysql") idDef = qn + " BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY";
2590
+ else idDef = qn + " INTEGER PRIMARY KEY AUTOINCREMENT";
2591
+ if (typeof c.constraints === "string" && c.constraints.length > 0) {
2592
+ var idCk = _checkRawFragment(c.constraints, [], { allowLiterals: true }, "createTable.constraints");
2593
+ idDef += " " + idCk.sql;
2594
+ }
2595
+ return idDef;
2596
+ }
2597
+ var def = qn + " " + _ddlType(c.type, dialect);
2598
+ if (c.primaryKey) def += " PRIMARY KEY";
2599
+ if (c.notNull) def += " NOT NULL";
2600
+ if (c.unique) def += " UNIQUE";
2601
+ if (c.default !== undefined) def += " DEFAULT " + _ddlDefault(c.default);
2602
+ // Foreign key: a quote-by-construction REFERENCES clause (string table
2603
+ // name or { table, column?, onDelete?, onUpdate? }). Identifiers are
2604
+ // validated + quoted; the referential actions are allowlisted.
2605
+ if (c.references !== undefined && c.references !== false) {
2606
+ def += _ddlReferences(c.references, dialect, opts);
2607
+ }
2608
+ if (typeof c.constraints === "string" && c.constraints.length > 0) {
2609
+ // Verbatim constraint clause (CHECK / REFERENCES). Guarded so an
2610
+ // operator-influenced constraint can't smuggle a statement.
2611
+ var checked = _checkRawFragment(c.constraints, [], { allowLiterals: true }, "createTable.constraints");
2612
+ def += " " + checked.sql;
2613
+ }
2614
+ return def;
2615
+ });
2616
+ if (Array.isArray(opts.primaryKey) && opts.primaryKey.length > 0) {
2617
+ opts.primaryKey.forEach(_validateColumn);
2618
+ pieces.push("PRIMARY KEY (" + opts.primaryKey.map(function (k) {
2619
+ return _quoteId(k, dialect);
2620
+ }).join(", ") + ")");
2621
+ }
2622
+ var ifNot = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
2623
+ var sql = "CREATE TABLE " + ifNot + ref.ref(dialect) + " (" + pieces.join(", ") + ")";
2624
+ return { sql: sql, params: [] };
2625
+ }
2626
+
2627
+ // DDL DEFAULT renderer - numeric / boolean / null inline; a string
2628
+ // default is emitted as a single-quoted SQL literal with the quote
2629
+ // doubled to escape it (DDL defaults are static, operator-controlled,
2630
+ // and never bound).
2631
+ function _ddlDefault(value) {
2632
+ if (value === null) return "NULL";
2633
+ if (typeof value === "number" && isFinite(value)) return String(value);
2634
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
2635
+ if (typeof value === "string") return "'" + value.replace(/'/g, "''") + "'";
2636
+ throw _err("createTable column default must be a string, number, boolean, or null",
2637
+ "sql-builder/bad-default");
2638
+ }
2639
+
2640
+ // Referential actions allowed on a foreign key (ON DELETE / ON UPDATE).
2641
+ var FK_ACTIONS = Object.freeze({
2642
+ "CASCADE": true, "SET NULL": true, "SET DEFAULT": true, "RESTRICT": true, "NO ACTION": true,
2643
+ });
2644
+
2645
+ // Quote-by-construction REFERENCES clause. `references` is a table-name
2646
+ // string (referenced column defaults to "id") or { table, column?, onDelete?,
2647
+ // onUpdate? }. The referenced table inherits the parent table's prefix /
2648
+ // schema so a prefixed deployment's FK target resolves to the same namespace.
2649
+ function _ddlReferences(references, dialect, opts) {
2650
+ var spec = typeof references === "string" ? { table: references } : references;
2651
+ if (!spec || typeof spec.table !== "string" || spec.table.length === 0) {
2652
+ throw _err("column 'references' must be a table name or { table, column?, onDelete?, onUpdate? }",
2653
+ "sql-builder/bad-references");
2654
+ }
2655
+ var refTable = _normTableRef(spec.table, opts || {});
2656
+ var refCol = spec.column || "id";
2657
+ _validateColumn(refCol);
2658
+ var out = " REFERENCES " + refTable.ref(dialect) + " (" + _quoteId(refCol, dialect) + ")";
2659
+ ["onDelete", "onUpdate"].forEach(function (k) {
2660
+ if (spec[k] === undefined || spec[k] === null) return;
2661
+ var action = String(spec[k]).toUpperCase();
2662
+ if (FK_ACTIONS[action] !== true) {
2663
+ throw _err("invalid " + k + " referential action '" + spec[k] +
2664
+ "' (CASCADE / SET NULL / SET DEFAULT / RESTRICT / NO ACTION)", "sql-builder/bad-fk-action");
2665
+ }
2666
+ out += (k === "onDelete" ? " ON DELETE " : " ON UPDATE ") + action;
2667
+ });
2668
+ return out;
2669
+ }
2670
+
2671
+ /**
2672
+ * @primitive b.sql.createIndex
2673
+ * @signature b.sql.createIndex(name, tableName, columns, opts?)
2674
+ * @since 0.14.29
2675
+ * @status stable
2676
+ * @related b.sql.createTable, b.sql.dropTable
2677
+ *
2678
+ * Build a `CREATE INDEX` statement, identifiers quoted by construction,
2679
+ * `IF NOT EXISTS` by default. `columns` is the indexed column list (each
2680
+ * quoted); `opts.unique` emits a `UNIQUE INDEX`.
2681
+ *
2682
+ * @opts
2683
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
2684
+ * unique: boolean, // default false
2685
+ * ifNotExists: boolean, // default true
2686
+ * where: string, // partial-index predicate (guarded raw fragment)
2687
+ * whereParams: Array, // bound params for the partial-index predicate
2688
+ *
2689
+ * A partial index (`opts.where`) narrows the index to rows matching a
2690
+ * boolean predicate - the publisher's pending-row index
2691
+ * (`WHERE status = 'pending'`) is the canonical case. The predicate rides
2692
+ * the same `b.guardSql`-gated raw-fragment path as `whereRaw` (a static
2693
+ * operator-controlled literal opts in via `allowLiterals`); MySQL has no
2694
+ * partial index, so it throws there.
2695
+ *
2696
+ * @example
2697
+ * var b = require("@blamejs/core");
2698
+ * b.sql.createIndex("idx_widget_name", "widget", ["name"],
2699
+ * { dialect: "sqlite", unique: true }).sql;
2700
+ * // -> 'CREATE UNIQUE INDEX IF NOT EXISTS "idx_widget_name" ON widget ("name")'
2701
+ * // (the index name is quoted; the bare default table stays the
2702
+ * // clusterStorage rewrite target)
2703
+ */
2704
+ function createIndex(name, tableName, columns, opts) {
2705
+ opts = opts || {};
2706
+ var dialect = _normDialect(opts.dialect);
2707
+ _validateColumn(name);
2708
+ var ref = _normTableRef(tableName, opts);
2709
+ if (!Array.isArray(columns) || columns.length === 0) {
2710
+ throw _err("createIndex requires a non-empty columns array", "sql-builder/bad-columns");
2711
+ }
2712
+ columns.forEach(_validateColumn);
2713
+ var ifNot = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
2714
+ var cols = columns.map(function (c) { return _quoteId(c, dialect); }).join(", ");
2715
+ var sql = "CREATE " + (opts.unique ? "UNIQUE " : "") + "INDEX " + ifNot +
2716
+ _quoteId(name, dialect) + " ON " + ref.ref(dialect) + " (" + cols + ")";
2717
+ var params = [];
2718
+ if (opts.where !== undefined && opts.where !== null) {
2719
+ if (dialect === "mysql") {
2720
+ throw _err("createIndex: partial index (where) is Postgres / SQLite-only " +
2721
+ "(MySQL has no partial index)", "sql-builder/partial-index-unsupported");
2722
+ }
2723
+ var checked = _checkRawFragment(opts.where, opts.whereParams,
2724
+ { allowLiterals: opts.allowLiterals !== false }, "createIndex.where");
2725
+ sql += " WHERE " + checked.sql;
2726
+ params = checked.params;
2727
+ }
2728
+ return { sql: sql, params: params };
2729
+ }
2730
+
2731
+ /**
2732
+ * @primitive b.sql.alterTable
2733
+ * @signature b.sql.alterTable(name, change, opts?)
2734
+ * @since 0.14.29
2735
+ * @status stable
2736
+ * @related b.sql.createTable, b.sql.dropTable
2737
+ *
2738
+ * Build an `ALTER TABLE` statement. `change` is one of
2739
+ * `{ addColumn: { name, type, ... } }`,
2740
+ * `{ dropColumn: "name" }`, or
2741
+ * `{ renameColumn: { from, to } }` - each identifier quoted, the
2742
+ * add-column type drawn from the framework type map.
2743
+ *
2744
+ * @opts
2745
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
2746
+ *
2747
+ * @example
2748
+ * var b = require("@blamejs/core");
2749
+ * b.sql.alterTable("widget", { addColumn: { name: "active", type: "boolean" } },
2750
+ * { dialect: "postgres" }).sql;
2751
+ * // -> 'ALTER TABLE widget ADD COLUMN "active" BOOLEAN'
2752
+ * // (bare default table name; the added column is quoted)
2753
+ */
2754
+ function alterTable(name, change, opts) {
2755
+ opts = opts || {};
2756
+ var dialect = _normDialect(opts.dialect);
2757
+ var ref = _normTableRef(name, opts);
2758
+ if (!change || typeof change !== "object") {
2759
+ throw _err("alterTable requires a change descriptor", "sql-builder/bad-alter");
2760
+ }
2761
+ var head = "ALTER TABLE " + ref.ref(dialect) + " ";
2762
+ if (change.addColumn) {
2763
+ var col = change.addColumn;
2764
+ if (typeof col.name !== "string") throw _err("addColumn requires a name", "sql-builder/bad-column");
2765
+ _validateColumn(col.name);
2766
+ var def = _quoteId(col.name, dialect) + " " + _ddlType(col.type, dialect);
2767
+ if (col.notNull) def += " NOT NULL";
2768
+ if (col.unique) def += " UNIQUE";
2769
+ if (col.default !== undefined) def += " DEFAULT " + _ddlDefault(col.default);
2770
+ return { sql: head + "ADD COLUMN " + def, params: [] };
2771
+ }
2772
+ if (change.dropColumn) {
2773
+ _validateColumn(change.dropColumn);
2774
+ return { sql: head + "DROP COLUMN " + _quoteId(change.dropColumn, dialect), params: [] };
2775
+ }
2776
+ if (change.renameColumn) {
2777
+ var rc = change.renameColumn;
2778
+ if (typeof rc.from !== "string" || typeof rc.to !== "string") {
2779
+ throw _err("renameColumn requires { from, to }", "sql-builder/bad-alter");
2780
+ }
2781
+ _validateColumn(rc.from);
2782
+ _validateColumn(rc.to);
2783
+ return {
2784
+ sql: head + "RENAME COLUMN " + _quoteId(rc.from, dialect) + " TO " + _quoteId(rc.to, dialect),
2785
+ params: [],
2786
+ };
2787
+ }
2788
+ throw _err("alterTable change must be addColumn / dropColumn / renameColumn",
2789
+ "sql-builder/bad-alter");
2790
+ }
2791
+
2792
+ /**
2793
+ * @primitive b.sql.dropTable
2794
+ * @signature b.sql.dropTable(name, opts?)
2795
+ * @since 0.14.29
2796
+ * @status stable
2797
+ * @related b.sql.createTable, b.sql.alterTable
2798
+ *
2799
+ * Build a `DROP TABLE` statement, identifier quoted, `IF EXISTS` by
2800
+ * default so dropping a missing table is a no-op.
2801
+ *
2802
+ * @opts
2803
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
2804
+ * ifExists: boolean, // default true
2805
+ * cascade: boolean, // default false (Postgres CASCADE)
2806
+ *
2807
+ * @example
2808
+ * var b = require("@blamejs/core");
2809
+ * b.sql.dropTable("widget", { dialect: "postgres", cascade: true }).sql;
2810
+ * // -> 'DROP TABLE IF EXISTS widget CASCADE'
2811
+ * // (bare default table name; the clusterStorage rewrite target)
2812
+ */
2813
+ function dropTable(name, opts) {
2814
+ opts = opts || {};
2815
+ var dialect = _normDialect(opts.dialect);
2816
+ var ref = _normTableRef(name, opts);
2817
+ var ifExists = opts.ifExists === false ? "" : "IF EXISTS ";
2818
+ var sql = "DROP TABLE " + ifExists + ref.ref(dialect);
2819
+ if (opts.cascade && dialect === "postgres") sql += " CASCADE";
2820
+ return { sql: sql, params: [] };
2821
+ }
2822
+
2823
+ // ---- sqlite virtual table (FTS5) ------------------------------------
2824
+ //
2825
+ // The sqlite-only virtual-table DDL b.sql's general createTable has no
2826
+ // form for - the FTS5 full-text index the mail store's sealed-token
2827
+ // search runs MATCH against. The supported module is `fts5` (the only one
2828
+ // a framework primitive ships against); the column list + tokenizer
2829
+ // option are quoted / allowlisted by construction so no operator-supplied
2830
+ // token reaches the DDL raw.
2831
+
2832
+ // The tokenizers an operator may name - the fixed FTS5 built-in set. A
2833
+ // custom tokenizer (a loadable extension) is outside the framework's
2834
+ // supported surface and refused, so no arbitrary token reaches the
2835
+ // `tokenize = '...'` option.
2836
+ var FTS5_TOKENIZERS = Object.freeze({
2837
+ "unicode61": true, "ascii": true, "porter": true, "trigram": true,
2838
+ });
2839
+ // The tokenizer ARGUMENT tokens FTS5 accepts after the tokenizer name
2840
+ // (e.g. `unicode61 remove_diacritics 2`). A fixed allowlist so the whole
2841
+ // `tokenize` option is builder-controlled end to end.
2842
+ var FTS5_TOKENIZER_ARGS = Object.freeze({
2843
+ "remove_diacritics": true, "0": true, "1": true, "2": true,
2844
+ "categories": true, "tokenchars": true, "separators": true, "case_sensitive": true,
2845
+ });
2846
+
2847
+ /**
2848
+ * @primitive b.sql.createVirtualTable
2849
+ * @signature b.sql.createVirtualTable(name, opts)
2850
+ * @since 0.15.0
2851
+ * @status stable
2852
+ * @related b.sql.createTable, b.sql.select, b.sql.createIndex
2853
+ *
2854
+ * Build a sqlite `CREATE VIRTUAL TABLE ... USING fts5(...)` statement for
2855
+ * a full-text index - the construct `b.sql.createTable` has no form for.
2856
+ * `opts.columns` is the FTS5 column list; each entry is a column name (a
2857
+ * searched column) or `{ name, unindexed: true }` (a stored-but-not-
2858
+ * searched column, the join key). `opts.tokenize` names a built-in FTS5
2859
+ * tokenizer (`unicode61` / `ascii` / `porter` / `trigram`) and optional
2860
+ * allowlisted arguments (`remove_diacritics 2`); a custom / loadable
2861
+ * tokenizer is refused. Every column name is quoted by construction and
2862
+ * every tokenizer token is allowlisted, so no operator-supplied token
2863
+ * reaches the DDL raw. `IF NOT EXISTS` by default. sqlite-only (FTS5 is a
2864
+ * sqlite extension); a non-sqlite dialect throws at build.
2865
+ *
2866
+ * @opts
2867
+ * columns: Array, // FTS5 columns: "name" | { name, unindexed }
2868
+ * tokenize: string, // "unicode61 remove_diacritics 2" (built-in + allowlisted args)
2869
+ * ifNotExists: boolean, // default true
2870
+ *
2871
+ * @example
2872
+ * var b = require("@blamejs/core");
2873
+ * b.sql.createVirtualTable("mail_fts", {
2874
+ * columns: [{ name: "objectid", unindexed: true }, "subject_toks", "body_toks"],
2875
+ * tokenize: "unicode61 remove_diacritics 2",
2876
+ * }).sql;
2877
+ * // -> 'CREATE VIRTUAL TABLE IF NOT EXISTS "mail_fts" USING fts5(' +
2878
+ * // '"objectid" UNINDEXED, "subject_toks", "body_toks", ' +
2879
+ * // "tokenize = 'unicode61 remove_diacritics 2')"
2880
+ */
2881
+ function createVirtualTable(name, opts) {
2882
+ opts = opts || {};
2883
+ var dialect = _normDialect(opts.dialect || "sqlite");
2884
+ if (dialect !== "sqlite") {
2885
+ throw _err("createVirtualTable (USING fts5) is sqlite-only (FTS5 is a sqlite " +
2886
+ "extension); build it with { dialect: 'sqlite' }", "sql-builder/vtable-sqlite-only");
2887
+ }
2888
+ // The table identifier is QUOTED (this DDL targets a concrete sqlite
2889
+ // handle, not a clusterStorage-rewritten bare name).
2890
+ var ref = _normTableRef(name, Object.assign({}, opts, { quoteName: true }));
2891
+ if (!Array.isArray(opts.columns) || opts.columns.length === 0) {
2892
+ throw _err("createVirtualTable requires a non-empty columns array", "sql-builder/bad-columns");
2893
+ }
2894
+ var cols = opts.columns.map(function (c) {
2895
+ var colName = typeof c === "string" ? c : (c && c.name);
2896
+ _validateColumn(colName);
2897
+ var piece = _quoteId(colName, "sqlite");
2898
+ if (c && typeof c === "object" && c.unindexed === true) piece += " UNINDEXED";
2899
+ // Reject any other per-column option token (an arbitrary string would
2900
+ // splice into the DDL); only UNINDEXED is supported.
2901
+ if (c && typeof c === "object") {
2902
+ for (var k in c) {
2903
+ if (!Object.prototype.hasOwnProperty.call(c, k)) continue;
2904
+ if (k === "name" || k === "unindexed") continue;
2905
+ throw _err("createVirtualTable column option '" + k + "' is not supported " +
2906
+ "(only { name, unindexed } )", "sql-builder/bad-vtable-column");
2907
+ }
2908
+ }
2909
+ return piece;
2910
+ });
2911
+ var tokenizeClause = "";
2912
+ if (opts.tokenize !== undefined && opts.tokenize !== null) {
2913
+ tokenizeClause = ", tokenize = '" + _ftsTokenize(opts.tokenize) + "'";
2914
+ }
2915
+ var ifNot = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
2916
+ var sql = "CREATE VIRTUAL TABLE " + ifNot + ref.ref("sqlite") + " USING fts5(" +
2917
+ cols.join(", ") + tokenizeClause + ")";
2918
+ return { sql: sql, params: [] };
2919
+ }
2920
+
2921
+ // Validate + re-render an FTS5 tokenize spec from its allowlisted tokens.
2922
+ // The first token is the tokenizer name (built-in only); the rest are
2923
+ // allowlisted argument tokens. Returns the canonical space-joined string -
2924
+ // every token came off the allowlist, so the emitted `'...'` literal is
2925
+ // fully builder-controlled (no operator token reaches the DDL raw).
2926
+ function _ftsTokenize(spec) {
2927
+ if (typeof spec !== "string" || spec.length === 0) {
2928
+ throw _err("createVirtualTable tokenize must be a non-empty string", "sql-builder/bad-tokenize");
2929
+ }
2930
+ var tokens = spec.trim().split(/\s+/);
2931
+ if (FTS5_TOKENIZERS[tokens[0]] !== true) {
2932
+ throw _err("createVirtualTable tokenizer '" + tokens[0] + "' is not a built-in FTS5 " +
2933
+ "tokenizer (unicode61 / ascii / porter / trigram); a loadable tokenizer is refused",
2934
+ "sql-builder/bad-tokenize");
2935
+ }
2936
+ for (var i = 1; i < tokens.length; i += 1) {
2937
+ if (FTS5_TOKENIZER_ARGS[tokens[i]] !== true) {
2938
+ throw _err("createVirtualTable tokenize argument '" + tokens[i] + "' is not on the " +
2939
+ "allowlist", "sql-builder/bad-tokenize");
2940
+ }
2941
+ }
2942
+ return tokens.join(" ");
2943
+ }
2944
+
2945
+ // ---- Row-Level Security (Postgres RLS) ------------------------------
2946
+ //
2947
+ // Postgres-only: ENABLE ROW LEVEL SECURITY + CREATE POLICY + DROP POLICY.
2948
+ // Identifiers (schema / table / policy / role) are quoted by construction
2949
+ // through the framework's single identifier primitive; the USING /
2950
+ // WITH CHECK boolean predicates ride the EXISTING guardSql-gated raw-
2951
+ // fragment path (the same choke-point whereRaw / setRaw use), so an
2952
+ // operator-influenced predicate can't smuggle a stacked statement, a
2953
+ // string literal, or a dangerous primitive. SQLite + MySQL have no
2954
+ // portable RLS grammar, so every RLS builder refuses a non-Postgres
2955
+ // dialect at build time (config-time tier - the operator catches the
2956
+ // typo at boot, not at apply).
2957
+
2958
+ var RLS_COMMANDS = Object.freeze({
2959
+ ALL: true, SELECT: true, INSERT: true, UPDATE: true, DELETE: true,
2960
+ });
2961
+
2962
+ function _assertPostgresRls(dialect, what) {
2963
+ if (dialect !== "postgres") {
2964
+ throw _err(what + " is Postgres-only (SQLite / MySQL have no portable " +
2965
+ "row-level-security grammar); build it with { dialect: 'postgres' }",
2966
+ "sql-builder/rls-postgres-only");
2967
+ }
2968
+ }
2969
+
2970
+ // A USING / WITH CHECK predicate is a boolean value expression, routed
2971
+ // through the SAME raw-fragment guard whereRaw uses (b.guardSql strict +
2972
+ // the embedded-literal + placeholder-count scanners). It binds no params
2973
+ // by default - an RLS predicate references session GUCs / row columns, not
2974
+ // per-request bound values - but accepts a params array for the rare
2975
+ // parameterized predicate. Returns the checked { sql, params }.
2976
+ function _rlsPredicate(label, expr, params, opts) {
2977
+ return _checkRawFragment(expr, params, opts || {}, label);
2978
+ }
2979
+
2980
+ /**
2981
+ * @primitive b.sql.enableRowLevelSecurity
2982
+ * @signature b.sql.enableRowLevelSecurity(table, opts?)
2983
+ * @since 0.15.0
2984
+ * @status stable
2985
+ * @related b.sql.createPolicy, b.sql.dropPolicy, b.db.declareRowPolicy
2986
+ *
2987
+ * Build a Postgres `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` statement,
2988
+ * the table identifier quoted by construction (schema-qualified via
2989
+ * `{ schema }` or the dotted `"schema.table"` form). Postgres has no
2990
+ * `IF NOT EXISTS` for this verb; the declarative migration in
2991
+ * `b.db.declareRowPolicy` checks `pg_class.relrowsecurity` and skips the
2992
+ * ALTER when already enabled, so re-running a partially-applied migration
2993
+ * set does not fail. Refuses a non-Postgres dialect at build time.
2994
+ *
2995
+ * @opts
2996
+ * schema: string, // schema qualifier, quoted at build time
2997
+ * force: boolean, // default false - emit FORCE ROW LEVEL SECURITY
2998
+ *
2999
+ * @example
3000
+ * var b = require("@blamejs/core");
3001
+ * b.sql.enableRowLevelSecurity("sessions",
3002
+ * { schema: "public" }).sql;
3003
+ * // -> 'ALTER TABLE "public"."sessions" ENABLE ROW LEVEL SECURITY'
3004
+ */
3005
+ function enableRowLevelSecurity(name, opts) {
3006
+ opts = opts || {};
3007
+ var dialect = _normDialect(opts.dialect || "postgres");
3008
+ _assertPostgresRls(dialect, "enableRowLevelSecurity");
3009
+ // RLS targets a concrete table, so it is quoted (quoteName) rather than
3010
+ // emitted bare - there is no clusterStorage rewrite for a Postgres RLS
3011
+ // migration, which runs against the operator's external backend directly.
3012
+ var ref = _normTableRef(name, Object.assign({}, opts, { quoteName: true }));
3013
+ var sql = "ALTER TABLE " + ref.ref(dialect) + " " +
3014
+ (opts.force === true ? "FORCE" : "ENABLE") + " ROW LEVEL SECURITY";
3015
+ return { sql: sql, params: [] };
3016
+ }
3017
+
3018
+ /**
3019
+ * @primitive b.sql.disableRowLevelSecurity
3020
+ * @signature b.sql.disableRowLevelSecurity(table, opts?)
3021
+ * @since 0.15.0
3022
+ * @status stable
3023
+ * @related b.sql.enableRowLevelSecurity, b.sql.dropPolicy
3024
+ *
3025
+ * Build a Postgres `ALTER TABLE ... DISABLE ROW LEVEL SECURITY` statement
3026
+ * (the inverse of `enableRowLevelSecurity`), the table identifier quoted
3027
+ * by construction. Refuses a non-Postgres dialect at build time.
3028
+ *
3029
+ * @opts
3030
+ * schema: string, // schema qualifier, quoted at build time
3031
+ *
3032
+ * @example
3033
+ * var b = require("@blamejs/core");
3034
+ * b.sql.disableRowLevelSecurity("sessions", { schema: "public" }).sql;
3035
+ * // -> 'ALTER TABLE "public"."sessions" DISABLE ROW LEVEL SECURITY'
3036
+ */
3037
+ function disableRowLevelSecurity(name, opts) {
3038
+ opts = opts || {};
3039
+ var dialect = _normDialect(opts.dialect || "postgres");
3040
+ _assertPostgresRls(dialect, "disableRowLevelSecurity");
3041
+ var ref = _normTableRef(name, Object.assign({}, opts, { quoteName: true }));
3042
+ return { sql: "ALTER TABLE " + ref.ref(dialect) + " DISABLE ROW LEVEL SECURITY", params: [] };
3043
+ }
3044
+
3045
+ /**
3046
+ * @primitive b.sql.createPolicy
3047
+ * @signature b.sql.createPolicy(name, table, spec, opts?)
3048
+ * @since 0.15.0
3049
+ * @status stable
3050
+ * @related b.sql.enableRowLevelSecurity, b.sql.dropPolicy, b.db.declareRowPolicy
3051
+ *
3052
+ * Build a Postgres `CREATE POLICY` statement in canonical clause order:
3053
+ * `name -> table -> AS PERMISSIVE|RESTRICTIVE -> FOR <command> ->
3054
+ * TO <role> -> USING (<pred>) -> WITH CHECK (<pred>)`. The policy / table /
3055
+ * role identifiers are quoted by construction; the `using` and `withCheck`
3056
+ * boolean predicates ride the SAME `b.guardSql`-gated raw-fragment path as
3057
+ * `whereRaw` (strict profile by default, embedded-literal + placeholder-
3058
+ * count scanners), so an operator-influenced predicate cannot smuggle a
3059
+ * stacked statement or a dangerous primitive. Refuses a non-Postgres
3060
+ * dialect at build time.
3061
+ *
3062
+ * `spec.command` is one of `ALL` (default) / `SELECT` / `INSERT` /
3063
+ * `UPDATE` / `DELETE`; `spec.permissive` defaults `true` (a `PERMISSIVE`
3064
+ * policy OR-combines with peers; `false` emits `RESTRICTIVE`, which
3065
+ * AND-combines). `spec.role` is optional (omitted -> the policy applies to
3066
+ * every role). The predicates default to binding no params - an RLS
3067
+ * predicate references session GUCs / row columns - but a `usingParams` /
3068
+ * `withCheckParams` array binds values for a parameterized predicate.
3069
+ *
3070
+ * @opts
3071
+ * schema: string, // schema qualifier for the table
3072
+ * guardProfile: string, // raw-fragment guard profile (default "strict")
3073
+ *
3074
+ * @example
3075
+ * var b = require("@blamejs/core");
3076
+ * b.sql.createPolicy("tenant_isolation", "sessions", {
3077
+ * role: "app_user",
3078
+ * command: "ALL",
3079
+ * using: "tenant_id = current_setting('app.tenant_id')::uuid",
3080
+ * withCheck: "tenant_id = current_setting('app.tenant_id')::uuid",
3081
+ * }, { schema: "public" }).sql;
3082
+ * // -> 'CREATE POLICY "tenant_isolation" ON "public"."sessions" ' +
3083
+ * // 'AS PERMISSIVE FOR ALL TO "app_user" ' +
3084
+ * // "USING (tenant_id = current_setting('app.tenant_id')::uuid) " +
3085
+ * // "WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid)"
3086
+ * // (the static current_setting literal opts in via allowLiterals)
3087
+ */
3088
+ function createPolicy(name, table, spec, opts) {
3089
+ opts = opts || {};
3090
+ spec = spec || {};
3091
+ var dialect = _normDialect(opts.dialect || "postgres");
3092
+ _assertPostgresRls(dialect, "createPolicy");
3093
+ _validateColumn(name);
3094
+ var ref = _normTableRef(table, Object.assign({}, opts, { quoteName: true }));
3095
+
3096
+ var command = "ALL";
3097
+ if (spec.command !== undefined && spec.command !== null) {
3098
+ if (typeof spec.command !== "string" || RLS_COMMANDS[spec.command.toUpperCase()] !== true) {
3099
+ throw _err("createPolicy command must be ALL / SELECT / INSERT / UPDATE / DELETE (got " +
3100
+ JSON.stringify(spec.command) + ")", "sql-builder/bad-rls-command");
3101
+ }
3102
+ command = spec.command.toUpperCase();
3103
+ }
3104
+ var permissive = spec.permissive !== false;
3105
+
3106
+ if (spec.using === undefined || spec.using === null) {
3107
+ throw _err("createPolicy requires a 'using' boolean predicate", "sql-builder/bad-rls-predicate");
3108
+ }
3109
+ // The USING / WITH CHECK predicates are guarded raw fragments. RLS
3110
+ // predicates routinely carry a static, operator-controlled string
3111
+ // literal (current_setting('app.tenant_id')), so allowLiterals defaults
3112
+ // ON here - the literal is the policy author's, never per-request input;
3113
+ // every value-bearing operand still binds via a ? placeholder + params.
3114
+ var rawOpts = {
3115
+ guardProfile: opts.guardProfile || "strict",
3116
+ allowLiterals: spec.allowLiterals !== false,
3117
+ };
3118
+ var using = _rlsPredicate("createPolicy.using", spec.using, spec.usingParams, rawOpts);
3119
+ var withCheck = null;
3120
+ if (spec.withCheck !== undefined && spec.withCheck !== null) {
3121
+ withCheck = _rlsPredicate("createPolicy.withCheck", spec.withCheck, spec.withCheckParams, rawOpts);
3122
+ }
3123
+
3124
+ var sql = "CREATE POLICY " + _quoteId(name, dialect) + " ON " + ref.ref(dialect);
3125
+ sql += " AS " + (permissive ? "PERMISSIVE" : "RESTRICTIVE");
3126
+ sql += " FOR " + command;
3127
+ if (spec.role !== undefined && spec.role !== null) {
3128
+ _validateColumn(spec.role);
3129
+ sql += " TO " + _quoteId(spec.role, dialect);
3130
+ }
3131
+ var params = [];
3132
+ sql += " USING (" + using.sql + ")";
3133
+ for (var ui = 0; ui < using.params.length; ui += 1) params.push(using.params[ui]);
3134
+ if (withCheck) {
3135
+ sql += " WITH CHECK (" + withCheck.sql + ")";
3136
+ for (var wi = 0; wi < withCheck.params.length; wi += 1) params.push(withCheck.params[wi]);
3137
+ }
3138
+ return _emit(sql, params);
3139
+ }
3140
+
3141
+ /**
3142
+ * @primitive b.sql.dropPolicy
3143
+ * @signature b.sql.dropPolicy(name, table, opts?)
3144
+ * @since 0.15.0
3145
+ * @status stable
3146
+ * @related b.sql.createPolicy, b.sql.enableRowLevelSecurity
3147
+ *
3148
+ * Build a Postgres `DROP POLICY` statement, the policy + table identifiers
3149
+ * quoted by construction, `IF EXISTS` by default so dropping a missing
3150
+ * policy is a no-op (the migration down-path is idempotent). Refuses a
3151
+ * non-Postgres dialect at build time.
3152
+ *
3153
+ * @opts
3154
+ * schema: string, // schema qualifier for the table
3155
+ * ifExists: boolean, // default true
3156
+ *
3157
+ * @example
3158
+ * var b = require("@blamejs/core");
3159
+ * b.sql.dropPolicy("tenant_isolation", "sessions", { schema: "public" }).sql;
3160
+ * // -> 'DROP POLICY IF EXISTS "tenant_isolation" ON "public"."sessions"'
3161
+ */
3162
+ function dropPolicy(name, table, opts) {
3163
+ opts = opts || {};
3164
+ var dialect = _normDialect(opts.dialect || "postgres");
3165
+ _assertPostgresRls(dialect, "dropPolicy");
3166
+ _validateColumn(name);
3167
+ var ref = _normTableRef(table, Object.assign({}, opts, { quoteName: true }));
3168
+ var ifExists = opts.ifExists === false ? "" : "IF EXISTS ";
3169
+ return { sql: "DROP POLICY " + ifExists + _quoteId(name, dialect) + " ON " + ref.ref(dialect), params: [] };
3170
+ }
3171
+
3172
+ // ---- Catalog / PRAGMA (narrow audited sqlite-internal sub-API) ------
3173
+ //
3174
+ // b.safeSql.quoteIdentifier refuses an `sqlite_`-prefixed identifier BY
3175
+ // DESIGN (sql/internal-prefix) and _assertEmittable refuses a multi-verb
3176
+ // statement - both stay intact for every general caller. The vault key-
3177
+ // rotation pipeline (lib/vault/rotate.js) legitimately needs to read the
3178
+ // sqlite catalog (sqlite_master), introspect a table (PRAGMA table_info),
3179
+ // set journal mode + synchronous, checkpoint the WAL, and sample rows in
3180
+ // random order. None of those compose through the general builder. This
3181
+ // narrow sub-API allowlists EXACTLY those statements + verbs and nothing
3182
+ // else: every other sqlite_-prefixed identifier and every other PRAGMA
3183
+ // verb still refuses through the general quoteIdentifier / builder gate.
3184
+ //
3185
+ // Every emitter here returns the SAME { sql, params } shape the verbs do,
3186
+ // validated through a CATALOG-scoped output gate (_assertCatalogEmittable)
3187
+ // that allows the sqlite_master / PRAGMA / RANDOM() forms the general
3188
+ // _assertEmittable refuses while keeping NUL / surrogate / stacked-
3189
+ // statement / unterminated-quote refusals fully intact.
3190
+
3191
+ // The exact PRAGMA verbs this sub-API will emit. A verb not on this list
3192
+ // throws - the allowlist is the audit boundary, not a suggestion.
3193
+ var CATALOG_PRAGMA_VERBS = Object.freeze({
3194
+ "table_info": { kind: "introspect" }, // PRAGMA table_info("<table>")
3195
+ "journal_mode": { kind: "set-or-read" }, // PRAGMA journal_mode=WAL | PRAGMA journal_mode
3196
+ "synchronous": { kind: "set-or-read" }, // PRAGMA synchronous=NORMAL
3197
+ "wal_checkpoint": { kind: "checkpoint" }, // PRAGMA wal_checkpoint(TRUNCATE)
3198
+ });
3199
+ // Allowlisted argument tokens per set-or-read / checkpoint PRAGMA - a
3200
+ // fixed, operator-uninfluenced vocabulary so no arbitrary token reaches
3201
+ // the PRAGMA argument position.
3202
+ var PRAGMA_JOURNAL_MODES = Object.freeze({
3203
+ DELETE: true, TRUNCATE: true, PERSIST: true, MEMORY: true, WAL: true, OFF: true,
3204
+ });
3205
+ var PRAGMA_SYNC_LEVELS = Object.freeze({ OFF: true, NORMAL: true, FULL: true, EXTRA: true });
3206
+ var PRAGMA_CHECKPOINT_MODES = Object.freeze({ PASSIVE: true, FULL: true, RESTART: true, TRUNCATE: true });
3207
+
3208
+ // Quote an sqlite identifier WITH allowReserved (an internal table walk
3209
+ // can encounter any name). The sqlite_-prefix rule stays in force for the
3210
+ // general quoteIdentifier path; catalog identifiers that are themselves a
3211
+ // real user table go through the normal allowReserved quote (a user table
3212
+ // is never sqlite_-prefixed). The ONLY sqlite_-prefixed token this sub-API
3213
+ // emits is the fixed `sqlite_master` / `sqlite_schema` literal below -
3214
+ // never an operator-supplied name.
3215
+ function _catalogQuoteTable(name) {
3216
+ if (typeof name !== "string" || name.length === 0) {
3217
+ throw _err("catalog: table name must be a non-empty string", "sql-builder/bad-table");
3218
+ }
3219
+ // A catalog walk reads a name OUT of sqlite_master, so the live table
3220
+ // name is already a validated existing identifier; still route it through
3221
+ // the framework quote primitive (shape / length / NUL rules) with
3222
+ // allowReserved on. An sqlite_-prefixed user table cannot exist (sqlite
3223
+ // reserves the prefix), so the general internal-prefix refusal correctly
3224
+ // rejects it if one is ever passed - the catalog sub-API never relaxes
3225
+ // that for an operator-supplied name.
3226
+ return safeSql.quoteIdentifier(name, "sqlite", { allowReserved: true });
3227
+ }
3228
+
3229
+ // Output gate for the catalog sub-API. Keeps every boundary-escape
3230
+ // refusal _assertEmittable has (NUL, lone surrogate, stacked top-level ';',
3231
+ // unterminated quote, param/placeholder parity) but does NOT run the
3232
+ // single-verb / identifier-shape assumptions the general gate makes about
3233
+ // the builder verbs - a PRAGMA / catalog statement is its own shape.
3234
+ function _assertCatalogEmittable(sql, params) {
3235
+ if (typeof sql !== "string" || sql.length === 0) {
3236
+ throw _err("catalog: emitted SQL must be a non-empty string (builder bug)",
3237
+ "sql-builder/empty-sql");
3238
+ }
3239
+ if (!Array.isArray(params)) {
3240
+ throw _err("catalog: params must be an array (builder bug)", "sql-builder/bad-params-shape");
3241
+ }
3242
+ if (sql.indexOf("\u0000") !== -1) {
3243
+ throw _err("catalog: emitted SQL contains a NUL byte - rejected",
3244
+ "sql-builder/null-byte-sql");
3245
+ }
3246
+ if (typeof sql.isWellFormed === "function" && !sql.isWellFormed()) {
3247
+ throw _err("catalog: emitted SQL contains invalid Unicode (lone surrogates) - rejected",
3248
+ "sql-builder/invalid-encoding-sql");
3249
+ }
3250
+ var holders = _countPlaceholders(sql);
3251
+ if (holders !== params.length) {
3252
+ throw _err("catalog: placeholder/param count mismatch - " + holders + " '?' but " +
3253
+ params.length + " param(s)", "sql-builder/param-mismatch");
3254
+ }
3255
+ // Quote/comment-aware single-statement + balanced-paren scan, identical
3256
+ // to _assertEmittable's tail. A stacked top-level ';' / unterminated
3257
+ // quote is refused here too.
3258
+ var i = 0;
3259
+ var len = sql.length;
3260
+ var depth = 0;
3261
+ while (i < len) {
3262
+ var ch = sql.charAt(i);
3263
+ var next = i + 1 < len ? sql.charAt(i + 1) : "";
3264
+ if (ch === "'" || ch === '"' || ch === "`") {
3265
+ var q = ch;
3266
+ var closed = false;
3267
+ i += 1;
3268
+ while (i < len) {
3269
+ if (sql.charAt(i) === q) {
3270
+ if (sql.charAt(i + 1) === q) { i += 2; continue; }
3271
+ i += 1; closed = true; break;
3272
+ }
3273
+ i += 1;
3274
+ }
3275
+ if (!closed) {
3276
+ throw _err("catalog: unterminated quote in emitted SQL (quote-jump / breakout risk)",
3277
+ "sql-builder/unterminated-quote");
3278
+ }
3279
+ continue;
3280
+ }
3281
+ if (ch === "-" && next === "-") { while (i < len && sql.charAt(i) !== "\n") i += 1; continue; }
3282
+ if (ch === "/" && next === "*") {
3283
+ i += 2;
3284
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) i += 1;
3285
+ i += 2;
3286
+ continue;
3287
+ }
3288
+ if (ch === "(") { depth += 1; }
3289
+ else if (ch === ")") { depth -= 1; }
3290
+ else if (ch === ";") {
3291
+ throw _err("catalog: emitted a top-level ';' - exactly one statement",
3292
+ "sql-builder/stacked-statement");
3293
+ }
3294
+ i += 1;
3295
+ }
3296
+ if (depth !== 0) {
3297
+ throw _err("catalog: unbalanced parentheses in emitted SQL (builder bug)", "sql-builder/unbalanced");
3298
+ }
3299
+ return { sql: sql, params: params };
3300
+ }
3301
+
3302
+ // The audited catalog/PRAGMA sub-API. Every method returns { sql, params }.
3303
+ var catalog = Object.freeze({
3304
+ /**
3305
+ * @primitive b.sql.catalog.listTables
3306
+ * @signature b.sql.catalog.listTables()
3307
+ * @since 0.15.0
3308
+ * @status stable
3309
+ * @related b.sql.catalog.tableInfo, b.sql.catalog.tableExists
3310
+ *
3311
+ * Build the sqlite catalog query that lists every user table -
3312
+ * `SELECT name FROM sqlite_master WHERE type='table' AND
3313
+ * name NOT LIKE 'sqlite_%'`. This is the ONLY general path that emits an
3314
+ * `sqlite_master` reference; the framework's `b.safeSql.quoteIdentifier`
3315
+ * refuses an `sqlite_`-prefixed identifier for every other caller, so a
3316
+ * `sqlite_master` scan cannot be hand-built through the normal builder.
3317
+ * The `sqlite_%` LIKE pattern is a builder-emitted static literal (not
3318
+ * operator input). sqlite-internal; no dialect option.
3319
+ *
3320
+ * @example
3321
+ * var b = require("@blamejs/core");
3322
+ * var q = b.sql.catalog.listTables();
3323
+ * // -> { sql: "SELECT name FROM sqlite_master WHERE type = 'table' " +
3324
+ * // "AND name NOT LIKE 'sqlite_%'", params: [] }
3325
+ */
3326
+ listTables: function () {
3327
+ var sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'";
3328
+ return _assertCatalogEmittable(sql, []);
3329
+ },
3330
+
3331
+ /**
3332
+ * @primitive b.sql.catalog.tableExists
3333
+ * @signature b.sql.catalog.tableExists(name)
3334
+ * @since 0.15.0
3335
+ * @status stable
3336
+ * @related b.sql.catalog.listTables, b.sql.catalog.tableInfo
3337
+ *
3338
+ * Build the sqlite catalog existence probe for one table -
3339
+ * `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, the
3340
+ * table name BOUND as a `?` parameter (never interpolated). Returns one
3341
+ * row when the table exists, none otherwise.
3342
+ *
3343
+ * @example
3344
+ * var b = require("@blamejs/core");
3345
+ * b.sql.catalog.tableExists("audit_log");
3346
+ * // -> { sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
3347
+ * // params: ["audit_log"] }
3348
+ */
3349
+ tableExists: function (name) {
3350
+ if (typeof name !== "string" || name.length === 0) {
3351
+ throw _err("catalog.tableExists: name must be a non-empty string", "sql-builder/bad-table");
3352
+ }
3353
+ var sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?";
3354
+ return _assertCatalogEmittable(sql, [name]);
3355
+ },
3356
+
3357
+ /**
3358
+ * @primitive b.sql.catalog.tableInfo
3359
+ * @signature b.sql.catalog.tableInfo(name)
3360
+ * @since 0.15.0
3361
+ * @status stable
3362
+ * @related b.sql.catalog.listTables, b.sql.pragma
3363
+ *
3364
+ * Build a `PRAGMA table_info("<table>")` statement, the table name
3365
+ * quoted by construction through `b.safeSql`. PRAGMA does not bind a
3366
+ * parameter in its argument position, so the name is quoted (shape /
3367
+ * length / NUL-validated), never string-interpolated raw. sqlite-only.
3368
+ *
3369
+ * @example
3370
+ * var b = require("@blamejs/core");
3371
+ * b.sql.catalog.tableInfo("audit_log").sql;
3372
+ * // -> 'PRAGMA table_info("audit_log")'
3373
+ */
3374
+ tableInfo: function (name) {
3375
+ var sql = "PRAGMA table_info(" + _catalogQuoteTable(name) + ")";
3376
+ return _assertCatalogEmittable(sql, []);
3377
+ },
3378
+
3379
+ /**
3380
+ * @primitive b.sql.catalog.sampleRandom
3381
+ * @signature b.sql.catalog.sampleRandom(table, columns?, opts?)
3382
+ * @since 0.15.0
3383
+ * @status stable
3384
+ * @related b.sql.select, b.sql.catalog.tableInfo
3385
+ *
3386
+ * Build a `SELECT <cols> FROM "<table>" ORDER BY RANDOM() LIMIT ?`
3387
+ * row-sampler, identifiers quoted by construction and the limit BOUND as
3388
+ * a `?` parameter. `RANDOM()` ordering is the audited sqlite sampler form
3389
+ * the general `b.sql.select` builder has no clause for (it is used to
3390
+ * pick representative rows for verification, not cryptographic
3391
+ * randomness). `columns` defaults to `*`. sqlite-only.
3392
+ *
3393
+ * @opts
3394
+ * limit: number, // bound LIMIT (required > 0)
3395
+ *
3396
+ * @example
3397
+ * var b = require("@blamejs/core");
3398
+ * b.sql.catalog.sampleRandom("sessions", ["_id", "email"], { limit: 50 });
3399
+ * // -> { sql: 'SELECT "_id", "email" FROM "sessions" ORDER BY RANDOM() LIMIT ?',
3400
+ * // params: [50] }
3401
+ */
3402
+ /**
3403
+ * @primitive b.sql.catalog.changes
3404
+ * @signature b.sql.catalog.changes()
3405
+ * @since 0.15.0
3406
+ * @status stable
3407
+ * @related b.sql.catalog.listTables, b.sql.delete
3408
+ *
3409
+ * Build `SELECT changes() AS c` - the sqlite scalar that reports the row
3410
+ * count of the most recent INSERT / UPDATE / DELETE on the current
3411
+ * connection. `changes()` is a sqlite-internal function with no table to
3412
+ * select from, so the general builder (which requires a FROM table) has
3413
+ * no form for it; this audited builder emits the exact zero-parameter
3414
+ * probe the inbox sweep uses to learn how many rows a preceding DELETE
3415
+ * removed. sqlite-only; the column alias is `c`.
3416
+ *
3417
+ * @example
3418
+ * var b = require("@blamejs/core");
3419
+ * b.sql.catalog.changes().sql; // -> "SELECT changes() AS c"
3420
+ */
3421
+ changes: function () {
3422
+ return _assertCatalogEmittable("SELECT changes() AS c", []);
3423
+ },
3424
+
3425
+ sampleRandom: function (table, columns, opts) {
3426
+ opts = opts || {};
3427
+ var qt = _catalogQuoteTable(table);
3428
+ var proj = "*";
3429
+ if (columns !== undefined && columns !== null) {
3430
+ if (!Array.isArray(columns) || columns.length === 0) {
3431
+ throw _err("catalog.sampleRandom: columns must be a non-empty array (or omit for *)",
3432
+ "sql-builder/bad-columns");
3433
+ }
3434
+ proj = columns.map(function (c) {
3435
+ _validateColumn(c);
3436
+ return _quoteId(c, "sqlite");
3437
+ }).join(", ");
3438
+ }
3439
+ var limit = opts.limit;
3440
+ if (!Number.isInteger(limit) || limit <= 0) {
3441
+ throw _err("catalog.sampleRandom: opts.limit must be a positive integer", "sql-builder/bad-limit");
3442
+ }
3443
+ var sql = "SELECT " + proj + " FROM " + qt + " ORDER BY RANDOM() LIMIT ?";
3444
+ return _assertCatalogEmittable(sql, [limit]);
3445
+ },
3446
+ });
3447
+
3448
+ /**
3449
+ * @primitive b.sql.pragma
3450
+ * @signature b.sql.pragma(verb, arg?)
3451
+ * @since 0.15.0
3452
+ * @status stable
3453
+ * @related b.sql.catalog.tableInfo, b.sql.catalog.listTables
3454
+ *
3455
+ * Build a sqlite `PRAGMA` statement from a NARROW allowlist of verbs:
3456
+ * `journal_mode` (set `PRAGMA journal_mode=WAL` or read `PRAGMA
3457
+ * journal_mode`), `synchronous` (`PRAGMA synchronous=NORMAL`), and
3458
+ * `wal_checkpoint` (`PRAGMA wal_checkpoint(TRUNCATE)`). The argument is
3459
+ * matched against a fixed per-verb vocabulary - a journal mode / sync
3460
+ * level / checkpoint mode - so no operator-influenced token reaches the
3461
+ * PRAGMA argument position. A verb not on the allowlist throws; this is
3462
+ * the audit boundary the at-rest key-rotation pipeline routes its PRAGMA
3463
+ * statements through. Pass no `arg` to a set-or-read verb to read the
3464
+ * current value. sqlite-only.
3465
+ *
3466
+ * @opts
3467
+ * (none - the second positional is the allowlisted argument token)
3468
+ *
3469
+ * @example
3470
+ * var b = require("@blamejs/core");
3471
+ * b.sql.pragma("journal_mode", "WAL").sql; // -> 'PRAGMA journal_mode=WAL'
3472
+ * b.sql.pragma("synchronous", "NORMAL").sql; // -> 'PRAGMA synchronous=NORMAL'
3473
+ * b.sql.pragma("wal_checkpoint", "TRUNCATE").sql; // -> 'PRAGMA wal_checkpoint(TRUNCATE)'
3474
+ * b.sql.pragma("journal_mode").sql; // -> 'PRAGMA journal_mode' (read)
3475
+ */
3476
+ function pragma(verb, arg) {
3477
+ if (typeof verb !== "string" || CATALOG_PRAGMA_VERBS[verb] === undefined) {
3478
+ throw _err("pragma: verb '" + verb + "' is not on the allowlist (journal_mode / " +
3479
+ "synchronous / wal_checkpoint); a PRAGMA outside this set is refused by design",
3480
+ "sql-builder/bad-pragma");
3481
+ }
3482
+ var def = CATALOG_PRAGMA_VERBS[verb];
3483
+ if (def.kind === "introspect") {
3484
+ // table_info is reached via catalog.tableInfo (needs a quoted name).
3485
+ throw _err("pragma: use b.sql.catalog.tableInfo(name) for PRAGMA table_info",
3486
+ "sql-builder/bad-pragma");
3487
+ }
3488
+ if (def.kind === "checkpoint") {
3489
+ var ckMode = (arg === undefined || arg === null) ? "PASSIVE" : String(arg).toUpperCase();
3490
+ if (PRAGMA_CHECKPOINT_MODES[ckMode] !== true) {
3491
+ throw _err("pragma wal_checkpoint mode must be PASSIVE / FULL / RESTART / TRUNCATE (got " +
3492
+ JSON.stringify(arg) + ")", "sql-builder/bad-pragma-arg");
3493
+ }
3494
+ return _assertCatalogEmittable("PRAGMA wal_checkpoint(" + ckMode + ")", []);
3495
+ }
3496
+ // set-or-read: journal_mode / synchronous.
3497
+ if (arg === undefined || arg === null) {
3498
+ return _assertCatalogEmittable("PRAGMA " + verb, []);
3499
+ }
3500
+ var token = String(arg).toUpperCase();
3501
+ var vocab = verb === "journal_mode" ? PRAGMA_JOURNAL_MODES : PRAGMA_SYNC_LEVELS;
3502
+ if (vocab[token] !== true) {
3503
+ throw _err("pragma " + verb + " argument '" + arg + "' is not in the allowed vocabulary",
3504
+ "sql-builder/bad-pragma-arg");
3505
+ }
3506
+ return _assertCatalogEmittable("PRAGMA " + verb + "=" + token, []);
3507
+ }
3508
+
3509
+ // ---- Schema optimization (defineTable) ------------------------------
3510
+ //
3511
+ // PK / FK / index automation over createTable + createIndex. Each layer is
3512
+ // on by default and individually disablable.
3513
+
3514
+ // Naive pluralizer for FK table inference (entity -> table). Covers the
3515
+ // common English cases; an unusual plural is overridden with an explicit
3516
+ // `references`. consonant+y -> ies, sibilant -> es, else -> s.
3517
+ function _pluralize(s) {
3518
+ if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
3519
+ if (/(?:s|x|z|ch|sh)$/i.test(s)) return s + "es";
3520
+ return s + "s";
3521
+ }
3522
+
3523
+ // Infer a foreign-key reference from a column name by convention: a column
3524
+ // named `<entity>Id` / `<entity>_id` references `<pluralize(entity)>(<pkCol>)`.
3525
+ // Returns null when the name does not match (or the entity part is empty,
3526
+ // e.g. a bare `id` / `_id`).
3527
+ function _inferFkRef(colName, pkCol) {
3528
+ var m = /^(.+?)(?:Id|_id)$/.exec(colName);
3529
+ if (!m || m[1].length === 0) return null;
3530
+ return { table: _pluralize(m[1]), column: pkCol };
3531
+ }
3532
+
3533
+ // Deterministic index name `idx_<table>_<cols>`, sanitized to an identifier
3534
+ // and capped at the dialect identifier limit (Postgres NAMEDATALEN 63) the
3535
+ // same way the query builder bounds every identifier. An over-long name is
3536
+ // truncated with a short stable checksum suffix so two long names can't
3537
+ // collide after truncation.
3538
+ function _indexName(table, cols) {
3539
+ var base = ("idx_" + table + "_" + cols.join("_")).replace(/[^A-Za-z0-9_]/g, "_");
3540
+ if (base.length > safeSql.MAX_IDENTIFIER_LENGTH) {
3541
+ var h = 0;
3542
+ for (var i = 0; i < base.length; i += 1) h = (h * 31 + base.charCodeAt(i)) >>> 0;
3543
+ base = base.slice(0, safeSql.MAX_IDENTIFIER_LENGTH - 9) + "_" + h.toString(36);
3544
+ }
3545
+ return base;
3546
+ }
3547
+
3548
+ /**
3549
+ * @primitive b.sql.defineTable
3550
+ * @signature b.sql.defineTable(name, spec, opts?)
3551
+ * @since 0.14.29
3552
+ * @status stable
3553
+ * @related b.sql.createTable, b.sql.createIndex, b.sql.select
3554
+ *
3555
+ * Declarative schema with built-in PK / FK / index optimization. Returns an
3556
+ * ordered `{ statements: [{ sql, params }, ...] }` bundle (the `CREATE TABLE`
3557
+ * first, then each `CREATE INDEX`) to run in sequence. Three automation
3558
+ * layers, each on by default and individually disablable:
3559
+ *
3560
+ * - **Primary key** - if no column declares `primaryKey` / `autoIncrement`
3561
+ * and `opts.primaryKey` is unset, an identity PK column (`opts.primaryKeyColumn`,
3562
+ * default `id`) is auto-added in the dialect-correct form (BIGSERIAL /
3563
+ * INTEGER AUTOINCREMENT / BIGINT AUTO_INCREMENT). Disable: `autoPrimaryKey: false`.
3564
+ * - **Foreign keys** - a column named `<entity>Id` / `<entity>_id` infers a
3565
+ * `REFERENCES <pluralize(entity)>(<pk>)` constraint. Override one column with
3566
+ * an explicit `references` (`"table"` or `{ table, column?, onDelete?,
3567
+ * onUpdate? }`) or opt it out with `references: false`. Disable all
3568
+ * inference: `autoForeignKeys: false`.
3569
+ * - **Indexes** - every FK column is auto-indexed (databases do not index
3570
+ * FK columns for you), as is any column flagged `index: true`
3571
+ * (`unique: true` is enforced inline). Add composite / custom indexes via
3572
+ * `opts.indexes`. Disable auto-indexing: `autoIndex: false`.
3573
+ *
3574
+ * Every index / FK column is gated against the table's declared column set -
3575
+ * the same column-namespace discipline the query builder applies with
3576
+ * `allowedColumns` - and every generated index name is bounded to the dialect
3577
+ * identifier limit.
3578
+ *
3579
+ * @opts
3580
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
3581
+ * prefix: string, // operator app-table namespace prefix
3582
+ * schema: string, // schema qualifier
3583
+ * autoPrimaryKey: boolean, // default true
3584
+ * primaryKeyColumn: string, // default "id"
3585
+ * autoForeignKeys: boolean, // default true (naming-convention inference)
3586
+ * autoIndex: boolean, // default true
3587
+ * indexes: array, // [{ columns: [...], unique?, name? }]
3588
+ *
3589
+ * @example
3590
+ * var b = require("@blamejs/core");
3591
+ * var ddl = b.sql.defineTable("orders", [
3592
+ * { name: "userId", type: "int" }, // -> FK users(id) + index
3593
+ * { name: "total", type: "numeric" },
3594
+ * { name: "email", type: "text", index: true },
3595
+ * ], { dialect: "postgres" });
3596
+ * ddl.statements.length;
3597
+ * // -> 3 (CREATE TABLE orders; CREATE INDEX on userId; CREATE INDEX on email)
3598
+ */
3599
+ function defineTable(name, spec, opts) {
3600
+ opts = opts || {};
3601
+ var dialect = _normDialect(opts.dialect);
3602
+ if (!Array.isArray(spec) || spec.length === 0) {
3603
+ throw _err("defineTable requires a non-empty columns spec array", "sql-builder/bad-columns");
3604
+ }
3605
+ var autoPk = opts.autoPrimaryKey !== false;
3606
+ var autoFk = opts.autoForeignKeys !== false;
3607
+ var autoIdx = opts.autoIndex !== false;
3608
+ var pkCol = opts.primaryKeyColumn || "id";
3609
+
3610
+ // Shallow-copy each spec so FK inference never mutates the caller's object.
3611
+ var cols = spec.map(function (c) {
3612
+ if (!c || typeof c !== "object" || typeof c.name !== "string") {
3613
+ throw _err("defineTable column must be { name, type, ... }", "sql-builder/bad-column");
3614
+ }
3615
+ return Object.assign({}, c);
3616
+ });
3617
+
3618
+ // PK automation.
3619
+ var declaredPk = cols.some(function (c) { return c.primaryKey || c.autoIncrement; }) ||
3620
+ (Array.isArray(opts.primaryKey) && opts.primaryKey.length > 0);
3621
+ if (autoPk && !declaredPk) cols.unshift({ name: pkCol, autoIncrement: true });
3622
+
3623
+ // Column namespace - index / FK columns must be members, the same gate the
3624
+ // query builder enforces with allowedColumns / _assertColumnMember.
3625
+ var declared = {};
3626
+ cols.forEach(function (c) { declared[c.name] = true; });
3627
+ function _assertMember(col, where) {
3628
+ if (declared[col] !== true) {
3629
+ throw _err("defineTable: " + where + " references column '" + col +
3630
+ "' which is not a declared column of '" + name + "'", "sql-builder/unknown-column");
3631
+ }
3632
+ }
3633
+
3634
+ // FK automation (convention-by-default + per-column override).
3635
+ var fkColumns = [];
3636
+ cols.forEach(function (c) {
3637
+ if (c.references === false) return; // opt-out
3638
+ if (c.references !== undefined) { fkColumns.push(c.name); return; } // explicit
3639
+ if (c.primaryKey || c.autoIncrement) return; // PK is not an FK
3640
+ if (autoFk) {
3641
+ var inferred = _inferFkRef(c.name, pkCol);
3642
+ if (inferred) { c.references = inferred; fkColumns.push(c.name); }
3643
+ }
3644
+ });
3645
+
3646
+ var statements = [createTable(name, cols, opts)];
3647
+
3648
+ // Index automation. Generated index names are bounded by _indexName.
3649
+ var indexed = {};
3650
+ function _pushIndex(indexCols, unique, explicitName) {
3651
+ indexCols.forEach(function (col) { _assertMember(col, "index"); });
3652
+ statements.push(createIndex(explicitName || _indexName(name, indexCols), name, indexCols,
3653
+ { dialect: dialect, unique: unique === true, prefix: opts.prefix, schema: opts.schema }));
3654
+ }
3655
+ if (autoIdx) {
3656
+ fkColumns.forEach(function (cn) {
3657
+ if (!indexed[cn]) { indexed[cn] = true; _pushIndex([cn], false, null); }
3658
+ });
3659
+ cols.forEach(function (c) {
3660
+ if (c.index === true && !c.unique && !c.primaryKey && !c.autoIncrement && !indexed[c.name]) {
3661
+ indexed[c.name] = true; _pushIndex([c.name], false, null);
3662
+ }
3663
+ });
3664
+ }
3665
+ // Explicit indexes are always honored (even with autoIndex off).
3666
+ if (Array.isArray(opts.indexes)) {
3667
+ opts.indexes.forEach(function (ix) {
3668
+ if (!ix || !Array.isArray(ix.columns) || ix.columns.length === 0) {
3669
+ throw _err("defineTable opts.indexes entry needs a non-empty columns array",
3670
+ "sql-builder/bad-index");
3671
+ }
3672
+ _pushIndex(ix.columns, ix.unique, ix.name);
3673
+ });
3674
+ }
3675
+
3676
+ return { statements: statements };
3677
+ }
3678
+
3679
+ // ---- Verb entry points ----------------------------------------------
3680
+
3681
+ /**
3682
+ * @primitive b.sql.select
3683
+ * @signature b.sql.select(table, opts?)
3684
+ * @since 0.14.29
3685
+ * @status stable
3686
+ * @related b.sql.insert, b.sql.update, b.sql.delete, b.sql.upsert
3687
+ *
3688
+ * Start a `SELECT` builder over `table` (a name, a `"schema.table"`, or a
3689
+ * `b.sql.table(...)` reference). Chain `columns` / aggregates /
3690
+ * `join` family / `where` family / `groupBy` / `having` / `orderBy` /
3691
+ * `limit` / `offset`, then call `toSql()` for `{ sql, params }`. Emits
3692
+ * bare default table names + `?` placeholders so `b.clusterStorage`
3693
+ * applies the cluster prefix + Postgres `$N` translation at execute time.
3694
+ *
3695
+ * @opts
3696
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
3697
+ * schema: string, // schema qualifier for the table
3698
+ * prefix: string, // operator app-table namespace prefix
3699
+ * alias: string, // table alias (for joins)
3700
+ * allowedColumns: array, // column-membership gate set
3701
+ * columnGateMode: string, // reject | warn | off
3702
+ *
3703
+ * @example
3704
+ * var b = require("@blamejs/core");
3705
+ * b.sql.select("users")
3706
+ * .columns(["id", "email"])
3707
+ * .where("status", "active")
3708
+ * .orderBy("createdAt", "desc")
3709
+ * .limit(10)
3710
+ * .toSql();
3711
+ * // -> { sql: 'SELECT "id", "email" FROM users WHERE "status" = ? ORDER BY "createdAt" DESC LIMIT 10',
3712
+ * // params: ["active"] }
3713
+ */
3714
+ function select(tableNameOrRef, opts) { return new SelectBuilder(tableNameOrRef, opts); }
3715
+
3716
+ /**
3717
+ * @primitive b.sql.insert
3718
+ * @signature b.sql.insert(table, opts?)
3719
+ * @since 0.14.29
3720
+ * @status stable
3721
+ * @related b.sql.select, b.sql.upsert, b.sql.update
3722
+ *
3723
+ * Start an `INSERT` builder. Provide rows via `columns([...])` +
3724
+ * `values([...])` (positional), `values({ ... })` (one row object), or
3725
+ * `values([{...}, {...}])` (multi-row). Optional `returning(cols)`. The
3726
+ * value set is fully bound - every value becomes a `?` placeholder.
3727
+ *
3728
+ * @opts
3729
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
3730
+ * schema: string, // schema qualifier
3731
+ * prefix: string, // operator app-table namespace prefix
3732
+ * allowedColumns: array, // column-membership gate set
3733
+ *
3734
+ * @example
3735
+ * var b = require("@blamejs/core");
3736
+ * b.sql.insert("users")
3737
+ * .values({ id: 1, email: "a@b.c" })
3738
+ * .returning(["id"])
3739
+ * .toSql();
3740
+ * // -> { sql: 'INSERT INTO users ("id", "email") VALUES (?, ?) RETURNING "id"',
3741
+ * // params: [1, "a@b.c"] }
3742
+ */
3743
+ function insert(tableNameOrRef, opts) { return new InsertBuilder(tableNameOrRef, opts); }
3744
+
3745
+ /**
3746
+ * @primitive b.sql.update
3747
+ * @signature b.sql.update(table, opts?)
3748
+ * @since 0.14.29
3749
+ * @status stable
3750
+ * @related b.sql.select, b.sql.insert, b.sql.delete
3751
+ *
3752
+ * Start an `UPDATE` builder. Set assignments via `set({ ... })` /
3753
+ * `set(col, val)` / `setRaw(col, expr, params)`; filter via the `where`
3754
+ * family. An update with no `where()` THROWS unless `allowNoWhere()` is
3755
+ * called - a deliberate full-table write must opt in. Optional
3756
+ * `returning(cols)`.
3757
+ *
3758
+ * @opts
3759
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
3760
+ * schema: string, // schema qualifier
3761
+ * prefix: string, // operator app-table namespace prefix
3762
+ * allowedColumns: array, // column-membership gate set
3763
+ *
3764
+ * @example
3765
+ * var b = require("@blamejs/core");
3766
+ * b.sql.update("users")
3767
+ * .set({ status: "inactive" })
3768
+ * .where("id", 1)
3769
+ * .toSql();
3770
+ * // -> { sql: 'UPDATE users SET "status" = ? WHERE "id" = ?', params: ["inactive", 1] }
3771
+ */
3772
+ function update(tableNameOrRef, opts) { return new UpdateBuilder(tableNameOrRef, opts); }
3773
+
3774
+ /**
3775
+ * @primitive b.sql.delete
3776
+ * @signature b.sql.delete(table, opts?)
3777
+ * @since 0.14.29
3778
+ * @status stable
3779
+ * @related b.sql.select, b.sql.update, b.sql.insert
3780
+ *
3781
+ * Start a `DELETE` builder. Filter via the `where` family. A delete with
3782
+ * no `where()` THROWS unless `allowNoWhere()` is called. Optional
3783
+ * `returning(cols)`.
3784
+ *
3785
+ * @opts
3786
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
3787
+ * schema: string, // schema qualifier
3788
+ * prefix: string, // operator app-table namespace prefix
3789
+ * allowedColumns: array, // column-membership gate set
3790
+ *
3791
+ * @example
3792
+ * var b = require("@blamejs/core");
3793
+ * b.sql.delete("sessions")
3794
+ * .where("expiresAt", "<", 1700000000)
3795
+ * .toSql();
3796
+ * // -> { sql: 'DELETE FROM sessions WHERE "expiresAt" < ?', params: [1700000000] }
3797
+ */
3798
+ function del(tableNameOrRef, opts) { return new DeleteBuilder(tableNameOrRef, opts); }
3799
+
3800
+ /**
3801
+ * @primitive b.sql.upsert
3802
+ * @signature b.sql.upsert(table, opts?)
3803
+ * @since 0.14.29
3804
+ * @status stable
3805
+ * @related b.sql.insert, b.sql.update, b.sql.select
3806
+ *
3807
+ * Start an `UPSERT` builder - the one verb that emits dialect-final
3808
+ * conflict syntax. Supply the row via `columns` + `values({...})`, the
3809
+ * conflict key via `onConflict(keys)`, and one conflict action:
3810
+ * `doUpdate(cols | { col: expr })`, `doUpdateFromExcluded(cols)`, or
3811
+ * `doNothing()`. Optional `conflictWhere(rawGuard, params, opts?)` fences
3812
+ * the update - pass `{ guardColumn: "<col>" }` to name the column the
3813
+ * fence protects so the MySQL fold emits it last (see below); optional
3814
+ * `returning(cols)`.
3815
+ *
3816
+ * On Postgres / SQLite `toSql()` returns
3817
+ * `{ sql, params }` emitting `ON CONFLICT (keys) DO UPDATE SET
3818
+ * col = EXCLUDED.col [WHERE ...] [RETURNING ...]`. On MySQL it returns
3819
+ * `{ sql, params, readbackSql }` emitting `ON DUPLICATE KEY UPDATE
3820
+ * col = VALUES(col)` (or `IF(guard, VALUES(col), col)` when
3821
+ * `conflictWhere` is set); MySQL evaluates the SET list left to right, so
3822
+ * when the fenced guard column is itself a SET target it must be assigned
3823
+ * last (each IF must see the guard column's pre-update value) - name it
3824
+ * via `conflictWhere(..., { guardColumn })` and the fold reorders it to
3825
+ * the end. MySQL has no per-statement WHERE / RETURNING on the conflict
3826
+ * action, so a readback `SELECT` keyed on the conflict columns is
3827
+ * returned for the caller to fetch the upserted row.
3828
+ *
3829
+ * @opts
3830
+ * dialect: string, // postgres | sqlite | mysql (default sqlite)
3831
+ * schema: string, // schema qualifier
3832
+ * prefix: string, // operator app-table namespace prefix
3833
+ * allowedColumns: array, // column-membership gate set
3834
+ *
3835
+ * @example
3836
+ * var b = require("@blamejs/core");
3837
+ * b.sql.upsert("audit_tip", { dialect: "postgres" })
3838
+ * .values({ id: 1, counter: 42 })
3839
+ * .onConflict(["id"])
3840
+ * .doUpdateFromExcluded(["counter"])
3841
+ * .toSql();
3842
+ * // -> { sql: 'INSERT INTO audit_tip ("id", "counter") VALUES (?, ?) ' +
3843
+ * // 'ON CONFLICT ("id") DO UPDATE SET "counter" = EXCLUDED."counter"',
3844
+ * // params: [1, 42] }
3845
+ */
3846
+ function upsert(tableNameOrRef, opts) { return new UpsertBuilder(tableNameOrRef, opts); }
3847
+
3848
+ module.exports = {
3849
+ // Verbs
3850
+ select: select,
3851
+ insert: insert,
3852
+ update: update,
3853
+ delete: del,
3854
+ upsert: upsert,
3855
+ // Table reference
3856
+ table: table,
3857
+ // Value-position helpers (INSERT values() / UPDATE set() right-hand side)
3858
+ fn: fn,
3859
+ cast: cast,
3860
+ // Driver-final positional translation (for direct-driver callers: a DDL
3861
+ // { sql, params } result or a chainable builder -> $1..$N on postgres).
3862
+ toExternalSql: toExternalSql,
3863
+ // DDL
3864
+ createTable: createTable,
3865
+ createIndex: createIndex,
3866
+ alterTable: alterTable,
3867
+ dropTable: dropTable,
3868
+ createVirtualTable: createVirtualTable,
3869
+ defineTable: defineTable,
3870
+ // Row-Level Security (Postgres)
3871
+ enableRowLevelSecurity: enableRowLevelSecurity,
3872
+ disableRowLevelSecurity: disableRowLevelSecurity,
3873
+ createPolicy: createPolicy,
3874
+ dropPolicy: dropPolicy,
3875
+ // Catalog / PRAGMA (narrow audited sqlite-internal sub-API)
3876
+ catalog: catalog,
3877
+ pragma: pragma,
3878
+ // Error class
3879
+ SqlBuilderError: SqlBuilderError,
3880
+ // Exposed for the integrator: the operator-facing builder bases +
3881
+ // operator allowlist, so wiki harvesters + adjacent lib code can
3882
+ // instanceof-check a builder and the must-compose detector can scope.
3883
+ Builder: Builder,
3884
+ ALLOWED_OPS: ALLOWED_OPS,
3885
+ };