@blamejs/core 0.7.18 → 0.7.19

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 (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  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-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  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 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  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/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. 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
+ };