@blamejs/core 0.7.4 → 0.7.18

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 (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
@@ -1,1061 +1,1061 @@
1
- "use strict";
2
- /**
3
- * External database service — pluggable wrapper for app-data DB connections.
4
- *
5
- * Framework state (audit_log, consent_log, _blamejs_*) stays in the local
6
- * SQLite via b.db. This module is for APP DATA — when an operator wants to
7
- * keep their app's domain tables in Postgres / MySQL / MongoDB / libsql /
8
- * etc., they configure a backend here and use b.externalDb.query() instead
9
- * of b.db.from() for those tables.
10
- *
11
- * Bring-your-own-client design (per "zero npm runtime deps" rule):
12
- * The operator supplies the actual DB driver via the backend's connect/
13
- * query/close functions. The framework adds:
14
- * - Connection pooling (lazy-create, reuse across queries)
15
- * - Retry on transient errors (5xx-equivalent + network)
16
- * - Circuit breaker per-backend
17
- * - Classification routing (which backend serves which data class)
18
- * - Residency enforcement (boot-time validation against
19
- * db.getDataResidency().region)
20
- * - Audit hooks (system.externaldb.{query,transaction,connect.failure})
21
- *
22
- * Built-in protocol adapters (native pg-wire, libsql-HTTP, MongoDB wire)
23
- * are not currently bundled — operators supply `connect`/`query`/`close`
24
- * directly using their wire client of choice. When framework-bundled
25
- * adapters land they will be available as `b.externalDb.adapters.pg`,
26
- * `.libsqlHttp`, etc., but the bring-your-own-client API is the
27
- * permanent surface.
28
- *
29
- * Public API:
30
- * externalDb.init({ backends: { name: { connect, query, close?, ... } },
31
- * defaultBackend? })
32
- * externalDb.query(sql, params?, opts?) → { rows, rowCount }
33
- * externalDb.transaction(fn, opts?) → fn's return value
34
- * externalDb.healthCheck(backendName?) → backend status
35
- * externalDb.listBackends()
36
- * externalDb.shutdown()
37
- *
38
- * Backend config:
39
- * {
40
- * connect(): async () → client (returns operator's DB client)
41
- * query(client, sql, params): async → { rows, rowCount }
42
- * close(client): async → void
43
- * ping(client): async → bool (optional health check)
44
- * beginTx(client): async → void (optional; default 'BEGIN')
45
- * commit(client): async → void (optional; default 'COMMIT')
46
- * rollback(client): async → void (optional; default 'ROLLBACK')
47
- * pool: { min: 1, max: 10, idleTimeoutMs: C.TIME.minutes(1) }
48
- * classifications: ['personal' | 'operational' | 'public' | <custom>]
49
- * residencyTag: 'EU' | 'US' | ...
50
- * retry, breaker
51
- * }
52
- */
53
- var retryHelper = require("./retry");
54
- var C = require("./constants");
55
- var dbRoleContext = require("./db-role-context");
56
- var externalDbMigrate = require("./external-db-migrate");
57
- var lazyRequire = require("./lazy-require");
58
- var { boot } = require("./log");
59
- var safeAsync = require("./safe-async");
60
- var safeSql = require("./safe-sql");
61
- var { ExternalDbError } = require("./framework-error");
62
-
63
- var log = boot("external-db");
64
-
65
- var audit = lazyRequire(function () { return require("./audit"); });
66
- var db = lazyRequire(function () { return require("./db"); });
67
- var observability = lazyRequire(function () { return require("./observability"); });
68
-
69
- function _emitMetric(name, value, labels) {
70
- try { observability().event(name, value, labels || {}); }
71
- catch (_e) { /* hot-path observability sink — drop silent by design */ }
72
- }
73
-
74
- var _err = ExternalDbError.factory;
75
-
76
- var initialized = false;
77
- var backends = {};
78
- var defaultBackend = null;
79
- // Operator-declared { role: backendName } map for request-time pool pick.
80
- // Populated at init() from opts.dbRoleBackends. Read by _pickBackend
81
- // when no explicit opts.backend is supplied AND the ALS scope has a role.
82
- var dbRoleBackends = {};
83
-
84
- // ---- Pool ----
85
- //
86
- // Per-backend pool with lazy creation + LRU-ish reuse. Connections returned
87
- // to the pool when query/transaction completes; idle connections expire.
88
-
89
- class Pool {
90
- constructor(name, config) {
91
- this.name = name;
92
- this.config = Object.assign({ min: 1, max: 10, idleTimeoutMs: C.TIME.minutes(1) }, config.pool || {});
93
- this.connect = config.connect;
94
- this.close = config.close || function () { return Promise.resolve(); };
95
- this.idle = []; // [{ client, lastUsedAt }]
96
- this.active = 0; // count of in-use clients
97
- this.waiters = []; // queued acquisitions when at max
98
- this._reaper = safeAsync.repeating(this._reapIdle.bind(this),
99
- C.TIME.seconds(10), { name: "external-db-reaper" });
100
- }
101
-
102
- async acquire() {
103
- if (this.idle.length > 0) {
104
- var entry = this.idle.pop();
105
- this.active += 1;
106
- return entry.client;
107
- }
108
- if (this.active < this.config.max) {
109
- this.active += 1;
110
- try {
111
- return await this.connect();
112
- } catch (e) {
113
- this.active -= 1;
114
- throw e;
115
- }
116
- }
117
- // At max — wait for a release. The waiter's clock starts now;
118
- // when release() resolves the waiter we emit the wait duration so
119
- // operators can see backpressure on the pool.
120
- var self = this;
121
- var waitStartedAt = Date.now();
122
- return new Promise(function (resolve, reject) {
123
- self.waiters.push({
124
- resolve: function (client) {
125
- _emitMetric("externaldb.pool.acquire_wait", Date.now() - waitStartedAt,
126
- { backend: self.name });
127
- resolve(client);
128
- },
129
- reject: reject,
130
- });
131
- });
132
- }
133
-
134
- release(client) {
135
- this.active -= 1;
136
- if (this.waiters.length > 0) {
137
- var w = this.waiters.shift();
138
- this.active += 1;
139
- w.resolve(client);
140
- return;
141
- }
142
- this.idle.push({ client: client, lastUsedAt: Date.now() });
143
- }
144
-
145
- async destroy(client) {
146
- this.active -= 1;
147
- try { await this.close(client); } catch (_e) { /* best effort */ }
148
- if (this.waiters.length > 0) {
149
- var w = this.waiters.shift();
150
- this.acquire().then(w.resolve, w.reject);
151
- }
152
- }
153
-
154
- _reapIdle() {
155
- var now = Date.now();
156
- var keep = [];
157
- var self = this;
158
- this.idle.forEach(function (entry) {
159
- if ((now - entry.lastUsedAt) >= self.config.idleTimeoutMs) {
160
- Promise.resolve().then(function () { return self.close(entry.client); }).catch(function () {});
161
- } else {
162
- keep.push(entry);
163
- }
164
- });
165
- this.idle = keep;
166
- }
167
-
168
- async drain() {
169
- if (this._reaper) { this._reaper.stop(); this._reaper = null; }
170
- var idleClients = this.idle.map(function (e) { return e.client; });
171
- this.idle = [];
172
- var self = this;
173
- await Promise.all(idleClients.map(function (c) {
174
- return Promise.resolve().then(function () { return self.close(c); }).catch(function () {});
175
- }));
176
- this.waiters.forEach(function (w) { w.reject(_err("POOL_DRAINED", "pool is shutting down", true)); });
177
- this.waiters = [];
178
- }
179
-
180
- stats() {
181
- return { active: this.active, idle: this.idle.length, waiters: this.waiters.length };
182
- }
183
- }
184
-
185
- // ---- Init ----
186
-
187
- function init(opts) {
188
- if (initialized) return;
189
- if (!opts || !opts.backends) throw new Error("externalDb.init({ backends }) is required");
190
-
191
- backends = {};
192
- dbRoleBackends = {};
193
- for (var name in opts.backends) {
194
- var cfg = opts.backends[name];
195
- if (typeof cfg.connect !== "function") {
196
- throw _err("INVALID_CONFIG", "backend '" + name + "' missing connect() function", true);
197
- }
198
- if (typeof cfg.query !== "function") {
199
- throw _err("INVALID_CONFIG", "backend '" + name + "' missing query() function", true);
200
- }
201
- // dialect — informational marker so dialect-specific consumers
202
- // (e.g. b.db.declareView) can fail-fast at apply time. Defaults to
203
- // "postgres" because that's the dominant blamejs externalDb target;
204
- // operators on SQLite/MySQL/etc. set this explicitly so downstream
205
- // primitives surface NOT_SUPPORTED with a clear message instead of
206
- // emitting Postgres-flavored DDL into the wrong dialect.
207
- var dialect = (cfg.dialect || "postgres").toLowerCase();
208
- if (["postgres", "mysql", "sqlite", "mongodb", "other"].indexOf(dialect) === -1) {
209
- throw _err("INVALID_CONFIG",
210
- "backend '" + name + "': dialect must be one of " +
211
- "'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'other', got '" + dialect + "'", true);
212
- }
213
- backends[name] = {
214
- name: name,
215
- dialect: dialect,
216
- pool: new Pool(name, cfg),
217
- query: cfg.query,
218
- ping: cfg.ping || null,
219
- beginTx: cfg.beginTx || function (client) { return cfg.query(client, "BEGIN", []); },
220
- commit: cfg.commit || function (client) { return cfg.query(client, "COMMIT", []); },
221
- rollback: cfg.rollback || function (client) { return cfg.query(client, "ROLLBACK", []); },
222
- classifications: Array.isArray(cfg.classifications) && cfg.classifications.length > 0
223
- ? cfg.classifications.slice()
224
- : ["*"],
225
- residencyTag: cfg.residencyTag || "unrestricted",
226
- breaker: new retryHelper.CircuitBreaker("externalDb:" + name, cfg.breaker),
227
- retryConfig: cfg.retry || null,
228
- replicas: _buildReplicas(name, cfg),
229
- replicaIdx: 0, // round-robin cursor
230
- replicaFallbackToPrimary: cfg.replicaFallbackToPrimary !== false,
231
- };
232
- }
233
-
234
- defaultBackend = opts.defaultBackend || Object.keys(backends)[0];
235
-
236
- // dbRoleBackends — request-time role → backend mapping. Each role name
237
- // validates as a SQL identifier at init (matches the dbRoleFor
238
- // middleware's runtime check) so a typo surfaces at boot rather than
239
- // as a silent default-backend fallback at the first request.
240
- if (opts.dbRoleBackends !== undefined && opts.dbRoleBackends !== null) {
241
- if (typeof opts.dbRoleBackends !== "object" || Array.isArray(opts.dbRoleBackends)) {
242
- throw _err("INVALID_CONFIG",
243
- "dbRoleBackends must be an object map of role → backendName", true);
244
- }
245
- for (var role in opts.dbRoleBackends) {
246
- if (!Object.prototype.hasOwnProperty.call(opts.dbRoleBackends, role)) continue;
247
- try {
248
- safeSql.validateIdentifier(role, { allowReserved: false });
249
- } catch (e) {
250
- throw _err("INVALID_CONFIG",
251
- "dbRoleBackends: role '" + role + "' is not a valid SQL identifier: " +
252
- ((e && e.message) || String(e)), true);
253
- }
254
- var bn = opts.dbRoleBackends[role];
255
- if (typeof bn !== "string" || bn.length === 0) {
256
- throw _err("INVALID_CONFIG",
257
- "dbRoleBackends['" + role + "']: backend name must be a non-empty string", true);
258
- }
259
- if (!Object.prototype.hasOwnProperty.call(backends, bn)) {
260
- throw _err("INVALID_CONFIG",
261
- "dbRoleBackends['" + role + "']: no backend named '" + bn + "' " +
262
- "(declared backends: " + Object.keys(backends).join(", ") + ")", true);
263
- }
264
- dbRoleBackends[role] = bn;
265
- }
266
- }
267
-
268
- _validateResidency();
269
- initialized = true;
270
- }
271
-
272
- function _validateResidency() {
273
- var residency;
274
- try { residency = db().getDataResidency(); } catch (_e) { residency = null; }
275
- if (!residency || !residency.region) return;
276
-
277
- var allowed = [residency.region].concat(residency.allowedStorageRegions || []);
278
- for (var name in backends) {
279
- var b = backends[name];
280
- var serves = b.classifications.indexOf("*") !== -1 || b.classifications.indexOf("personal") !== -1;
281
- if (!serves) continue;
282
- if (allowed.indexOf(b.residencyTag) === -1) {
283
- throw _err("RESIDENCY_VIOLATION",
284
- "externalDb backend '" + name + "' serves 'personal' data with residencyTag '" +
285
- b.residencyTag + "' but app's dataResidency.region is '" + residency.region + "'",
286
- true);
287
- }
288
- }
289
- }
290
-
291
- // ---- Backend selection ----
292
- //
293
- // Pick precedence:
294
- // 1. opts.backend — explicit override always wins
295
- // 2. opts.classification — first backend serving that class
296
- // 3. ALS-bound dbRole + dbRoleBackends — request-time auto-pick
297
- // 4. defaultBackend — final fallback
298
- //
299
- // The ALS path matches the dbRoleFor middleware shape: middleware sets
300
- // the role; deep async reads pick up the matching backend without having
301
- // to thread `req` through every call site.
302
-
303
- function _pickBackend(opts) {
304
- opts = opts || {};
305
- if (opts.backend) {
306
- var b = backends[opts.backend];
307
- if (!b) throw _err("UNKNOWN_BACKEND", "no backend named '" + opts.backend + "'", true);
308
- if (opts.classification && !_servesClassification(b, opts.classification)) {
309
- throw _err("CLASSIFICATION_MISMATCH",
310
- "backend '" + opts.backend + "' does not serve classification '" + opts.classification + "'", true);
311
- }
312
- return b;
313
- }
314
- var classification = opts.classification;
315
- if (classification) {
316
- for (var name in backends) {
317
- if (_servesClassification(backends[name], classification)) return backends[name];
318
- }
319
- throw _err("NO_BACKEND_FOR_CLASSIFICATION",
320
- "no backend serves classification '" + classification + "'", true);
321
- }
322
- var role = dbRoleContext.getRole();
323
- if (role && Object.prototype.hasOwnProperty.call(dbRoleBackends, role)) {
324
- return backends[dbRoleBackends[role]];
325
- }
326
- return backends[defaultBackend] || null;
327
- }
328
-
329
- function _servesClassification(b, cls) {
330
- return b.classifications.indexOf("*") !== -1 || b.classifications.indexOf(cls) !== -1;
331
- }
332
-
333
- // ---- Public API ----
334
-
335
- async function query(sql, params, opts) {
336
- _requireInit();
337
- opts = opts || {};
338
- var b = _pickBackend(opts);
339
- var role = dbRoleContext.getRole();
340
-
341
- var t0 = Date.now();
342
- try {
343
- var result = await retryHelper.withRetry(function () {
344
- return b.breaker.wrap(async function () {
345
- var client = await b.pool.acquire();
346
- try {
347
- var res = await b.query(client, sql, params || []);
348
- b.pool.release(client);
349
- return res;
350
- } catch (e) {
351
- // Connection-level errors → destroy the client; query errors →
352
- // release back to the pool. Heuristic: any error with a code
353
- // looking like a network/connection issue → destroy.
354
- if (e && (e.code === "ECONNRESET" || e.code === "ECONNREFUSED" ||
355
- e.code === "ETIMEDOUT" || e.code === "ENOTFOUND" ||
356
- e.code === "EPIPE")) {
357
- await b.pool.destroy(client);
358
- } else {
359
- b.pool.release(client);
360
- }
361
- throw e;
362
- }
363
- });
364
- }, b.retryConfig);
365
-
366
- var durationMs = Date.now() - t0;
367
- _emit("system.externaldb.query", "success", {
368
- backend: b.name,
369
- role: role,
370
- durationMs: durationMs,
371
- classification: opts.classification || null,
372
- rowCount: result && result.rowCount,
373
- // SQL is NOT logged by default — may contain sensitive literal values
374
- // even in parameterized queries. Operators who want SQL in audit
375
- // metadata pass opts.includeSqlInAudit: true (then sealed via
376
- // field-crypto on the audit row).
377
- sql: opts.includeSqlInAudit ? sql : null,
378
- });
379
- _emitMetric("externaldb.query.success", 1,
380
- { backend: b.name, role: role || "(none)" });
381
- _emitMetric("externaldb.query.duration_ms", durationMs,
382
- { backend: b.name, role: role || "(none)" });
383
- return result;
384
- } catch (e) {
385
- var failureMs = Date.now() - t0;
386
- _emit("system.externaldb.query", "failure", {
387
- backend: b.name,
388
- role: role,
389
- durationMs: failureMs,
390
- classification: opts.classification || null,
391
- errorCode: e.code || null,
392
- }, (e && e.message) || String(e));
393
- _emitMetric("externaldb.query.failure", 1,
394
- { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
395
- // Postgres signals authorization-denied as SQLSTATE 42501
396
- // (insufficient_privilege). RLS-shaped writes that violate a
397
- // policy and GRANT-denied SELECTs both surface this code. The
398
- // operator's role-views recipe relies on this signal: a row of
399
- // db.role.denied means a request-time role attempted something its
400
- // grant or RLS policy forbids — the highest-signal compliance event
401
- // the externalDb layer can emit.
402
- if (e && e.code === "42501") {
403
- _emitMetric("db.role.denied", 1,
404
- { backend: b.name, role: role || "(none)" });
405
- }
406
- throw e;
407
- }
408
- }
409
-
410
- async function transaction(fn, opts) {
411
- _requireInit();
412
- if (typeof fn !== "function") throw _err("INVALID_FN", "transaction requires a function", true);
413
- opts = opts || {};
414
- var b = _pickBackend(opts);
415
- var role = dbRoleContext.getRole();
416
-
417
- // sessionGucs — per-transaction `SET LOCAL "name" = value` plumbing.
418
- // Each name validates as a SQL identifier (Postgres GUC names follow
419
- // the same NAMEDATALEN-shaped rules; dotted GUCs like 'app.tenant_id'
420
- // validate per-segment via quoteQualified). Values are emitted as SQL
421
- // string literals (single-quote escaped) for strings, raw for finite
422
- // numbers. SET LOCAL ties the binding to the surrounding transaction
423
- // so the tenant_id used by RLS policies resets cleanly at
424
- // COMMIT/ROLLBACK without caller cleanup.
425
- var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
426
-
427
- var t0 = Date.now();
428
- return await b.breaker.wrap(async function () {
429
- var client = await b.pool.acquire();
430
- var txClient = {
431
- query: function (sql, params) { return b.query(client, sql, params || []); },
432
- };
433
- var committed = false;
434
- try {
435
- await b.beginTx(client);
436
- for (var gi = 0; gi < prebuiltGucs.length; gi++) {
437
- await b.query(client, prebuiltGucs[gi], []);
438
- }
439
- var result = await fn(txClient);
440
- await b.commit(client);
441
- committed = true;
442
- var durationMs = Date.now() - t0;
443
- _emit("system.externaldb.transaction", "success", {
444
- backend: b.name, role: role, durationMs: durationMs,
445
- classification: opts.classification || null,
446
- });
447
- _emitMetric("externaldb.transaction.success", 1,
448
- { backend: b.name, role: role || "(none)" });
449
- _emitMetric("externaldb.transaction.duration_ms", durationMs,
450
- { backend: b.name, role: role || "(none)" });
451
- return result;
452
- } catch (e) {
453
- try { if (!committed) await b.rollback(client); } catch (_e) { /* best effort */ }
454
- var failureMs = Date.now() - t0;
455
- _emit("system.externaldb.transaction", "failure", {
456
- backend: b.name, role: role, durationMs: failureMs,
457
- classification: opts.classification || null,
458
- errorCode: e.code || null,
459
- }, (e && e.message) || String(e));
460
- _emitMetric("externaldb.transaction.failure", 1,
461
- { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
462
- if (e && e.code === "42501") {
463
- _emitMetric("db.role.denied", 1,
464
- { backend: b.name, role: role || "(none)" });
465
- }
466
- throw e;
467
- } finally {
468
- b.pool.release(client);
469
- }
470
- });
471
- }
472
-
473
- async function healthCheck(backendName) {
474
- _requireInit();
475
- if (backendName) {
476
- return _pingBackend(backends[backendName]);
477
- }
478
- var out = {};
479
- for (var name in backends) {
480
- out[name] = await _pingBackend(backends[name]);
481
- }
482
- return out;
483
- }
484
-
485
- async function _pingBackend(b) {
486
- if (!b) return { ok: false, error: "unknown backend" };
487
- try {
488
- var client = await b.pool.acquire();
489
- try {
490
- if (b.ping) await b.ping(client);
491
- else await b.query(client, "SELECT 1", []);
492
- b.pool.release(client);
493
- return { ok: true, breakerState: b.breaker.getState(), pool: b.pool.stats() };
494
- } catch (e) {
495
- await b.pool.destroy(client);
496
- return { ok: false, error: e.message, breakerState: b.breaker.getState() };
497
- }
498
- } catch (e) {
499
- return { ok: false, error: e.message, breakerState: b.breaker.getState() };
500
- }
501
- }
502
-
503
- function listBackends() {
504
- if (!initialized) return [];
505
- return Object.keys(backends).map(function (name) {
506
- var b = backends[name];
507
- return {
508
- name: name,
509
- dialect: b.dialect,
510
- classifications: b.classifications.slice(),
511
- residencyTag: b.residencyTag,
512
- breakerState: b.breaker.getState(),
513
- pool: b.pool.stats(),
514
- };
515
- });
516
- }
517
-
518
- async function shutdown() {
519
- if (!initialized) return;
520
- for (var name in backends) {
521
- try { await backends[name].pool.drain(); } catch (_e) { /* best effort */ }
522
- var bk = backends[name];
523
- if (bk && bk.replicas) {
524
- for (var i = 0; i < bk.replicas.length; i++) {
525
- try { await bk.replicas[i].pool.drain(); } catch (_e) { /* best effort */ }
526
- }
527
- }
528
- }
529
- backends = {};
530
- defaultBackend = null;
531
- initialized = false;
532
- }
533
-
534
- // Build the SET LOCAL statements for a transaction's sessionGucs map.
535
- // Identifier-validates each GUC name (per dot-segment so dotted names
536
- // like 'app.tenant_id' work), quotes them with the Postgres dialect,
537
- // and renders the value as either a SQL string literal (single-quoted,
538
- // embedded quotes doubled) or a numeric literal for finite numbers.
539
- // Bad shapes throw at the call site rather than as a confused Postgres
540
- // error mid-transaction.
541
- function _buildSessionGucsStatements(sessionGucs) {
542
- if (sessionGucs === undefined || sessionGucs === null) return [];
543
- if (typeof sessionGucs !== "object" || Array.isArray(sessionGucs)) {
544
- throw _err("INVALID_SESSION_GUCS",
545
- "sessionGucs must be an object map of name → value", true);
546
- }
547
- var out = [];
548
- for (var name in sessionGucs) {
549
- if (!Object.prototype.hasOwnProperty.call(sessionGucs, name)) continue;
550
- if (typeof name !== "string" || name.length === 0) {
551
- throw _err("INVALID_SESSION_GUCS",
552
- "sessionGucs: GUC name must be a non-empty string", true);
553
- }
554
- // Validate per-segment so dotted GUCs (Postgres custom GUC class.
555
- // setting form) pass. quoteQualified handles both the validation
556
- // and the dot-quoted rendering.
557
- var qName;
558
- try {
559
- qName = safeSql.quoteQualified(name, "postgres");
560
- } catch (e) {
561
- throw _err("INVALID_SESSION_GUCS",
562
- "sessionGucs: name '" + name + "' is not a valid identifier: " +
563
- ((e && e.message) || String(e)), true);
564
- }
565
- var value = sessionGucs[name];
566
- var literal;
567
- if (typeof value === "number" && isFinite(value)) {
568
- literal = String(value);
569
- } else if (typeof value === "boolean") {
570
- // Postgres SET accepts on/off/true/false — render true/false.
571
- literal = value ? "true" : "false";
572
- } else if (typeof value === "string") {
573
- literal = "'" + value.replace(/'/g, "''") + "'";
574
- } else if (value === null || value === undefined) {
575
- throw _err("INVALID_SESSION_GUCS",
576
- "sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
577
- (value === null ? "null" : "undefined") + ")", true);
578
- } else {
579
- throw _err("INVALID_SESSION_GUCS",
580
- "sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
581
- typeof value + ")", true);
582
- }
583
- out.push("SET LOCAL " + qName + " = " + literal);
584
- }
585
- return out;
586
- }
587
-
588
- // Fire-and-forget audit emission. We CANNOT await this in cluster mode:
589
- // audit storage routes back through external-db when cluster mode is
590
- // active, so awaiting would create a recursive dependency (every audit
591
- // row insert triggers an external-db query which would await another
592
- // audit row insert). Tests that need audit-row durability before reading
593
- // audit_log should flush microtasks explicitly.
594
- function _emit(action, outcome, metadata, reason) {
595
- audit().safeEmit({ action: action, outcome: outcome, reason: reason, metadata: metadata });
596
- }
597
-
598
- function _requireInit() {
599
- if (!initialized) throw _err("NOT_INITIALIZED", "externalDb.init() must be called first", true);
600
- }
601
-
602
- // ---- Read-replica routing ----
603
- //
604
- // Operators with a primary + replicas declare replicas alongside the
605
- // primary backend config:
606
- //
607
- // externalDb.init({
608
- // backends: {
609
- // main: {
610
- // connect, query, // primary
611
- // replicas: [
612
- // { connect: replica1, query, weight: 1 },
613
- // { connect: replica2, query, weight: 2 },
614
- // ],
615
- // replicaFallbackToPrimary: true, // default; on all-replicas-unhealthy,
616
- // // read.query falls back to primary
617
- // },
618
- // },
619
- // });
620
- //
621
- // await externalDb.read.query("SELECT * FROM users"); // → replica
622
- // await externalDb.write.query("INSERT INTO users ..."); // → primary
623
- // await externalDb.query("..."); // → primary (legacy, unchanged)
624
- //
625
- // Load balancing: weighted round-robin (default weight 1). Weights
626
- // expand into a static plan at init — a [w1, w2, w3] vector becomes a
627
- // pre-built index sequence, then read.query() advances replicaIdx.
628
- //
629
- // Health: each replica tracks `lastFailureAt`. After UNHEALTHY_COOLDOWN_MS
630
- // since the last failure, the replica re-enters the rotation. Operators
631
- // observing all-replicas-down see read.query() fall back to primary
632
- // (overridable via replicaFallbackToPrimary: false).
633
-
634
- var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
635
-
636
- function _buildReplicas(backendName, cfg) {
637
- if (!cfg.replicas) return null;
638
- if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
639
- throw _err("INVALID_CONFIG",
640
- "backend '" + backendName + "': replicas must be a non-empty array", true);
641
- }
642
- var out = [];
643
- for (var i = 0; i < cfg.replicas.length; i++) {
644
- var r = cfg.replicas[i];
645
- if (!r || typeof r.connect !== "function") {
646
- throw _err("INVALID_CONFIG",
647
- "backend '" + backendName + "': replicas[" + i + "].connect must be a function", true);
648
- }
649
- if (typeof r.query !== "function") {
650
- throw _err("INVALID_CONFIG",
651
- "backend '" + backendName + "': replicas[" + i + "].query must be a function", true);
652
- }
653
- var weight = r.weight !== undefined ? r.weight : 1;
654
- if (typeof weight !== "number" || !isFinite(weight) || weight <= 0 ||
655
- Math.floor(weight) !== weight) {
656
- throw _err("INVALID_CONFIG",
657
- "backend '" + backendName + "': replicas[" + i + "].weight must be a positive integer", true);
658
- }
659
- out.push({
660
- index: i,
661
- pool: new Pool(backendName + ":replica:" + i, r),
662
- query: r.query,
663
- weight: weight,
664
- lastFailureAt: 0,
665
- consecutiveFailures: 0,
666
- });
667
- }
668
- return out;
669
- }
670
-
671
- function _pickReplica(b) {
672
- if (!b.replicas || b.replicas.length === 0) return null;
673
- var now = Date.now();
674
- // Build a healthy candidate set.
675
- var healthy = [];
676
- for (var i = 0; i < b.replicas.length; i++) {
677
- var r = b.replicas[i];
678
- if (now - r.lastFailureAt >= REPLICA_UNHEALTHY_COOLDOWN_MS) healthy.push(r);
679
- }
680
- if (healthy.length === 0) return null;
681
- // Weighted round-robin: walk by weight, advancing replicaIdx by 1 each
682
- // call and modding by total weight. Each replica's "slot" in the
683
- // sequence repeats `weight` times.
684
- var totalWeight = 0;
685
- for (var w = 0; w < healthy.length; w++) totalWeight += healthy[w].weight;
686
- var cursor = (b.replicaIdx++) % totalWeight;
687
- var acc = 0;
688
- for (var c = 0; c < healthy.length; c++) {
689
- acc += healthy[c].weight;
690
- if (cursor < acc) return healthy[c];
691
- }
692
- return healthy[0]; // unreachable; defensive
693
- }
694
-
695
- async function _readQuery(sql, params, opts) {
696
- _requireInit();
697
- opts = opts || {};
698
- var b = _pickBackend(opts);
699
- if (!b.replicas || b.replicas.length === 0) {
700
- // No replicas configured — read.query() returns primary.
701
- return query(sql, params, opts);
702
- }
703
- var replica = _pickReplica(b);
704
- if (!replica) {
705
- if (b.replicaFallbackToPrimary) return query(sql, params, opts);
706
- throw _err("ALL_REPLICAS_UNHEALTHY",
707
- "backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
708
- }
709
- var role = dbRoleContext.getRole();
710
- var t0 = Date.now();
711
- try {
712
- var client = await replica.pool.acquire();
713
- try {
714
- var res = await replica.query(client, sql, params || []);
715
- replica.pool.release(client);
716
- replica.consecutiveFailures = 0;
717
- var durationMs = Date.now() - t0;
718
- _emit("system.externaldb.read", "success", {
719
- backend: b.name,
720
- role: role,
721
- replicaIdx: replica.index,
722
- durationMs: durationMs,
723
- rowCount: res && res.rowCount,
724
- });
725
- _emitMetric("externaldb.read.success", 1,
726
- { backend: b.name, role: role || "(none)", replicaIdx: replica.index });
727
- _emitMetric("externaldb.read.duration_ms", durationMs,
728
- { backend: b.name, role: role || "(none)", replicaIdx: replica.index });
729
- return res;
730
- } catch (e) {
731
- // Connection-shape errors mark unhealthy + destroy.
732
- if (e && (e.code === "ECONNRESET" || e.code === "ECONNREFUSED" ||
733
- e.code === "ETIMEDOUT" || e.code === "ENOTFOUND" ||
734
- e.code === "EPIPE")) {
735
- await replica.pool.destroy(client);
736
- replica.lastFailureAt = Date.now();
737
- replica.consecutiveFailures += 1;
738
- } else {
739
- replica.pool.release(client);
740
- }
741
- throw e;
742
- }
743
- } catch (e) {
744
- _emit("system.externaldb.read", "failure", {
745
- backend: b.name,
746
- role: role,
747
- replicaIdx: replica.index,
748
- durationMs: Date.now() - t0,
749
- errorCode: e.code || null,
750
- }, (e && e.message) || String(e));
751
- _emitMetric("externaldb.read.failure", 1,
752
- { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
753
- if (e && e.code === "42501") {
754
- _emitMetric("db.role.denied", 1,
755
- { backend: b.name, role: role || "(none)" });
756
- }
757
- // Fallback to primary on a failed replica read when allowed.
758
- if (b.replicaFallbackToPrimary) {
759
- return query(sql, params, opts);
760
- }
761
- throw e;
762
- }
763
- }
764
-
765
- var read = {
766
- query: _readQuery,
767
- };
768
-
769
- // write namespace — alias for the primary path. Lets operators express
770
- // intent symmetrically with read.query without a magic-comment hint.
771
- var write = {
772
- query: function (sql, params, opts) { return query(sql, params, opts); },
773
- transaction: function (fn, opts) { return transaction(fn, opts); },
774
- };
775
-
776
- function _resetForTest() {
777
- Object.keys(backends).forEach(function (n) {
778
- try { backends[n].pool.drain(); }
779
- catch (e) { log.debug("test-reset pool drain failed", { backend: n, error: e.message }); }
780
- var bk = backends[n];
781
- if (bk && bk.replicas) {
782
- bk.replicas.forEach(function (r) {
783
- try { r.pool.drain(); }
784
- catch (e2) { log.debug("test-reset replica drain failed", { backend: n, error: e2.message }); }
785
- });
786
- }
787
- });
788
- backends = {};
789
- defaultBackend = null;
790
- dbRoleBackends = {};
791
- initialized = false;
792
- audit.reset();
793
- db.reset();
794
- }
795
-
796
- // ---- configurePool — runtime resize of an existing backend's pool ----
797
- //
798
- // Operators tune pool sizing without restarting the app. Existing idle
799
- // clients are kept; new acquisitions respect the new max. min is honored
800
- // the next time the pool refills. idleTimeoutMs takes effect on the next
801
- // reaper tick.
802
- function configurePool(backendName, opts) {
803
- _requireInit();
804
- if (typeof backendName !== "string" || backendName.length === 0) {
805
- throw _err("INVALID_CONFIG", "configurePool: backendName must be a non-empty string", true);
806
- }
807
- var bk = backends[backendName];
808
- if (!bk) throw _err("UNKNOWN_BACKEND", "configurePool: no backend named '" + backendName + "'", true);
809
- if (!opts || typeof opts !== "object") {
810
- throw _err("INVALID_CONFIG", "configurePool: opts must be an object", true);
811
- }
812
- var allowed = ["min", "max", "idleTimeoutMs"];
813
- for (var k in opts) {
814
- if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
815
- if (allowed.indexOf(k) === -1) {
816
- throw _err("INVALID_CONFIG",
817
- "configurePool: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
818
- }
819
- }
820
- function _requirePosInt(name, value) {
821
- if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
822
- throw _err("INVALID_CONFIG",
823
- "configurePool: " + name + " must be a positive integer, got " + JSON.stringify(value), true);
824
- }
825
- }
826
- if (opts.min !== undefined) _requirePosInt("min", opts.min);
827
- if (opts.max !== undefined) _requirePosInt("max", opts.max);
828
- if (opts.idleTimeoutMs !== undefined) _requirePosInt("idleTimeoutMs", opts.idleTimeoutMs);
829
- if (opts.min !== undefined && opts.max !== undefined && opts.min > opts.max) {
830
- throw _err("INVALID_CONFIG", "configurePool: min must be <= max", true);
831
- }
832
- Object.assign(bk.pool.config, opts);
833
- }
834
-
835
- // ---- adapters.connectAs — Postgres role-aware connect wrapper ----
836
- //
837
- // Wraps an operator's connect() so that every fresh client runs
838
- // `SET ROLE`, `SET search_path`, `SET application_name`, and any other
839
- // operator-supplied GUCs at acquire time. The pattern enables the
840
- // search_path-views shape: the same SQL `SELECT * FROM sessions`
841
- // resolves to `public.sessions` for app_user and to
842
- // `analytics.sessions` (a view with PHI redacted) for analytics_user.
843
- // See the "Compliance Patterns" wiki page.
844
- //
845
- // Identifier inputs (role, schemas in searchPath) are validated via
846
- // safeSql.validateIdentifier — bad shapes throw at the call site. String
847
- // values (applicationName, statement_timeout) are quoted as SQL string
848
- // literals with single-quote escaping per the SQL standard.
849
- //
850
- // connect: b.externalDb.adapters.connectAs(rawConnect, {
851
- // role: "analytics_user",
852
- // searchPath: ["analytics", "public"],
853
- // applicationName: "wiki:analytics",
854
- // statementTimeoutMs: C.TIME.seconds(30),
855
- // gucs: {
856
- // idle_in_transaction_session_timeout: "60s",
857
- // },
858
- // })
859
- //
860
- // `query` is the same query function the backend declares; the wrapper
861
- // uses it to issue the SET statements.
862
- function _connectAs(rawConnect, query, opts) {
863
- if (typeof rawConnect !== "function") {
864
- throw _err("INVALID_CONFIG", "connectAs: connect must be a function", true);
865
- }
866
- if (typeof query !== "function") {
867
- throw _err("INVALID_CONFIG", "connectAs: query must be a function", true);
868
- }
869
- opts = opts || {};
870
- var allowed = ["role", "searchPath", "applicationName", "statementTimeoutMs", "gucs"];
871
- for (var k in opts) {
872
- if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
873
- if (allowed.indexOf(k) === -1) {
874
- throw _err("INVALID_CONFIG",
875
- "connectAs: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
876
- }
877
- }
878
-
879
- // Validate inputs at config time so a malformed name surfaces at
880
- // boot rather than on the first connection.
881
- if (opts.role !== undefined) {
882
- safeSql.validateIdentifier(String(opts.role), { allowReserved: false });
883
- }
884
- var pathSegments = null;
885
- if (opts.searchPath !== undefined) {
886
- var raw = Array.isArray(opts.searchPath) ? opts.searchPath : [opts.searchPath];
887
- if (raw.length === 0) {
888
- throw _err("INVALID_CONFIG", "connectAs: searchPath must have at least one schema", true);
889
- }
890
- pathSegments = [];
891
- for (var pi = 0; pi < raw.length; pi++) {
892
- safeSql.validateIdentifier(String(raw[pi]), { allowReserved: false });
893
- pathSegments.push(String(raw[pi]));
894
- }
895
- }
896
- if (opts.applicationName !== undefined && typeof opts.applicationName !== "string") {
897
- throw _err("INVALID_CONFIG", "connectAs: applicationName must be a string", true);
898
- }
899
- if (opts.statementTimeoutMs !== undefined) {
900
- if (typeof opts.statementTimeoutMs !== "number" || !isFinite(opts.statementTimeoutMs) ||
901
- opts.statementTimeoutMs <= 0 || Math.floor(opts.statementTimeoutMs) !== opts.statementTimeoutMs) {
902
- throw _err("INVALID_CONFIG",
903
- "connectAs: statementTimeoutMs must be a positive integer", true);
904
- }
905
- }
906
- if (opts.gucs !== undefined && (typeof opts.gucs !== "object" || opts.gucs === null)) {
907
- throw _err("INVALID_CONFIG", "connectAs: gucs must be an object", true);
908
- }
909
- if (opts.gucs) {
910
- for (var gname in opts.gucs) {
911
- // GUC names: Postgres NAMEDATALEN-shaped identifiers.
912
- safeSql.validateIdentifier(gname, { allowReserved: true });
913
- }
914
- }
915
-
916
- // Pre-compute the SET statements once — every fresh client runs the
917
- // same list, so building it per-connect would burn microbenchmarks.
918
- var stmts = [];
919
- if (opts.role) {
920
- stmts.push('SET ROLE "' + opts.role + '"');
921
- }
922
- if (pathSegments) {
923
- var pathSql = pathSegments.map(function (s) { return '"' + s + '"'; }).join(", ");
924
- stmts.push("SET search_path TO " + pathSql);
925
- }
926
- if (opts.applicationName !== undefined) {
927
- // Single-quoted string literal — SQL-standard escape doubles embedded
928
- // single quotes.
929
- var an = String(opts.applicationName).replace(/'/g, "''");
930
- stmts.push("SET application_name TO '" + an + "'");
931
- }
932
- if (opts.statementTimeoutMs !== undefined) {
933
- stmts.push("SET statement_timeout TO " + opts.statementTimeoutMs);
934
- }
935
- if (opts.gucs) {
936
- for (var gn in opts.gucs) {
937
- var gv = opts.gucs[gn];
938
- if (typeof gv === "number") {
939
- stmts.push('SET "' + gn + '" TO ' + gv);
940
- } else {
941
- var gvs = String(gv).replace(/'/g, "''");
942
- stmts.push('SET "' + gn + '" TO \'' + gvs + "'");
943
- }
944
- }
945
- }
946
-
947
- return async function wrappedConnect() {
948
- var client = await rawConnect();
949
- try {
950
- for (var i = 0; i < stmts.length; i++) {
951
- await query(client, stmts[i], []);
952
- }
953
- } catch (e) {
954
- // Initialization failed — the operator's close hook isn't visible
955
- // here, so we throw and let the pool's catch destroy the partial
956
- // client.
957
- throw e;
958
- }
959
- return client;
960
- };
961
- }
962
-
963
- // Operators import the helper as `b.externalDb.adapters.connectAs(connect, opts)`
964
- // — declarative wrapping with shared input validation.
965
- function _adaptersConnectAs(connect, opts) {
966
- // The backend's query function is needed to issue SET statements on a
967
- // freshly-acquired client. Operators pass it via opts.query — same
968
- // function they declare on the backend itself.
969
- if (!opts || typeof opts !== "object") {
970
- throw _err("INVALID_CONFIG",
971
- "adapters.connectAs: opts must be an object", true);
972
- }
973
- if (typeof opts.query !== "function") {
974
- throw _err("INVALID_CONFIG",
975
- "adapters.connectAs: opts.query is required (the backend's query function)", true);
976
- }
977
- // Pull query off and pass the remaining role-aware opts.
978
- var query = opts.query;
979
- var roleOpts = {};
980
- for (var k in opts) {
981
- if (Object.prototype.hasOwnProperty.call(opts, k) && k !== "query") {
982
- roleOpts[k] = opts[k];
983
- }
984
- }
985
- return _connectAs(connect, query, roleOpts);
986
- }
987
-
988
- // ---- runAs / currentRole — out-of-request role binding ----
989
- //
990
- // Inside an HTTP request the dbRoleFor middleware already pushes the
991
- // role into the shared db-role-context ALS. Background workers (jobs,
992
- // schedulers, CLI commands) don't run under that middleware — they wrap
993
- // their work in runAs(role, fn) so the same backend-pick logic applies.
994
- //
995
- // await b.externalDb.runAs("analytics_user", async function () {
996
- // return await b.externalDb.read.query("SELECT ..."); // → analytics backend
997
- // });
998
- //
999
- // currentRole() returns the active role (or null) — useful for diagnostic
1000
- // logs and observability labels.
1001
- function runAs(role, fn) {
1002
- if (typeof fn !== "function") {
1003
- throw _err("INVALID_FN", "externalDb.runAs: fn must be a function", true);
1004
- }
1005
- if (role !== null && role !== undefined) {
1006
- if (typeof role !== "string" || role.length === 0) {
1007
- throw _err("INVALID_ROLE",
1008
- "externalDb.runAs: role must be a non-empty string or null", true);
1009
- }
1010
- safeSql.validateIdentifier(role, { allowReserved: false });
1011
- }
1012
- // Audit the role transition. runAs has no req, so the actor 5 W's
1013
- // come from whatever the caller has bound on the audit-context ALS
1014
- // (log.js requestId, plus any request-bound actor that was set in
1015
- // an outer scope). Same audit shape as the dbRoleFor middleware
1016
- // path — forensic walkers can reconstruct the role timeline whether
1017
- // the binding came from request middleware or a job runner.
1018
- var previousRole = dbRoleContext.getRole();
1019
- var newRole = role || null;
1020
- if (previousRole !== newRole) {
1021
- audit().safeEmit({
1022
- action: "db.role.switched",
1023
- actor: {},
1024
- resource: { kind: "db.role", id: newRole || "(none)" },
1025
- outcome: "success",
1026
- metadata: {
1027
- previousRole: previousRole,
1028
- newRole: newRole,
1029
- source: "runAs",
1030
- },
1031
- });
1032
- }
1033
- return dbRoleContext.runWithRole(role || null, fn);
1034
- }
1035
-
1036
- function currentRole() {
1037
- return dbRoleContext.getRole();
1038
- }
1039
-
1040
- module.exports = {
1041
- init: init,
1042
- query: query,
1043
- transaction: transaction,
1044
- healthCheck: healthCheck,
1045
- listBackends: listBackends,
1046
- shutdown: shutdown,
1047
- configurePool: configurePool,
1048
- read: read,
1049
- write: write,
1050
- runAs: runAs,
1051
- currentRole: currentRole,
1052
- adapters: {
1053
- connectAs: _adaptersConnectAs,
1054
- },
1055
- // Migration runner targeting an externalDb backend. Mirrors b.migrations
1056
- // (which targets local SQLite) but runs against externalDb. Tracking +
1057
- // lock tables live on the externalDb side. See lib/external-db-migrate.js.
1058
- migrate: externalDbMigrate,
1059
- Pool: Pool,
1060
- _resetForTest: _resetForTest,
1061
- };
1
+ "use strict";
2
+ /**
3
+ * External database service — pluggable wrapper for app-data DB connections.
4
+ *
5
+ * Framework state (audit_log, consent_log, _blamejs_*) stays in the local
6
+ * SQLite via b.db. This module is for APP DATA — when an operator wants to
7
+ * keep their app's domain tables in Postgres / MySQL / MongoDB / libsql /
8
+ * etc., they configure a backend here and use b.externalDb.query() instead
9
+ * of b.db.from() for those tables.
10
+ *
11
+ * Bring-your-own-client design (per "zero npm runtime deps" rule):
12
+ * The operator supplies the actual DB driver via the backend's connect/
13
+ * query/close functions. The framework adds:
14
+ * - Connection pooling (lazy-create, reuse across queries)
15
+ * - Retry on transient errors (5xx-equivalent + network)
16
+ * - Circuit breaker per-backend
17
+ * - Classification routing (which backend serves which data class)
18
+ * - Residency enforcement (boot-time validation against
19
+ * db.getDataResidency().region)
20
+ * - Audit hooks (system.externaldb.{query,transaction,connect.failure})
21
+ *
22
+ * Built-in protocol adapters (native pg-wire, libsql-HTTP, MongoDB wire)
23
+ * are not currently bundled — operators supply `connect`/`query`/`close`
24
+ * directly using their wire client of choice. When framework-bundled
25
+ * adapters land they will be available as `b.externalDb.adapters.pg`,
26
+ * `.libsqlHttp`, etc., but the bring-your-own-client API is the
27
+ * permanent surface.
28
+ *
29
+ * Public API:
30
+ * externalDb.init({ backends: { name: { connect, query, close?, ... } },
31
+ * defaultBackend? })
32
+ * externalDb.query(sql, params?, opts?) → { rows, rowCount }
33
+ * externalDb.transaction(fn, opts?) → fn's return value
34
+ * externalDb.healthCheck(backendName?) → backend status
35
+ * externalDb.listBackends()
36
+ * externalDb.shutdown()
37
+ *
38
+ * Backend config:
39
+ * {
40
+ * connect(): async () → client (returns operator's DB client)
41
+ * query(client, sql, params): async → { rows, rowCount }
42
+ * close(client): async → void
43
+ * ping(client): async → bool (optional health check)
44
+ * beginTx(client): async → void (optional; default 'BEGIN')
45
+ * commit(client): async → void (optional; default 'COMMIT')
46
+ * rollback(client): async → void (optional; default 'ROLLBACK')
47
+ * pool: { min: 1, max: 10, idleTimeoutMs: C.TIME.minutes(1) }
48
+ * classifications: ['personal' | 'operational' | 'public' | <custom>]
49
+ * residencyTag: 'EU' | 'US' | ...
50
+ * retry, breaker
51
+ * }
52
+ */
53
+ var retryHelper = require("./retry");
54
+ var C = require("./constants");
55
+ var dbRoleContext = require("./db-role-context");
56
+ var externalDbMigrate = require("./external-db-migrate");
57
+ var lazyRequire = require("./lazy-require");
58
+ var { boot } = require("./log");
59
+ var safeAsync = require("./safe-async");
60
+ var safeSql = require("./safe-sql");
61
+ var { ExternalDbError } = require("./framework-error");
62
+
63
+ var log = boot("external-db");
64
+
65
+ var audit = lazyRequire(function () { return require("./audit"); });
66
+ var db = lazyRequire(function () { return require("./db"); });
67
+ var observability = lazyRequire(function () { return require("./observability"); });
68
+
69
+ function _emitMetric(name, value, labels) {
70
+ try { observability().event(name, value, labels || {}); }
71
+ catch (_e) { /* hot-path observability sink — drop silent by design */ }
72
+ }
73
+
74
+ var _err = ExternalDbError.factory;
75
+
76
+ var initialized = false;
77
+ var backends = {};
78
+ var defaultBackend = null;
79
+ // Operator-declared { role: backendName } map for request-time pool pick.
80
+ // Populated at init() from opts.dbRoleBackends. Read by _pickBackend
81
+ // when no explicit opts.backend is supplied AND the ALS scope has a role.
82
+ var dbRoleBackends = {};
83
+
84
+ // ---- Pool ----
85
+ //
86
+ // Per-backend pool with lazy creation + LRU-ish reuse. Connections returned
87
+ // to the pool when query/transaction completes; idle connections expire.
88
+
89
+ class Pool {
90
+ constructor(name, config) {
91
+ this.name = name;
92
+ this.config = Object.assign({ min: 1, max: 10, idleTimeoutMs: C.TIME.minutes(1) }, config.pool || {});
93
+ this.connect = config.connect;
94
+ this.close = config.close || function () { return Promise.resolve(); };
95
+ this.idle = []; // [{ client, lastUsedAt }]
96
+ this.active = 0; // count of in-use clients
97
+ this.waiters = []; // queued acquisitions when at max
98
+ this._reaper = safeAsync.repeating(this._reapIdle.bind(this),
99
+ C.TIME.seconds(10), { name: "external-db-reaper" });
100
+ }
101
+
102
+ async acquire() {
103
+ if (this.idle.length > 0) {
104
+ var entry = this.idle.pop();
105
+ this.active += 1;
106
+ return entry.client;
107
+ }
108
+ if (this.active < this.config.max) {
109
+ this.active += 1;
110
+ try {
111
+ return await this.connect();
112
+ } catch (e) {
113
+ this.active -= 1;
114
+ throw e;
115
+ }
116
+ }
117
+ // At max — wait for a release. The waiter's clock starts now;
118
+ // when release() resolves the waiter we emit the wait duration so
119
+ // operators can see backpressure on the pool.
120
+ var self = this;
121
+ var waitStartedAt = Date.now();
122
+ return new Promise(function (resolve, reject) {
123
+ self.waiters.push({
124
+ resolve: function (client) {
125
+ _emitMetric("externaldb.pool.acquire_wait", Date.now() - waitStartedAt,
126
+ { backend: self.name });
127
+ resolve(client);
128
+ },
129
+ reject: reject,
130
+ });
131
+ });
132
+ }
133
+
134
+ release(client) {
135
+ this.active -= 1;
136
+ if (this.waiters.length > 0) {
137
+ var w = this.waiters.shift();
138
+ this.active += 1;
139
+ w.resolve(client);
140
+ return;
141
+ }
142
+ this.idle.push({ client: client, lastUsedAt: Date.now() });
143
+ }
144
+
145
+ async destroy(client) {
146
+ this.active -= 1;
147
+ try { await this.close(client); } catch (_e) { /* best effort */ }
148
+ if (this.waiters.length > 0) {
149
+ var w = this.waiters.shift();
150
+ this.acquire().then(w.resolve, w.reject);
151
+ }
152
+ }
153
+
154
+ _reapIdle() {
155
+ var now = Date.now();
156
+ var keep = [];
157
+ var self = this;
158
+ this.idle.forEach(function (entry) {
159
+ if ((now - entry.lastUsedAt) >= self.config.idleTimeoutMs) {
160
+ Promise.resolve().then(function () { return self.close(entry.client); }).catch(function () {});
161
+ } else {
162
+ keep.push(entry);
163
+ }
164
+ });
165
+ this.idle = keep;
166
+ }
167
+
168
+ async drain() {
169
+ if (this._reaper) { this._reaper.stop(); this._reaper = null; }
170
+ var idleClients = this.idle.map(function (e) { return e.client; });
171
+ this.idle = [];
172
+ var self = this;
173
+ await Promise.all(idleClients.map(function (c) {
174
+ return Promise.resolve().then(function () { return self.close(c); }).catch(function () {});
175
+ }));
176
+ this.waiters.forEach(function (w) { w.reject(_err("POOL_DRAINED", "pool is shutting down", true)); });
177
+ this.waiters = [];
178
+ }
179
+
180
+ stats() {
181
+ return { active: this.active, idle: this.idle.length, waiters: this.waiters.length };
182
+ }
183
+ }
184
+
185
+ // ---- Init ----
186
+
187
+ function init(opts) {
188
+ if (initialized) return;
189
+ if (!opts || !opts.backends) throw new Error("externalDb.init({ backends }) is required");
190
+
191
+ backends = {};
192
+ dbRoleBackends = {};
193
+ for (var name in opts.backends) {
194
+ var cfg = opts.backends[name];
195
+ if (typeof cfg.connect !== "function") {
196
+ throw _err("INVALID_CONFIG", "backend '" + name + "' missing connect() function", true);
197
+ }
198
+ if (typeof cfg.query !== "function") {
199
+ throw _err("INVALID_CONFIG", "backend '" + name + "' missing query() function", true);
200
+ }
201
+ // dialect — informational marker so dialect-specific consumers
202
+ // (e.g. b.db.declareView) can fail-fast at apply time. Defaults to
203
+ // "postgres" because that's the dominant blamejs externalDb target;
204
+ // operators on SQLite/MySQL/etc. set this explicitly so downstream
205
+ // primitives surface NOT_SUPPORTED with a clear message instead of
206
+ // emitting Postgres-flavored DDL into the wrong dialect.
207
+ var dialect = (cfg.dialect || "postgres").toLowerCase();
208
+ if (["postgres", "mysql", "sqlite", "mongodb", "other"].indexOf(dialect) === -1) {
209
+ throw _err("INVALID_CONFIG",
210
+ "backend '" + name + "': dialect must be one of " +
211
+ "'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'other', got '" + dialect + "'", true);
212
+ }
213
+ backends[name] = {
214
+ name: name,
215
+ dialect: dialect,
216
+ pool: new Pool(name, cfg),
217
+ query: cfg.query,
218
+ ping: cfg.ping || null,
219
+ beginTx: cfg.beginTx || function (client) { return cfg.query(client, "BEGIN", []); },
220
+ commit: cfg.commit || function (client) { return cfg.query(client, "COMMIT", []); },
221
+ rollback: cfg.rollback || function (client) { return cfg.query(client, "ROLLBACK", []); },
222
+ classifications: Array.isArray(cfg.classifications) && cfg.classifications.length > 0
223
+ ? cfg.classifications.slice()
224
+ : ["*"],
225
+ residencyTag: cfg.residencyTag || "unrestricted",
226
+ breaker: new retryHelper.CircuitBreaker("externalDb:" + name, cfg.breaker),
227
+ retryConfig: cfg.retry || null,
228
+ replicas: _buildReplicas(name, cfg),
229
+ replicaIdx: 0, // round-robin cursor
230
+ replicaFallbackToPrimary: cfg.replicaFallbackToPrimary !== false,
231
+ };
232
+ }
233
+
234
+ defaultBackend = opts.defaultBackend || Object.keys(backends)[0];
235
+
236
+ // dbRoleBackends — request-time role → backend mapping. Each role name
237
+ // validates as a SQL identifier at init (matches the dbRoleFor
238
+ // middleware's runtime check) so a typo surfaces at boot rather than
239
+ // as a silent default-backend fallback at the first request.
240
+ if (opts.dbRoleBackends !== undefined && opts.dbRoleBackends !== null) {
241
+ if (typeof opts.dbRoleBackends !== "object" || Array.isArray(opts.dbRoleBackends)) {
242
+ throw _err("INVALID_CONFIG",
243
+ "dbRoleBackends must be an object map of role → backendName", true);
244
+ }
245
+ for (var role in opts.dbRoleBackends) {
246
+ if (!Object.prototype.hasOwnProperty.call(opts.dbRoleBackends, role)) continue;
247
+ try {
248
+ safeSql.validateIdentifier(role, { allowReserved: false });
249
+ } catch (e) {
250
+ throw _err("INVALID_CONFIG",
251
+ "dbRoleBackends: role '" + role + "' is not a valid SQL identifier: " +
252
+ ((e && e.message) || String(e)), true);
253
+ }
254
+ var bn = opts.dbRoleBackends[role];
255
+ if (typeof bn !== "string" || bn.length === 0) {
256
+ throw _err("INVALID_CONFIG",
257
+ "dbRoleBackends['" + role + "']: backend name must be a non-empty string", true);
258
+ }
259
+ if (!Object.prototype.hasOwnProperty.call(backends, bn)) {
260
+ throw _err("INVALID_CONFIG",
261
+ "dbRoleBackends['" + role + "']: no backend named '" + bn + "' " +
262
+ "(declared backends: " + Object.keys(backends).join(", ") + ")", true);
263
+ }
264
+ dbRoleBackends[role] = bn;
265
+ }
266
+ }
267
+
268
+ _validateResidency();
269
+ initialized = true;
270
+ }
271
+
272
+ function _validateResidency() {
273
+ var residency;
274
+ try { residency = db().getDataResidency(); } catch (_e) { residency = null; }
275
+ if (!residency || !residency.region) return;
276
+
277
+ var allowed = [residency.region].concat(residency.allowedStorageRegions || []);
278
+ for (var name in backends) {
279
+ var b = backends[name];
280
+ var serves = b.classifications.indexOf("*") !== -1 || b.classifications.indexOf("personal") !== -1;
281
+ if (!serves) continue;
282
+ if (allowed.indexOf(b.residencyTag) === -1) {
283
+ throw _err("RESIDENCY_VIOLATION",
284
+ "externalDb backend '" + name + "' serves 'personal' data with residencyTag '" +
285
+ b.residencyTag + "' but app's dataResidency.region is '" + residency.region + "'",
286
+ true);
287
+ }
288
+ }
289
+ }
290
+
291
+ // ---- Backend selection ----
292
+ //
293
+ // Pick precedence:
294
+ // 1. opts.backend — explicit override always wins
295
+ // 2. opts.classification — first backend serving that class
296
+ // 3. ALS-bound dbRole + dbRoleBackends — request-time auto-pick
297
+ // 4. defaultBackend — final fallback
298
+ //
299
+ // The ALS path matches the dbRoleFor middleware shape: middleware sets
300
+ // the role; deep async reads pick up the matching backend without having
301
+ // to thread `req` through every call site.
302
+
303
+ function _pickBackend(opts) {
304
+ opts = opts || {};
305
+ if (opts.backend) {
306
+ var b = backends[opts.backend];
307
+ if (!b) throw _err("UNKNOWN_BACKEND", "no backend named '" + opts.backend + "'", true);
308
+ if (opts.classification && !_servesClassification(b, opts.classification)) {
309
+ throw _err("CLASSIFICATION_MISMATCH",
310
+ "backend '" + opts.backend + "' does not serve classification '" + opts.classification + "'", true);
311
+ }
312
+ return b;
313
+ }
314
+ var classification = opts.classification;
315
+ if (classification) {
316
+ for (var name in backends) {
317
+ if (_servesClassification(backends[name], classification)) return backends[name];
318
+ }
319
+ throw _err("NO_BACKEND_FOR_CLASSIFICATION",
320
+ "no backend serves classification '" + classification + "'", true);
321
+ }
322
+ var role = dbRoleContext.getRole();
323
+ if (role && Object.prototype.hasOwnProperty.call(dbRoleBackends, role)) {
324
+ return backends[dbRoleBackends[role]];
325
+ }
326
+ return backends[defaultBackend] || null;
327
+ }
328
+
329
+ function _servesClassification(b, cls) {
330
+ return b.classifications.indexOf("*") !== -1 || b.classifications.indexOf(cls) !== -1;
331
+ }
332
+
333
+ // ---- Public API ----
334
+
335
+ async function query(sql, params, opts) {
336
+ _requireInit();
337
+ opts = opts || {};
338
+ var b = _pickBackend(opts);
339
+ var role = dbRoleContext.getRole();
340
+
341
+ var t0 = Date.now();
342
+ try {
343
+ var result = await retryHelper.withRetry(function () {
344
+ return b.breaker.wrap(async function () {
345
+ var client = await b.pool.acquire();
346
+ try {
347
+ var res = await b.query(client, sql, params || []);
348
+ b.pool.release(client);
349
+ return res;
350
+ } catch (e) {
351
+ // Connection-level errors → destroy the client; query errors →
352
+ // release back to the pool. Heuristic: any error with a code
353
+ // looking like a network/connection issue → destroy.
354
+ if (e && (e.code === "ECONNRESET" || e.code === "ECONNREFUSED" ||
355
+ e.code === "ETIMEDOUT" || e.code === "ENOTFOUND" ||
356
+ e.code === "EPIPE")) {
357
+ await b.pool.destroy(client);
358
+ } else {
359
+ b.pool.release(client);
360
+ }
361
+ throw e;
362
+ }
363
+ });
364
+ }, b.retryConfig);
365
+
366
+ var durationMs = Date.now() - t0;
367
+ _emit("system.externaldb.query", "success", {
368
+ backend: b.name,
369
+ role: role,
370
+ durationMs: durationMs,
371
+ classification: opts.classification || null,
372
+ rowCount: result && result.rowCount,
373
+ // SQL is NOT logged by default — may contain sensitive literal values
374
+ // even in parameterized queries. Operators who want SQL in audit
375
+ // metadata pass opts.includeSqlInAudit: true (then sealed via
376
+ // field-crypto on the audit row).
377
+ sql: opts.includeSqlInAudit ? sql : null,
378
+ });
379
+ _emitMetric("externaldb.query.success", 1,
380
+ { backend: b.name, role: role || "(none)" });
381
+ _emitMetric("externaldb.query.duration_ms", durationMs,
382
+ { backend: b.name, role: role || "(none)" });
383
+ return result;
384
+ } catch (e) {
385
+ var failureMs = Date.now() - t0;
386
+ _emit("system.externaldb.query", "failure", {
387
+ backend: b.name,
388
+ role: role,
389
+ durationMs: failureMs,
390
+ classification: opts.classification || null,
391
+ errorCode: e.code || null,
392
+ }, (e && e.message) || String(e));
393
+ _emitMetric("externaldb.query.failure", 1,
394
+ { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
395
+ // Postgres signals authorization-denied as SQLSTATE 42501
396
+ // (insufficient_privilege). RLS-shaped writes that violate a
397
+ // policy and GRANT-denied SELECTs both surface this code. The
398
+ // operator's role-views recipe relies on this signal: a row of
399
+ // db.role.denied means a request-time role attempted something its
400
+ // grant or RLS policy forbids — the highest-signal compliance event
401
+ // the externalDb layer can emit.
402
+ if (e && e.code === "42501") {
403
+ _emitMetric("db.role.denied", 1,
404
+ { backend: b.name, role: role || "(none)" });
405
+ }
406
+ throw e;
407
+ }
408
+ }
409
+
410
+ async function transaction(fn, opts) {
411
+ _requireInit();
412
+ if (typeof fn !== "function") throw _err("INVALID_FN", "transaction requires a function", true);
413
+ opts = opts || {};
414
+ var b = _pickBackend(opts);
415
+ var role = dbRoleContext.getRole();
416
+
417
+ // sessionGucs — per-transaction `SET LOCAL "name" = value` plumbing.
418
+ // Each name validates as a SQL identifier (Postgres GUC names follow
419
+ // the same NAMEDATALEN-shaped rules; dotted GUCs like 'app.tenant_id'
420
+ // validate per-segment via quoteQualified). Values are emitted as SQL
421
+ // string literals (single-quote escaped) for strings, raw for finite
422
+ // numbers. SET LOCAL ties the binding to the surrounding transaction
423
+ // so the tenant_id used by RLS policies resets cleanly at
424
+ // COMMIT/ROLLBACK without caller cleanup.
425
+ var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
426
+
427
+ var t0 = Date.now();
428
+ return await b.breaker.wrap(async function () {
429
+ var client = await b.pool.acquire();
430
+ var txClient = {
431
+ query: function (sql, params) { return b.query(client, sql, params || []); },
432
+ };
433
+ var committed = false;
434
+ try {
435
+ await b.beginTx(client);
436
+ for (var gi = 0; gi < prebuiltGucs.length; gi++) {
437
+ await b.query(client, prebuiltGucs[gi], []);
438
+ }
439
+ var result = await fn(txClient);
440
+ await b.commit(client);
441
+ committed = true;
442
+ var durationMs = Date.now() - t0;
443
+ _emit("system.externaldb.transaction", "success", {
444
+ backend: b.name, role: role, durationMs: durationMs,
445
+ classification: opts.classification || null,
446
+ });
447
+ _emitMetric("externaldb.transaction.success", 1,
448
+ { backend: b.name, role: role || "(none)" });
449
+ _emitMetric("externaldb.transaction.duration_ms", durationMs,
450
+ { backend: b.name, role: role || "(none)" });
451
+ return result;
452
+ } catch (e) {
453
+ try { if (!committed) await b.rollback(client); } catch (_e) { /* best effort */ }
454
+ var failureMs = Date.now() - t0;
455
+ _emit("system.externaldb.transaction", "failure", {
456
+ backend: b.name, role: role, durationMs: failureMs,
457
+ classification: opts.classification || null,
458
+ errorCode: e.code || null,
459
+ }, (e && e.message) || String(e));
460
+ _emitMetric("externaldb.transaction.failure", 1,
461
+ { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
462
+ if (e && e.code === "42501") {
463
+ _emitMetric("db.role.denied", 1,
464
+ { backend: b.name, role: role || "(none)" });
465
+ }
466
+ throw e;
467
+ } finally {
468
+ b.pool.release(client);
469
+ }
470
+ });
471
+ }
472
+
473
+ async function healthCheck(backendName) {
474
+ _requireInit();
475
+ if (backendName) {
476
+ return _pingBackend(backends[backendName]);
477
+ }
478
+ var out = {};
479
+ for (var name in backends) {
480
+ out[name] = await _pingBackend(backends[name]);
481
+ }
482
+ return out;
483
+ }
484
+
485
+ async function _pingBackend(b) {
486
+ if (!b) return { ok: false, error: "unknown backend" };
487
+ try {
488
+ var client = await b.pool.acquire();
489
+ try {
490
+ if (b.ping) await b.ping(client);
491
+ else await b.query(client, "SELECT 1", []);
492
+ b.pool.release(client);
493
+ return { ok: true, breakerState: b.breaker.getState(), pool: b.pool.stats() };
494
+ } catch (e) {
495
+ await b.pool.destroy(client);
496
+ return { ok: false, error: e.message, breakerState: b.breaker.getState() };
497
+ }
498
+ } catch (e) {
499
+ return { ok: false, error: e.message, breakerState: b.breaker.getState() };
500
+ }
501
+ }
502
+
503
+ function listBackends() {
504
+ if (!initialized) return [];
505
+ return Object.keys(backends).map(function (name) {
506
+ var b = backends[name];
507
+ return {
508
+ name: name,
509
+ dialect: b.dialect,
510
+ classifications: b.classifications.slice(),
511
+ residencyTag: b.residencyTag,
512
+ breakerState: b.breaker.getState(),
513
+ pool: b.pool.stats(),
514
+ };
515
+ });
516
+ }
517
+
518
+ async function shutdown() {
519
+ if (!initialized) return;
520
+ for (var name in backends) {
521
+ try { await backends[name].pool.drain(); } catch (_e) { /* best effort */ }
522
+ var bk = backends[name];
523
+ if (bk && bk.replicas) {
524
+ for (var i = 0; i < bk.replicas.length; i++) {
525
+ try { await bk.replicas[i].pool.drain(); } catch (_e) { /* best effort */ }
526
+ }
527
+ }
528
+ }
529
+ backends = {};
530
+ defaultBackend = null;
531
+ initialized = false;
532
+ }
533
+
534
+ // Build the SET LOCAL statements for a transaction's sessionGucs map.
535
+ // Identifier-validates each GUC name (per dot-segment so dotted names
536
+ // like 'app.tenant_id' work), quotes them with the Postgres dialect,
537
+ // and renders the value as either a SQL string literal (single-quoted,
538
+ // embedded quotes doubled) or a numeric literal for finite numbers.
539
+ // Bad shapes throw at the call site rather than as a confused Postgres
540
+ // error mid-transaction.
541
+ function _buildSessionGucsStatements(sessionGucs) {
542
+ if (sessionGucs === undefined || sessionGucs === null) return [];
543
+ if (typeof sessionGucs !== "object" || Array.isArray(sessionGucs)) {
544
+ throw _err("INVALID_SESSION_GUCS",
545
+ "sessionGucs must be an object map of name → value", true);
546
+ }
547
+ var out = [];
548
+ for (var name in sessionGucs) {
549
+ if (!Object.prototype.hasOwnProperty.call(sessionGucs, name)) continue;
550
+ if (typeof name !== "string" || name.length === 0) {
551
+ throw _err("INVALID_SESSION_GUCS",
552
+ "sessionGucs: GUC name must be a non-empty string", true);
553
+ }
554
+ // Validate per-segment so dotted GUCs (Postgres custom GUC class.
555
+ // setting form) pass. quoteQualified handles both the validation
556
+ // and the dot-quoted rendering.
557
+ var qName;
558
+ try {
559
+ qName = safeSql.quoteQualified(name, "postgres");
560
+ } catch (e) {
561
+ throw _err("INVALID_SESSION_GUCS",
562
+ "sessionGucs: name '" + name + "' is not a valid identifier: " +
563
+ ((e && e.message) || String(e)), true);
564
+ }
565
+ var value = sessionGucs[name];
566
+ var literal;
567
+ if (typeof value === "number" && isFinite(value)) {
568
+ literal = String(value);
569
+ } else if (typeof value === "boolean") {
570
+ // Postgres SET accepts on/off/true/false — render true/false.
571
+ literal = value ? "true" : "false";
572
+ } else if (typeof value === "string") {
573
+ literal = "'" + value.replace(/'/g, "''") + "'";
574
+ } else if (value === null || value === undefined) {
575
+ throw _err("INVALID_SESSION_GUCS",
576
+ "sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
577
+ (value === null ? "null" : "undefined") + ")", true);
578
+ } else {
579
+ throw _err("INVALID_SESSION_GUCS",
580
+ "sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
581
+ typeof value + ")", true);
582
+ }
583
+ out.push("SET LOCAL " + qName + " = " + literal);
584
+ }
585
+ return out;
586
+ }
587
+
588
+ // Fire-and-forget audit emission. We CANNOT await this in cluster mode:
589
+ // audit storage routes back through external-db when cluster mode is
590
+ // active, so awaiting would create a recursive dependency (every audit
591
+ // row insert triggers an external-db query which would await another
592
+ // audit row insert). Tests that need audit-row durability before reading
593
+ // audit_log should flush microtasks explicitly.
594
+ function _emit(action, outcome, metadata, reason) {
595
+ audit().safeEmit({ action: action, outcome: outcome, reason: reason, metadata: metadata });
596
+ }
597
+
598
+ function _requireInit() {
599
+ if (!initialized) throw _err("NOT_INITIALIZED", "externalDb.init() must be called first", true);
600
+ }
601
+
602
+ // ---- Read-replica routing ----
603
+ //
604
+ // Operators with a primary + replicas declare replicas alongside the
605
+ // primary backend config:
606
+ //
607
+ // externalDb.init({
608
+ // backends: {
609
+ // main: {
610
+ // connect, query, // primary
611
+ // replicas: [
612
+ // { connect: replica1, query, weight: 1 },
613
+ // { connect: replica2, query, weight: 2 },
614
+ // ],
615
+ // replicaFallbackToPrimary: true, // default; on all-replicas-unhealthy,
616
+ // // read.query falls back to primary
617
+ // },
618
+ // },
619
+ // });
620
+ //
621
+ // await externalDb.read.query("SELECT * FROM users"); // → replica
622
+ // await externalDb.write.query("INSERT INTO users ..."); // → primary
623
+ // await externalDb.query("..."); // → primary (legacy, unchanged)
624
+ //
625
+ // Load balancing: weighted round-robin (default weight 1). Weights
626
+ // expand into a static plan at init — a [w1, w2, w3] vector becomes a
627
+ // pre-built index sequence, then read.query() advances replicaIdx.
628
+ //
629
+ // Health: each replica tracks `lastFailureAt`. After UNHEALTHY_COOLDOWN_MS
630
+ // since the last failure, the replica re-enters the rotation. Operators
631
+ // observing all-replicas-down see read.query() fall back to primary
632
+ // (overridable via replicaFallbackToPrimary: false).
633
+
634
+ var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
635
+
636
+ function _buildReplicas(backendName, cfg) {
637
+ if (!cfg.replicas) return null;
638
+ if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
639
+ throw _err("INVALID_CONFIG",
640
+ "backend '" + backendName + "': replicas must be a non-empty array", true);
641
+ }
642
+ var out = [];
643
+ for (var i = 0; i < cfg.replicas.length; i++) {
644
+ var r = cfg.replicas[i];
645
+ if (!r || typeof r.connect !== "function") {
646
+ throw _err("INVALID_CONFIG",
647
+ "backend '" + backendName + "': replicas[" + i + "].connect must be a function", true);
648
+ }
649
+ if (typeof r.query !== "function") {
650
+ throw _err("INVALID_CONFIG",
651
+ "backend '" + backendName + "': replicas[" + i + "].query must be a function", true);
652
+ }
653
+ var weight = r.weight !== undefined ? r.weight : 1;
654
+ if (typeof weight !== "number" || !isFinite(weight) || weight <= 0 ||
655
+ Math.floor(weight) !== weight) {
656
+ throw _err("INVALID_CONFIG",
657
+ "backend '" + backendName + "': replicas[" + i + "].weight must be a positive integer", true);
658
+ }
659
+ out.push({
660
+ index: i,
661
+ pool: new Pool(backendName + ":replica:" + i, r),
662
+ query: r.query,
663
+ weight: weight,
664
+ lastFailureAt: 0,
665
+ consecutiveFailures: 0,
666
+ });
667
+ }
668
+ return out;
669
+ }
670
+
671
+ function _pickReplica(b) {
672
+ if (!b.replicas || b.replicas.length === 0) return null;
673
+ var now = Date.now();
674
+ // Build a healthy candidate set.
675
+ var healthy = [];
676
+ for (var i = 0; i < b.replicas.length; i++) {
677
+ var r = b.replicas[i];
678
+ if (now - r.lastFailureAt >= REPLICA_UNHEALTHY_COOLDOWN_MS) healthy.push(r);
679
+ }
680
+ if (healthy.length === 0) return null;
681
+ // Weighted round-robin: walk by weight, advancing replicaIdx by 1 each
682
+ // call and modding by total weight. Each replica's "slot" in the
683
+ // sequence repeats `weight` times.
684
+ var totalWeight = 0;
685
+ for (var w = 0; w < healthy.length; w++) totalWeight += healthy[w].weight;
686
+ var cursor = (b.replicaIdx++) % totalWeight;
687
+ var acc = 0;
688
+ for (var c = 0; c < healthy.length; c++) {
689
+ acc += healthy[c].weight;
690
+ if (cursor < acc) return healthy[c];
691
+ }
692
+ return healthy[0]; // unreachable; defensive
693
+ }
694
+
695
+ async function _readQuery(sql, params, opts) {
696
+ _requireInit();
697
+ opts = opts || {};
698
+ var b = _pickBackend(opts);
699
+ if (!b.replicas || b.replicas.length === 0) {
700
+ // No replicas configured — read.query() returns primary.
701
+ return query(sql, params, opts);
702
+ }
703
+ var replica = _pickReplica(b);
704
+ if (!replica) {
705
+ if (b.replicaFallbackToPrimary) return query(sql, params, opts);
706
+ throw _err("ALL_REPLICAS_UNHEALTHY",
707
+ "backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
708
+ }
709
+ var role = dbRoleContext.getRole();
710
+ var t0 = Date.now();
711
+ try {
712
+ var client = await replica.pool.acquire();
713
+ try {
714
+ var res = await replica.query(client, sql, params || []);
715
+ replica.pool.release(client);
716
+ replica.consecutiveFailures = 0;
717
+ var durationMs = Date.now() - t0;
718
+ _emit("system.externaldb.read", "success", {
719
+ backend: b.name,
720
+ role: role,
721
+ replicaIdx: replica.index,
722
+ durationMs: durationMs,
723
+ rowCount: res && res.rowCount,
724
+ });
725
+ _emitMetric("externaldb.read.success", 1,
726
+ { backend: b.name, role: role || "(none)", replicaIdx: replica.index });
727
+ _emitMetric("externaldb.read.duration_ms", durationMs,
728
+ { backend: b.name, role: role || "(none)", replicaIdx: replica.index });
729
+ return res;
730
+ } catch (e) {
731
+ // Connection-shape errors mark unhealthy + destroy.
732
+ if (e && (e.code === "ECONNRESET" || e.code === "ECONNREFUSED" ||
733
+ e.code === "ETIMEDOUT" || e.code === "ENOTFOUND" ||
734
+ e.code === "EPIPE")) {
735
+ await replica.pool.destroy(client);
736
+ replica.lastFailureAt = Date.now();
737
+ replica.consecutiveFailures += 1;
738
+ } else {
739
+ replica.pool.release(client);
740
+ }
741
+ throw e;
742
+ }
743
+ } catch (e) {
744
+ _emit("system.externaldb.read", "failure", {
745
+ backend: b.name,
746
+ role: role,
747
+ replicaIdx: replica.index,
748
+ durationMs: Date.now() - t0,
749
+ errorCode: e.code || null,
750
+ }, (e && e.message) || String(e));
751
+ _emitMetric("externaldb.read.failure", 1,
752
+ { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
753
+ if (e && e.code === "42501") {
754
+ _emitMetric("db.role.denied", 1,
755
+ { backend: b.name, role: role || "(none)" });
756
+ }
757
+ // Fallback to primary on a failed replica read when allowed.
758
+ if (b.replicaFallbackToPrimary) {
759
+ return query(sql, params, opts);
760
+ }
761
+ throw e;
762
+ }
763
+ }
764
+
765
+ var read = {
766
+ query: _readQuery,
767
+ };
768
+
769
+ // write namespace — alias for the primary path. Lets operators express
770
+ // intent symmetrically with read.query without a magic-comment hint.
771
+ var write = {
772
+ query: function (sql, params, opts) { return query(sql, params, opts); },
773
+ transaction: function (fn, opts) { return transaction(fn, opts); },
774
+ };
775
+
776
+ function _resetForTest() {
777
+ Object.keys(backends).forEach(function (n) {
778
+ try { backends[n].pool.drain(); }
779
+ catch (e) { log.debug("test-reset pool drain failed", { backend: n, error: e.message }); }
780
+ var bk = backends[n];
781
+ if (bk && bk.replicas) {
782
+ bk.replicas.forEach(function (r) {
783
+ try { r.pool.drain(); }
784
+ catch (e2) { log.debug("test-reset replica drain failed", { backend: n, error: e2.message }); }
785
+ });
786
+ }
787
+ });
788
+ backends = {};
789
+ defaultBackend = null;
790
+ dbRoleBackends = {};
791
+ initialized = false;
792
+ audit.reset();
793
+ db.reset();
794
+ }
795
+
796
+ // ---- configurePool — runtime resize of an existing backend's pool ----
797
+ //
798
+ // Operators tune pool sizing without restarting the app. Existing idle
799
+ // clients are kept; new acquisitions respect the new max. min is honored
800
+ // the next time the pool refills. idleTimeoutMs takes effect on the next
801
+ // reaper tick.
802
+ function configurePool(backendName, opts) {
803
+ _requireInit();
804
+ if (typeof backendName !== "string" || backendName.length === 0) {
805
+ throw _err("INVALID_CONFIG", "configurePool: backendName must be a non-empty string", true);
806
+ }
807
+ var bk = backends[backendName];
808
+ if (!bk) throw _err("UNKNOWN_BACKEND", "configurePool: no backend named '" + backendName + "'", true);
809
+ if (!opts || typeof opts !== "object") {
810
+ throw _err("INVALID_CONFIG", "configurePool: opts must be an object", true);
811
+ }
812
+ var allowed = ["min", "max", "idleTimeoutMs"];
813
+ for (var k in opts) {
814
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
815
+ if (allowed.indexOf(k) === -1) {
816
+ throw _err("INVALID_CONFIG",
817
+ "configurePool: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
818
+ }
819
+ }
820
+ function _requirePosInt(name, value) {
821
+ if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
822
+ throw _err("INVALID_CONFIG",
823
+ "configurePool: " + name + " must be a positive integer, got " + JSON.stringify(value), true);
824
+ }
825
+ }
826
+ if (opts.min !== undefined) _requirePosInt("min", opts.min);
827
+ if (opts.max !== undefined) _requirePosInt("max", opts.max);
828
+ if (opts.idleTimeoutMs !== undefined) _requirePosInt("idleTimeoutMs", opts.idleTimeoutMs);
829
+ if (opts.min !== undefined && opts.max !== undefined && opts.min > opts.max) {
830
+ throw _err("INVALID_CONFIG", "configurePool: min must be <= max", true);
831
+ }
832
+ Object.assign(bk.pool.config, opts);
833
+ }
834
+
835
+ // ---- adapters.connectAs — Postgres role-aware connect wrapper ----
836
+ //
837
+ // Wraps an operator's connect() so that every fresh client runs
838
+ // `SET ROLE`, `SET search_path`, `SET application_name`, and any other
839
+ // operator-supplied GUCs at acquire time. The pattern enables the
840
+ // search_path-views shape: the same SQL `SELECT * FROM sessions`
841
+ // resolves to `public.sessions` for app_user and to
842
+ // `analytics.sessions` (a view with PHI redacted) for analytics_user.
843
+ // See the "Compliance Patterns" wiki page.
844
+ //
845
+ // Identifier inputs (role, schemas in searchPath) are validated via
846
+ // safeSql.validateIdentifier — bad shapes throw at the call site. String
847
+ // values (applicationName, statement_timeout) are quoted as SQL string
848
+ // literals with single-quote escaping per the SQL standard.
849
+ //
850
+ // connect: b.externalDb.adapters.connectAs(rawConnect, {
851
+ // role: "analytics_user",
852
+ // searchPath: ["analytics", "public"],
853
+ // applicationName: "wiki:analytics",
854
+ // statementTimeoutMs: C.TIME.seconds(30),
855
+ // gucs: {
856
+ // idle_in_transaction_session_timeout: "60s",
857
+ // },
858
+ // })
859
+ //
860
+ // `query` is the same query function the backend declares; the wrapper
861
+ // uses it to issue the SET statements.
862
+ function _connectAs(rawConnect, query, opts) {
863
+ if (typeof rawConnect !== "function") {
864
+ throw _err("INVALID_CONFIG", "connectAs: connect must be a function", true);
865
+ }
866
+ if (typeof query !== "function") {
867
+ throw _err("INVALID_CONFIG", "connectAs: query must be a function", true);
868
+ }
869
+ opts = opts || {};
870
+ var allowed = ["role", "searchPath", "applicationName", "statementTimeoutMs", "gucs"];
871
+ for (var k in opts) {
872
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
873
+ if (allowed.indexOf(k) === -1) {
874
+ throw _err("INVALID_CONFIG",
875
+ "connectAs: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
876
+ }
877
+ }
878
+
879
+ // Validate inputs at config time so a malformed name surfaces at
880
+ // boot rather than on the first connection.
881
+ if (opts.role !== undefined) {
882
+ safeSql.validateIdentifier(String(opts.role), { allowReserved: false });
883
+ }
884
+ var pathSegments = null;
885
+ if (opts.searchPath !== undefined) {
886
+ var raw = Array.isArray(opts.searchPath) ? opts.searchPath : [opts.searchPath];
887
+ if (raw.length === 0) {
888
+ throw _err("INVALID_CONFIG", "connectAs: searchPath must have at least one schema", true);
889
+ }
890
+ pathSegments = [];
891
+ for (var pi = 0; pi < raw.length; pi++) {
892
+ safeSql.validateIdentifier(String(raw[pi]), { allowReserved: false });
893
+ pathSegments.push(String(raw[pi]));
894
+ }
895
+ }
896
+ if (opts.applicationName !== undefined && typeof opts.applicationName !== "string") {
897
+ throw _err("INVALID_CONFIG", "connectAs: applicationName must be a string", true);
898
+ }
899
+ if (opts.statementTimeoutMs !== undefined) {
900
+ if (typeof opts.statementTimeoutMs !== "number" || !isFinite(opts.statementTimeoutMs) ||
901
+ opts.statementTimeoutMs <= 0 || Math.floor(opts.statementTimeoutMs) !== opts.statementTimeoutMs) {
902
+ throw _err("INVALID_CONFIG",
903
+ "connectAs: statementTimeoutMs must be a positive integer", true);
904
+ }
905
+ }
906
+ if (opts.gucs !== undefined && (typeof opts.gucs !== "object" || opts.gucs === null)) {
907
+ throw _err("INVALID_CONFIG", "connectAs: gucs must be an object", true);
908
+ }
909
+ if (opts.gucs) {
910
+ for (var gname in opts.gucs) {
911
+ // GUC names: Postgres NAMEDATALEN-shaped identifiers.
912
+ safeSql.validateIdentifier(gname, { allowReserved: true });
913
+ }
914
+ }
915
+
916
+ // Pre-compute the SET statements once — every fresh client runs the
917
+ // same list, so building it per-connect would burn microbenchmarks.
918
+ var stmts = [];
919
+ if (opts.role) {
920
+ stmts.push('SET ROLE "' + opts.role + '"');
921
+ }
922
+ if (pathSegments) {
923
+ var pathSql = pathSegments.map(function (s) { return '"' + s + '"'; }).join(", ");
924
+ stmts.push("SET search_path TO " + pathSql);
925
+ }
926
+ if (opts.applicationName !== undefined) {
927
+ // Single-quoted string literal — SQL-standard escape doubles embedded
928
+ // single quotes.
929
+ var an = String(opts.applicationName).replace(/'/g, "''");
930
+ stmts.push("SET application_name TO '" + an + "'");
931
+ }
932
+ if (opts.statementTimeoutMs !== undefined) {
933
+ stmts.push("SET statement_timeout TO " + opts.statementTimeoutMs);
934
+ }
935
+ if (opts.gucs) {
936
+ for (var gn in opts.gucs) {
937
+ var gv = opts.gucs[gn];
938
+ if (typeof gv === "number") {
939
+ stmts.push('SET "' + gn + '" TO ' + gv);
940
+ } else {
941
+ var gvs = String(gv).replace(/'/g, "''");
942
+ stmts.push('SET "' + gn + '" TO \'' + gvs + "'");
943
+ }
944
+ }
945
+ }
946
+
947
+ return async function wrappedConnect() {
948
+ var client = await rawConnect();
949
+ try {
950
+ for (var i = 0; i < stmts.length; i++) {
951
+ await query(client, stmts[i], []);
952
+ }
953
+ } catch (e) {
954
+ // Initialization failed — the operator's close hook isn't visible
955
+ // here, so we throw and let the pool's catch destroy the partial
956
+ // client.
957
+ throw e;
958
+ }
959
+ return client;
960
+ };
961
+ }
962
+
963
+ // Operators import the helper as `b.externalDb.adapters.connectAs(connect, opts)`
964
+ // — declarative wrapping with shared input validation.
965
+ function _adaptersConnectAs(connect, opts) {
966
+ // The backend's query function is needed to issue SET statements on a
967
+ // freshly-acquired client. Operators pass it via opts.query — same
968
+ // function they declare on the backend itself.
969
+ if (!opts || typeof opts !== "object") {
970
+ throw _err("INVALID_CONFIG",
971
+ "adapters.connectAs: opts must be an object", true);
972
+ }
973
+ if (typeof opts.query !== "function") {
974
+ throw _err("INVALID_CONFIG",
975
+ "adapters.connectAs: opts.query is required (the backend's query function)", true);
976
+ }
977
+ // Pull query off and pass the remaining role-aware opts.
978
+ var query = opts.query;
979
+ var roleOpts = {};
980
+ for (var k in opts) {
981
+ if (Object.prototype.hasOwnProperty.call(opts, k) && k !== "query") {
982
+ roleOpts[k] = opts[k];
983
+ }
984
+ }
985
+ return _connectAs(connect, query, roleOpts);
986
+ }
987
+
988
+ // ---- runAs / currentRole — out-of-request role binding ----
989
+ //
990
+ // Inside an HTTP request the dbRoleFor middleware already pushes the
991
+ // role into the shared db-role-context ALS. Background workers (jobs,
992
+ // schedulers, CLI commands) don't run under that middleware — they wrap
993
+ // their work in runAs(role, fn) so the same backend-pick logic applies.
994
+ //
995
+ // await b.externalDb.runAs("analytics_user", async function () {
996
+ // return await b.externalDb.read.query("SELECT ..."); // → analytics backend
997
+ // });
998
+ //
999
+ // currentRole() returns the active role (or null) — useful for diagnostic
1000
+ // logs and observability labels.
1001
+ function runAs(role, fn) {
1002
+ if (typeof fn !== "function") {
1003
+ throw _err("INVALID_FN", "externalDb.runAs: fn must be a function", true);
1004
+ }
1005
+ if (role !== null && role !== undefined) {
1006
+ if (typeof role !== "string" || role.length === 0) {
1007
+ throw _err("INVALID_ROLE",
1008
+ "externalDb.runAs: role must be a non-empty string or null", true);
1009
+ }
1010
+ safeSql.validateIdentifier(role, { allowReserved: false });
1011
+ }
1012
+ // Audit the role transition. runAs has no req, so the actor 5 W's
1013
+ // come from whatever the caller has bound on the audit-context ALS
1014
+ // (log.js requestId, plus any request-bound actor that was set in
1015
+ // an outer scope). Same audit shape as the dbRoleFor middleware
1016
+ // path — forensic walkers can reconstruct the role timeline whether
1017
+ // the binding came from request middleware or a job runner.
1018
+ var previousRole = dbRoleContext.getRole();
1019
+ var newRole = role || null;
1020
+ if (previousRole !== newRole) {
1021
+ audit().safeEmit({
1022
+ action: "db.role.switched",
1023
+ actor: {},
1024
+ resource: { kind: "db.role", id: newRole || "(none)" },
1025
+ outcome: "success",
1026
+ metadata: {
1027
+ previousRole: previousRole,
1028
+ newRole: newRole,
1029
+ source: "runAs",
1030
+ },
1031
+ });
1032
+ }
1033
+ return dbRoleContext.runWithRole(role || null, fn);
1034
+ }
1035
+
1036
+ function currentRole() {
1037
+ return dbRoleContext.getRole();
1038
+ }
1039
+
1040
+ module.exports = {
1041
+ init: init,
1042
+ query: query,
1043
+ transaction: transaction,
1044
+ healthCheck: healthCheck,
1045
+ listBackends: listBackends,
1046
+ shutdown: shutdown,
1047
+ configurePool: configurePool,
1048
+ read: read,
1049
+ write: write,
1050
+ runAs: runAs,
1051
+ currentRole: currentRole,
1052
+ adapters: {
1053
+ connectAs: _adaptersConnectAs,
1054
+ },
1055
+ // Migration runner targeting an externalDb backend. Mirrors b.migrations
1056
+ // (which targets local SQLite) but runs against externalDb. Tracking +
1057
+ // lock tables live on the externalDb side. See lib/external-db-migrate.js.
1058
+ migrate: externalDbMigrate,
1059
+ Pool: Pool,
1060
+ _resetForTest: _resetForTest,
1061
+ };