@blamejs/core 0.7.4 → 0.7.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/external-db.js
CHANGED
|
@@ -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
|
+
};
|