@blamejs/core 0.6.2 → 0.6.4

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **0.6.3** (2026-05-01) — externalDb pool tuning + role-aware connect + read-replica routing
12
+ - **0.6.2** (2026-05-01) — input validation + identifier-quoting consistency
11
13
  - **0.6.1** (2026-05-01) — security tightenings + operator-facing jargon sweep
12
14
  - **0.6.0** (2026-05-01) — wiki restructured into 22 focused pages + missing-primitive coverage
13
15
 
package/README.md CHANGED
@@ -17,7 +17,7 @@ The modern Node app is a 1,200-package supply-chain liability with no LTS calend
17
17
 
18
18
  ## Status
19
19
 
20
- Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.2** ([releases](https://github.com/blamejs/blamejs/releases) · [npm](https://www.npmjs.com/package/@blamejs/core) · [container](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki)).
20
+ Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.3** ([releases](https://github.com/blamejs/blamejs/releases) · [npm](https://www.npmjs.com/package/@blamejs/core) · [container](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki)).
21
21
 
22
22
  ```js
23
23
  var b = require("@blamejs/core");
@@ -63,6 +63,7 @@ Full primitive-by-primitive docs live at [blamejs.com](https://blamejs.com), whi
63
63
  - **Validation** — [Safe Parsers](https://blamejs.com/safe-parsers)
64
64
  - **Communication** — [WebSockets](https://blamejs.com/websockets) · [Mail](https://blamejs.com/mail) · [Notifications](https://blamejs.com/notifications)
65
65
  - **Tools** — [Observability](https://blamejs.com/observability) · [Testing](https://blamejs.com/testing) · [i18n & Locale](https://blamejs.com/i18n-locale) · [Format Helpers](https://blamejs.com/format-helpers)
66
+ - **Compliance** — [Compliance Patterns](https://blamejs.com/compliance-patterns)
66
67
  - **Production** — [Cluster Mode](https://blamejs.com/cluster) · [Reliability](https://blamejs.com/reliability) · [Backup & Restore](https://blamejs.com/backup-restore)
67
68
 
68
69
  ## CLI
@@ -54,6 +54,7 @@ var retryHelper = require("./retry");
54
54
  var C = require("./constants");
55
55
  var lazyRequire = require("./lazy-require");
56
56
  var safeAsync = require("./safe-async");
57
+ var safeSql = require("./safe-sql");
57
58
  var { ExternalDbError } = require("./framework-error");
58
59
 
59
60
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -187,6 +188,9 @@ function init(opts) {
187
188
  residencyTag: cfg.residencyTag || "unrestricted",
188
189
  breaker: new retryHelper.CircuitBreaker("externalDb:" + name, cfg.breaker),
189
190
  retryConfig: cfg.retry || null,
191
+ replicas: _buildReplicas(name, cfg),
192
+ replicaIdx: 0, // round-robin cursor
193
+ replicaFallbackToPrimary: cfg.replicaFallbackToPrimary !== false,
190
194
  };
191
195
  }
192
196
 
@@ -380,6 +384,12 @@ async function shutdown() {
380
384
  if (!initialized) return;
381
385
  for (var name in backends) {
382
386
  try { await backends[name].pool.drain(); } catch (_e) { /* best effort */ }
387
+ var bk = backends[name];
388
+ if (bk && bk.replicas) {
389
+ for (var i = 0; i < bk.replicas.length; i++) {
390
+ try { await bk.replicas[i].pool.drain(); } catch (_e) { /* best effort */ }
391
+ }
392
+ }
383
393
  }
384
394
  backends = {};
385
395
  defaultBackend = null;
@@ -400,9 +410,173 @@ function _requireInit() {
400
410
  if (!initialized) throw _err("NOT_INITIALIZED", "externalDb.init() must be called first", true);
401
411
  }
402
412
 
413
+ // ---- Read-replica routing ----
414
+ //
415
+ // Operators with a primary + replicas declare replicas alongside the
416
+ // primary backend config:
417
+ //
418
+ // externalDb.init({
419
+ // backends: {
420
+ // main: {
421
+ // connect, query, // primary
422
+ // replicas: [
423
+ // { connect: replica1, query, weight: 1 },
424
+ // { connect: replica2, query, weight: 2 },
425
+ // ],
426
+ // replicaFallbackToPrimary: true, // default; on all-replicas-unhealthy,
427
+ // // read.query falls back to primary
428
+ // },
429
+ // },
430
+ // });
431
+ //
432
+ // await externalDb.read.query("SELECT * FROM users"); // → replica
433
+ // await externalDb.write.query("INSERT INTO users ..."); // → primary
434
+ // await externalDb.query("..."); // → primary (legacy, unchanged)
435
+ //
436
+ // Load balancing: weighted round-robin (default weight 1). Weights
437
+ // expand into a static plan at init — a [w1, w2, w3] vector becomes a
438
+ // pre-built index sequence, then read.query() advances replicaIdx.
439
+ //
440
+ // Health: each replica tracks `lastFailureAt`. After UNHEALTHY_COOLDOWN_MS
441
+ // since the last failure, the replica re-enters the rotation. Operators
442
+ // observing all-replicas-down see read.query() fall back to primary
443
+ // (overridable via replicaFallbackToPrimary: false).
444
+
445
+ var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
446
+
447
+ function _buildReplicas(backendName, cfg) {
448
+ if (!cfg.replicas) return null;
449
+ if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
450
+ throw _err("INVALID_CONFIG",
451
+ "backend '" + backendName + "': replicas must be a non-empty array", true);
452
+ }
453
+ var out = [];
454
+ for (var i = 0; i < cfg.replicas.length; i++) {
455
+ var r = cfg.replicas[i];
456
+ if (!r || typeof r.connect !== "function") {
457
+ throw _err("INVALID_CONFIG",
458
+ "backend '" + backendName + "': replicas[" + i + "].connect must be a function", true);
459
+ }
460
+ if (typeof r.query !== "function") {
461
+ throw _err("INVALID_CONFIG",
462
+ "backend '" + backendName + "': replicas[" + i + "].query must be a function", true);
463
+ }
464
+ var weight = r.weight !== undefined ? r.weight : 1;
465
+ if (typeof weight !== "number" || !isFinite(weight) || weight <= 0 ||
466
+ Math.floor(weight) !== weight) {
467
+ throw _err("INVALID_CONFIG",
468
+ "backend '" + backendName + "': replicas[" + i + "].weight must be a positive integer", true);
469
+ }
470
+ out.push({
471
+ index: i,
472
+ pool: new Pool(backendName + ":replica:" + i, r),
473
+ query: r.query,
474
+ weight: weight,
475
+ lastFailureAt: 0,
476
+ consecutiveFailures: 0,
477
+ });
478
+ }
479
+ return out;
480
+ }
481
+
482
+ function _pickReplica(b) {
483
+ if (!b.replicas || b.replicas.length === 0) return null;
484
+ var now = Date.now();
485
+ // Build a healthy candidate set.
486
+ var healthy = [];
487
+ for (var i = 0; i < b.replicas.length; i++) {
488
+ var r = b.replicas[i];
489
+ if (now - r.lastFailureAt >= REPLICA_UNHEALTHY_COOLDOWN_MS) healthy.push(r);
490
+ }
491
+ if (healthy.length === 0) return null;
492
+ // Weighted round-robin: walk by weight, advancing replicaIdx by 1 each
493
+ // call and modding by total weight. Each replica's "slot" in the
494
+ // sequence repeats `weight` times.
495
+ var totalWeight = 0;
496
+ for (var w = 0; w < healthy.length; w++) totalWeight += healthy[w].weight;
497
+ var cursor = (b.replicaIdx++) % totalWeight;
498
+ var acc = 0;
499
+ for (var c = 0; c < healthy.length; c++) {
500
+ acc += healthy[c].weight;
501
+ if (cursor < acc) return healthy[c];
502
+ }
503
+ return healthy[0]; // unreachable; defensive
504
+ }
505
+
506
+ async function _readQuery(sql, params, opts) {
507
+ _requireInit();
508
+ opts = opts || {};
509
+ var b = _pickBackend(opts);
510
+ if (!b.replicas || b.replicas.length === 0) {
511
+ // No replicas configured — read.query() returns primary.
512
+ return query(sql, params, opts);
513
+ }
514
+ var replica = _pickReplica(b);
515
+ if (!replica) {
516
+ if (b.replicaFallbackToPrimary) return query(sql, params, opts);
517
+ throw _err("ALL_REPLICAS_UNHEALTHY",
518
+ "backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
519
+ }
520
+ var t0 = Date.now();
521
+ try {
522
+ var client = await replica.pool.acquire();
523
+ try {
524
+ var res = await replica.query(client, sql, params || []);
525
+ replica.pool.release(client);
526
+ replica.consecutiveFailures = 0;
527
+ _emit("system.externaldb.read", "success", {
528
+ backend: b.name,
529
+ replicaIdx: replica.index,
530
+ durationMs: Date.now() - t0,
531
+ rowCount: res && res.rowCount,
532
+ });
533
+ return res;
534
+ } catch (e) {
535
+ // Connection-shape errors mark unhealthy + destroy.
536
+ if (e && (e.code === "ECONNRESET" || e.code === "ECONNREFUSED" ||
537
+ e.code === "ETIMEDOUT" || e.code === "ENOTFOUND" ||
538
+ e.code === "EPIPE")) {
539
+ await replica.pool.destroy(client);
540
+ replica.lastFailureAt = Date.now();
541
+ replica.consecutiveFailures += 1;
542
+ } else {
543
+ replica.pool.release(client);
544
+ }
545
+ throw e;
546
+ }
547
+ } catch (e) {
548
+ _emit("system.externaldb.read", "failure", {
549
+ backend: b.name,
550
+ replicaIdx: replica.index,
551
+ durationMs: Date.now() - t0,
552
+ errorCode: e.code || null,
553
+ }, (e && e.message) || String(e));
554
+ // Fallback to primary on a failed replica read when allowed.
555
+ if (b.replicaFallbackToPrimary) {
556
+ return query(sql, params, opts);
557
+ }
558
+ throw e;
559
+ }
560
+ }
561
+
562
+ var read = {
563
+ query: _readQuery,
564
+ };
565
+
566
+ // write namespace — alias for the primary path. Lets operators express
567
+ // intent symmetrically with read.query without a magic-comment hint.
568
+ var write = {
569
+ query: function (sql, params, opts) { return query(sql, params, opts); },
570
+ transaction: function (fn, opts) { return transaction(fn, opts); },
571
+ };
572
+
403
573
  function _resetForTest() {
404
574
  Object.keys(backends).forEach(function (n) {
405
575
  try { backends[n].pool.drain(); } catch (_e) {}
576
+ var bk = backends[n];
577
+ if (bk && bk.replicas) {
578
+ bk.replicas.forEach(function (r) { try { r.pool.drain(); } catch (_e) {} });
579
+ }
406
580
  });
407
581
  backends = {};
408
582
  defaultBackend = null;
@@ -411,6 +585,198 @@ function _resetForTest() {
411
585
  db.reset();
412
586
  }
413
587
 
588
+ // ---- configurePool — runtime resize of an existing backend's pool ----
589
+ //
590
+ // Operators tune pool sizing without restarting the app. Existing idle
591
+ // clients are kept; new acquisitions respect the new max. min is honored
592
+ // the next time the pool refills. idleTimeoutMs takes effect on the next
593
+ // reaper tick.
594
+ function configurePool(backendName, opts) {
595
+ _requireInit();
596
+ if (typeof backendName !== "string" || backendName.length === 0) {
597
+ throw _err("INVALID_CONFIG", "configurePool: backendName must be a non-empty string", true);
598
+ }
599
+ var bk = backends[backendName];
600
+ if (!bk) throw _err("UNKNOWN_BACKEND", "configurePool: no backend named '" + backendName + "'", true);
601
+ if (!opts || typeof opts !== "object") {
602
+ throw _err("INVALID_CONFIG", "configurePool: opts must be an object", true);
603
+ }
604
+ var allowed = ["min", "max", "idleTimeoutMs"];
605
+ for (var k in opts) {
606
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
607
+ if (allowed.indexOf(k) === -1) {
608
+ throw _err("INVALID_CONFIG",
609
+ "configurePool: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
610
+ }
611
+ }
612
+ function _requirePosInt(name, value) {
613
+ if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
614
+ throw _err("INVALID_CONFIG",
615
+ "configurePool: " + name + " must be a positive integer, got " + JSON.stringify(value), true);
616
+ }
617
+ }
618
+ if (opts.min !== undefined) _requirePosInt("min", opts.min);
619
+ if (opts.max !== undefined) _requirePosInt("max", opts.max);
620
+ if (opts.idleTimeoutMs !== undefined) _requirePosInt("idleTimeoutMs", opts.idleTimeoutMs);
621
+ if (opts.min !== undefined && opts.max !== undefined && opts.min > opts.max) {
622
+ throw _err("INVALID_CONFIG", "configurePool: min must be <= max", true);
623
+ }
624
+ Object.assign(bk.pool.config, opts);
625
+ }
626
+
627
+ // ---- adapters.connectAs — Postgres role-aware connect wrapper ----
628
+ //
629
+ // Wraps an operator's connect() so that every fresh client runs
630
+ // `SET ROLE`, `SET search_path`, `SET application_name`, and any other
631
+ // operator-supplied GUCs at acquire time. The pattern enables the
632
+ // search_path-views shape: the same SQL `SELECT * FROM sessions`
633
+ // resolves to `public.sessions` for app_user and to
634
+ // `analytics.sessions` (a view with PHI redacted) for analytics_user.
635
+ // See the "Compliance Patterns" wiki page.
636
+ //
637
+ // Identifier inputs (role, schemas in searchPath) are validated via
638
+ // safeSql.validateIdentifier — bad shapes throw at the call site. String
639
+ // values (applicationName, statement_timeout) are quoted as SQL string
640
+ // literals with single-quote escaping per the SQL standard.
641
+ //
642
+ // connect: b.externalDb.adapters.connectAs(rawConnect, {
643
+ // role: "analytics_user",
644
+ // searchPath: ["analytics", "public"],
645
+ // applicationName: "wiki:analytics",
646
+ // statementTimeoutMs: C.TIME.seconds(30),
647
+ // gucs: {
648
+ // idle_in_transaction_session_timeout: "60s",
649
+ // },
650
+ // })
651
+ //
652
+ // `query` is the same query function the backend declares; the wrapper
653
+ // uses it to issue the SET statements.
654
+ function _connectAs(rawConnect, query, opts) {
655
+ if (typeof rawConnect !== "function") {
656
+ throw _err("INVALID_CONFIG", "connectAs: connect must be a function", true);
657
+ }
658
+ if (typeof query !== "function") {
659
+ throw _err("INVALID_CONFIG", "connectAs: query must be a function", true);
660
+ }
661
+ opts = opts || {};
662
+ var allowed = ["role", "searchPath", "applicationName", "statementTimeoutMs", "gucs"];
663
+ for (var k in opts) {
664
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
665
+ if (allowed.indexOf(k) === -1) {
666
+ throw _err("INVALID_CONFIG",
667
+ "connectAs: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
668
+ }
669
+ }
670
+
671
+ // Validate inputs at config time so a malformed name surfaces at
672
+ // boot rather than on the first connection.
673
+ if (opts.role !== undefined) {
674
+ safeSql.validateIdentifier(String(opts.role), { allowReserved: false });
675
+ }
676
+ var pathSegments = null;
677
+ if (opts.searchPath !== undefined) {
678
+ var raw = Array.isArray(opts.searchPath) ? opts.searchPath : [opts.searchPath];
679
+ if (raw.length === 0) {
680
+ throw _err("INVALID_CONFIG", "connectAs: searchPath must have at least one schema", true);
681
+ }
682
+ pathSegments = [];
683
+ for (var pi = 0; pi < raw.length; pi++) {
684
+ safeSql.validateIdentifier(String(raw[pi]), { allowReserved: false });
685
+ pathSegments.push(String(raw[pi]));
686
+ }
687
+ }
688
+ if (opts.applicationName !== undefined && typeof opts.applicationName !== "string") {
689
+ throw _err("INVALID_CONFIG", "connectAs: applicationName must be a string", true);
690
+ }
691
+ if (opts.statementTimeoutMs !== undefined) {
692
+ if (typeof opts.statementTimeoutMs !== "number" || !isFinite(opts.statementTimeoutMs) ||
693
+ opts.statementTimeoutMs <= 0 || Math.floor(opts.statementTimeoutMs) !== opts.statementTimeoutMs) {
694
+ throw _err("INVALID_CONFIG",
695
+ "connectAs: statementTimeoutMs must be a positive integer", true);
696
+ }
697
+ }
698
+ if (opts.gucs !== undefined && (typeof opts.gucs !== "object" || opts.gucs === null)) {
699
+ throw _err("INVALID_CONFIG", "connectAs: gucs must be an object", true);
700
+ }
701
+ if (opts.gucs) {
702
+ for (var gname in opts.gucs) {
703
+ // GUC names: Postgres NAMEDATALEN-shaped identifiers.
704
+ safeSql.validateIdentifier(gname, { allowReserved: true });
705
+ }
706
+ }
707
+
708
+ // Pre-compute the SET statements once — every fresh client runs the
709
+ // same list, so building it per-connect would burn microbenchmarks.
710
+ var stmts = [];
711
+ if (opts.role) {
712
+ stmts.push('SET ROLE "' + opts.role + '"');
713
+ }
714
+ if (pathSegments) {
715
+ var pathSql = pathSegments.map(function (s) { return '"' + s + '"'; }).join(", ");
716
+ stmts.push("SET search_path TO " + pathSql);
717
+ }
718
+ if (opts.applicationName !== undefined) {
719
+ // Single-quoted string literal — SQL-standard escape doubles embedded
720
+ // single quotes.
721
+ var an = String(opts.applicationName).replace(/'/g, "''");
722
+ stmts.push("SET application_name TO '" + an + "'");
723
+ }
724
+ if (opts.statementTimeoutMs !== undefined) {
725
+ stmts.push("SET statement_timeout TO " + opts.statementTimeoutMs);
726
+ }
727
+ if (opts.gucs) {
728
+ for (var gn in opts.gucs) {
729
+ var gv = opts.gucs[gn];
730
+ if (typeof gv === "number") {
731
+ stmts.push('SET "' + gn + '" TO ' + gv);
732
+ } else {
733
+ var gvs = String(gv).replace(/'/g, "''");
734
+ stmts.push('SET "' + gn + '" TO \'' + gvs + "'");
735
+ }
736
+ }
737
+ }
738
+
739
+ return async function wrappedConnect() {
740
+ var client = await rawConnect();
741
+ try {
742
+ for (var i = 0; i < stmts.length; i++) {
743
+ await query(client, stmts[i], []);
744
+ }
745
+ } catch (e) {
746
+ // Initialization failed — the operator's close hook isn't visible
747
+ // here, so we throw and let the pool's catch destroy the partial
748
+ // client.
749
+ throw e;
750
+ }
751
+ return client;
752
+ };
753
+ }
754
+
755
+ // Operators import the helper as `b.externalDb.adapters.connectAs(connect, opts)`
756
+ // — declarative wrapping with shared input validation.
757
+ function _adaptersConnectAs(connect, opts) {
758
+ // The backend's query function is needed to issue SET statements on a
759
+ // freshly-acquired client. Operators pass it via opts.query — same
760
+ // function they declare on the backend itself.
761
+ if (!opts || typeof opts !== "object") {
762
+ throw _err("INVALID_CONFIG",
763
+ "adapters.connectAs: opts must be an object", true);
764
+ }
765
+ if (typeof opts.query !== "function") {
766
+ throw _err("INVALID_CONFIG",
767
+ "adapters.connectAs: opts.query is required (the backend's query function)", true);
768
+ }
769
+ // Pull query off and pass the remaining role-aware opts.
770
+ var query = opts.query;
771
+ var roleOpts = {};
772
+ for (var k in opts) {
773
+ if (Object.prototype.hasOwnProperty.call(opts, k) && k !== "query") {
774
+ roleOpts[k] = opts[k];
775
+ }
776
+ }
777
+ return _connectAs(connect, query, roleOpts);
778
+ }
779
+
414
780
  module.exports = {
415
781
  init: init,
416
782
  query: query,
@@ -418,6 +784,12 @@ module.exports = {
418
784
  healthCheck: healthCheck,
419
785
  listBackends: listBackends,
420
786
  shutdown: shutdown,
787
+ configurePool: configurePool,
788
+ read: read,
789
+ write: write,
790
+ adapters: {
791
+ connectAs: _adaptersConnectAs,
792
+ },
421
793
  Pool: Pool,
422
794
  _resetForTest: _resetForTest,
423
795
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",