@blamejs/core 0.14.27 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +158 -77
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +228 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +82 -29
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +293 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. package/sbom.cdx.json +6 -6
@@ -48,15 +48,35 @@
48
48
  var C = require("./constants");
49
49
  var { generateToken } = require("./crypto");
50
50
  var externalDb = require("./external-db");
51
+ var frameworkSchema = require("./framework-schema");
52
+ var lazyRequire = require("./lazy-require");
51
53
  var { ClusterProviderError } = require("./framework-error");
52
54
 
53
55
  var _err = ClusterProviderError.factory;
54
56
 
57
+ // Lazy requires — cluster.js requires this module while cluster-storage /
58
+ // sql are still mid-load (cluster -> cluster-provider-db -> external-db ->
59
+ // external-db-migrate -> cluster-storage -> cluster), so a top-of-file
60
+ // require would resolve to an unfinished module. clusterStorage.placeholderize
61
+ // translates the b.sql `?` output to Postgres `$N`; sql is the b.sql builder.
62
+ // Both are resolved at first SQL emission, by which point the cycle has settled.
63
+ var clusterStorage = lazyRequire(function () { return require("./cluster-storage"); });
64
+ var sql = lazyRequire(function () { return require("./sql"); });
65
+
55
66
  function create(config) {
56
67
  if (!config || !config.externalDbBackend) {
57
68
  throw _err("INVALID_CONFIG",
58
69
  "cluster-provider-db requires { externalDbBackend: <name> }", true);
59
70
  }
71
+
72
+ // The coordination tables, resolved through frameworkSchema so the
73
+ // configurable framework-table prefix is honored. These names are
74
+ // already `_blamejs_`-prefixed; the resolve is a no-op under the default
75
+ // prefix and namespaces them under a custom one. Resolved here (not at
76
+ // module load) because cluster.js requires this module while
77
+ // framework-schema is mid-load — its tableName export is not yet bound.
78
+ var LEADER_TABLE = frameworkSchema.tableName("_blamejs_leader"); // allow:hand-rolled-sql — single canonical logical-name reference
79
+ var STATE_TABLE = frameworkSchema.tableName("_blamejs_cluster_state"); // allow:hand-rolled-sql — single canonical logical-name reference
60
80
  var backendName = config.externalDbBackend;
61
81
  var dialect = (config.dialect || "postgres").toLowerCase();
62
82
  if (dialect !== "postgres" && dialect !== "sqlite" && dialect !== "mysql") {
@@ -65,16 +85,45 @@ function create(config) {
65
85
  true);
66
86
  }
67
87
 
68
- // Postgres + SQLite use $1/$2 placeholders; MySQL uses ?. Keeping a
69
- // single helper means the SQL builder doesn't have to care.
70
- function _placeholder(n) {
71
- return dialect === "mysql" ? "?" : "$" + n;
88
+ // The backtick / double-quote identifier-quote char b.sql raw fragments
89
+ // (the upsert fencing increment + conflict guard) must use so a fragment
90
+ // composes into the dialect-final statement with consistent quoting.
91
+ var qchar = dialect === "mysql" ? "`" : "\"";
92
+ function _qraw(col) { return qchar + col + qchar; }
93
+
94
+ // Existing-row self-reference inside an upsert's DO UPDATE / conflict guard.
95
+ // Postgres' ON CONFLICT DO UPDATE puts BOTH the target row and the `excluded`
96
+ // proposed row in scope with identical columns, so a bare column is ambiguous
97
+ // (SQLSTATE 42702) — the existing-row reference must be table-qualified.
98
+ // SQLite resolves a bare column to the target (no `excluded` ambiguity); MySQL
99
+ // uses the ON DUPLICATE KEY IF()-fold (no `excluded` table) and b.sql reads the
100
+ // bare column for that fold — so both keep the unqualified form.
101
+ function _selfCol(col) {
102
+ return dialect === "postgres" ? (LEADER_TABLE + "." + _qraw(col)) : _qraw(col);
103
+ }
104
+
105
+ // Emit a b.sql builder to dialect-final { sql, params }: b.sql always
106
+ // emits `?` placeholders, so translate them to `$N` for Postgres (the
107
+ // externalDb driver receives the SQL verbatim and never renumbers).
108
+ // SQLite + MySQL both accept `?`.
109
+ function _emit(builder) {
110
+ var built = builder.toSql();
111
+ return {
112
+ sql: clusterStorage().placeholderize(built.sql, dialect),
113
+ params: built.params,
114
+ };
72
115
  }
73
116
 
74
117
  function _q(sql, params) {
75
118
  return externalDb.query(sql, params || [], { backend: backendName });
76
119
  }
77
120
 
121
+ // Run a b.sql builder against the backend.
122
+ function _run(builder) {
123
+ var e = _emit(builder);
124
+ return _q(e.sql, e.params);
125
+ }
126
+
78
127
  async function ensureSchema() {
79
128
  // Postgres + MySQL: BIGINT for ms-precision timestamps. SQLite:
80
129
  // INTEGER (which is wide enough to hold a 64-bit value).
@@ -90,38 +139,38 @@ function create(config) {
90
139
  // belt-and-braces — application code only ever writes 'leader' /
91
140
  // 'state' so the check is informational. Skip on MySQL to avoid
92
141
  // CREATE TABLE failures on installations where CHECK is parsed
93
- // but then dropped silently (which would cause version drift).
94
- var leaderCheck = dialect === "mysql" ? "" : ", CHECK (scope = 'leader')";
95
- var stateCheck = dialect === "mysql" ? "" : ", CHECK (scope = 'state')";
142
+ // but then dropped silently (which would cause version drift). The
143
+ // scope CHECK is a static, operator-controlled literal carried as the
144
+ // last column's verbatim constraint (b.sql guards it with allowLiterals
145
+ // since it is not operator input).
146
+ var leaderCheck = dialect === "mysql" ? "" : ", CHECK (scope = 'leader')"; // allow:hand-rolled-sql — static DDL CHECK literal
147
+ var stateCheck = dialect === "mysql" ? "" : ", CHECK (scope = 'state')"; // allow:hand-rolled-sql — static DDL CHECK literal
96
148
 
97
- await _q(
98
- "CREATE TABLE IF NOT EXISTS _blamejs_leader (" +
99
- " scope " + pkText + " PRIMARY KEY," +
100
- " nodeId " + bodyText + " NOT NULL," +
101
- " leaseId " + bodyText + " NOT NULL," +
102
- " acquiredAt " + intType + " NOT NULL," +
103
- " expiresAt " + intType + " NOT NULL," +
104
- " fencingToken " + intType + " NOT NULL," +
105
- " endpoint " + bodyText + leaderCheck +
106
- ")"
107
- );
149
+ await _q(sql().createTable(LEADER_TABLE, [
150
+ { name: "scope", type: pkText, primaryKey: true },
151
+ { name: "nodeId", type: bodyText, notNull: true },
152
+ { name: "leaseId", type: bodyText, notNull: true },
153
+ { name: "acquiredAt", type: intType, notNull: true },
154
+ { name: "expiresAt", type: intType, notNull: true },
155
+ { name: "fencingToken", type: intType, notNull: true },
156
+ { name: "endpoint", type: bodyText, constraints: leaderCheck },
157
+ ], { dialect: dialect }).sql);
108
158
  // Migration for installs that pre-date the endpoint column. Both
109
159
  // Postgres (≥9.6) and SQLite (≥3.35, March 2021) support ADD COLUMN
110
160
  // IF NOT EXISTS; MySQL 8.0.29+ does as well. We go through try/catch
111
161
  // to keep the path version-agnostic — the only "expected" failure
112
162
  // here is "column already exists," which we swallow.
113
163
  try {
114
- await _q("ALTER TABLE _blamejs_leader ADD COLUMN endpoint " + bodyText);
164
+ await _q(sql().alterTable(LEADER_TABLE,
165
+ { addColumn: { name: "endpoint", type: bodyText } }, { dialect: dialect }).sql);
115
166
  } catch (_e) { /* column already exists — fine */ }
116
167
 
117
- await _q(
118
- "CREATE TABLE IF NOT EXISTS _blamejs_cluster_state (" +
119
- " scope " + pkText + " PRIMARY KEY," +
120
- " vaultKeyFp " + bodyText + " NOT NULL," +
121
- " recordedAt " + intType + " NOT NULL," +
122
- " recordedByNode " + bodyText + " NOT NULL" + stateCheck +
123
- ")"
124
- );
168
+ await _q(sql().createTable(STATE_TABLE, [
169
+ { name: "scope", type: pkText, primaryKey: true },
170
+ { name: "vaultKeyFp", type: bodyText, notNull: true },
171
+ { name: "recordedAt", type: intType, notNull: true },
172
+ { name: "recordedByNode", type: bodyText, notNull: true, constraints: stateCheck },
173
+ ], { dialect: dialect }).sql);
125
174
  }
126
175
 
127
176
  async function acquireLease(nodeId, leaseTtlMs, opts) {
@@ -134,60 +183,45 @@ function create(config) {
134
183
  var nowMs = Date.now();
135
184
  var expiresAt = nowMs + leaseTtlMs;
136
185
 
186
+ // One upsert builder serves every dialect. Postgres / SQLite emit
187
+ // `INSERT ... ON CONFLICT (scope) DO UPDATE SET ... WHERE expiresAt < ?
188
+ // RETURNING ...` (atomic acquire-or-steal in one statement). MySQL has
189
+ // no `ON CONFLICT ... WHERE` / `RETURNING`, so b.sql folds the conflict
190
+ // guard into `IF(expiresAt < ?, <new>, <old>)` per column (the still-
191
+ // valid lease is preserved, the expired one overwritten) and emits a
192
+ // readback SELECT keyed on scope='leader'. The fencing-token bump
193
+ // (`fencingToken + 1`) is the only non-proposed-value assignment; every
194
+ // other column re-binds the proposed value (equivalent to EXCLUDED.col /
195
+ // VALUES(col)). guardColumn names expiresAt so the MySQL fold assigns it
196
+ // LAST — IF() evaluates each column against the PRE-update row state, so
197
+ // expiresAt must change after the columns whose guard reads it.
198
+ var acquire = sql().upsert(LEADER_TABLE, { dialect: dialect })
199
+ .columns(["scope", "nodeId", "leaseId", "acquiredAt", "expiresAt", "fencingToken", "endpoint"])
200
+ .values({
201
+ scope: "leader", nodeId: nodeId, leaseId: leaseId,
202
+ acquiredAt: nowMs, expiresAt: expiresAt, fencingToken: 1, endpoint: endpoint,
203
+ })
204
+ .doUpdate({
205
+ nodeId: "?", leaseId: "?", acquiredAt: "?", expiresAt: "?",
206
+ fencingToken: _selfCol("fencingToken") + " + 1",
207
+ endpoint: "?",
208
+ }, [nodeId, leaseId, nowMs, expiresAt, endpoint])
209
+ .conflictWhere(_selfCol("expiresAt") + " < ?", [nowMs], { guardColumn: "expiresAt" })
210
+ .returning(["nodeId", "leaseId", "acquiredAt", "expiresAt", "fencingToken", "endpoint"]);
211
+
137
212
  var row;
138
213
  if (dialect === "mysql") {
139
- // MySQL has no `ON CONFLICT ... DO UPDATE WHERE` and no
140
- // `RETURNING`. Atomicity comes from `INSERT ... ON DUPLICATE
141
- // KEY UPDATE` evaluated as one statement; the WHERE-clause
142
- // semantics are implemented per-column with `IF(expiresAt <
143
- // nowMs, VALUES(col), col)` so a still-valid lease is preserved
144
- // and an expired one is overwritten. The follow-up SELECT
145
- // reveals who currently holds the row — same as Postgres'
146
- // RETURNING but as a separate statement.
147
- var insertSql =
148
- "INSERT INTO _blamejs_leader " +
149
- " (scope, nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint) " +
150
- "VALUES " +
151
- " ('leader', ?, ?, ?, ?, 1, ?) " +
152
- "ON DUPLICATE KEY UPDATE " +
153
- " nodeId = IF(expiresAt < ?, VALUES(nodeId), nodeId)," +
154
- " leaseId = IF(expiresAt < ?, VALUES(leaseId), leaseId)," +
155
- " acquiredAt = IF(expiresAt < ?, VALUES(acquiredAt), acquiredAt)," +
156
- " fencingToken = IF(expiresAt < ?, fencingToken + 1, fencingToken)," +
157
- " endpoint = IF(expiresAt < ?, VALUES(endpoint), endpoint)," +
158
- // expiresAt MUST be the last assignment — IF() evaluates each
159
- // column against the row state BEFORE that column's update is
160
- // applied, so checking expiresAt for the other columns first
161
- // and overwriting it last keeps the predicate consistent.
162
- " expiresAt = IF(expiresAt < ?, VALUES(expiresAt), expiresAt)";
163
- await _q(insertSql, [
164
- nodeId, leaseId, nowMs, expiresAt, endpoint,
165
- nowMs, nowMs, nowMs, nowMs, nowMs, nowMs,
166
- ]);
167
- var sel = await _q(
168
- "SELECT nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint " +
169
- "FROM _blamejs_leader WHERE scope = 'leader'"
170
- );
214
+ var mBuilt = acquire.toSql();
215
+ await _q(clusterStorage().placeholderize(mBuilt.sql, dialect), mBuilt.params);
216
+ // b.sql's MySQL upsert returns the readback SELECT alongside (the
217
+ // RETURNING-equivalent); run it to learn who currently holds the row.
218
+ var rb = mBuilt.readbackSql;
219
+ var sel = await _q(clusterStorage().placeholderize(rb.sql, dialect), rb.params);
171
220
  if (!sel.rows || sel.rows.length === 0) return null;
172
221
  row = sel.rows[0];
173
222
  } else {
174
- // Postgres / SQLite — single-statement RETURNING.
175
- var sql =
176
- "INSERT INTO _blamejs_leader " +
177
- " (scope, nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint) " +
178
- "VALUES " +
179
- " ('leader', " + _placeholder(1) + ", " + _placeholder(2) + ", " +
180
- " " + _placeholder(3) + ", " + _placeholder(4) + ", 1, " + _placeholder(5) + ") " +
181
- "ON CONFLICT (scope) DO UPDATE SET " +
182
- " nodeId = EXCLUDED.nodeId," +
183
- " leaseId = EXCLUDED.leaseId," +
184
- " acquiredAt = EXCLUDED.acquiredAt," +
185
- " expiresAt = EXCLUDED.expiresAt," +
186
- " fencingToken = _blamejs_leader.fencingToken + 1," +
187
- " endpoint = EXCLUDED.endpoint " +
188
- "WHERE _blamejs_leader.expiresAt < " + _placeholder(6) + " " +
189
- "RETURNING nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint";
190
- var result = await _q(sql, [nodeId, leaseId, nowMs, expiresAt, endpoint, nowMs]);
223
+ acquire.onConflict(["scope"]);
224
+ var result = await _run(acquire);
191
225
  if (!result.rows || result.rows.length === 0) return null;
192
226
  row = result.rows[0];
193
227
  }
@@ -219,24 +253,24 @@ function create(config) {
219
253
  // returns either no row OR a row with a different leaseId, and
220
254
  // we throw LEASE_LOST. Don't bump fencingToken on renewal — only
221
255
  // on a fresh acquire.
256
+ var renewCols = ["nodeId", "leaseId", "acquiredAt", "expiresAt", "fencingToken", "endpoint"];
222
257
  var row;
223
258
  if (dialect === "mysql") {
224
- var rv = await _q(
225
- "UPDATE _blamejs_leader SET " +
226
- " expiresAt = ?, endpoint = ? " +
227
- "WHERE scope = 'leader' AND nodeId = ? AND leaseId = ?",
228
- [newExpiresAt, endpoint, lease.nodeId, lease.leaseId]
229
- );
259
+ // MySQL has no RETURNING — UPDATE then read back, with a takeover
260
+ // detected when the read-back row no longer carries our leaseId.
261
+ var rvBuilt = sql().update(LEADER_TABLE, { dialect: dialect })
262
+ .set({ expiresAt: newExpiresAt, endpoint: endpoint })
263
+ .where("scope", "leader").where("nodeId", lease.nodeId).where("leaseId", lease.leaseId)
264
+ .toSql();
265
+ var rv = await _q(clusterStorage().placeholderize(rvBuilt.sql, dialect), rvBuilt.params);
230
266
  var affected = rv && (rv.affectedRows || rv.rowCount || 0);
231
267
  if (!affected) {
232
268
  throw _err("LEASE_LOST",
233
269
  "lease for node '" + lease.nodeId + "' was taken over (renewal rejected)",
234
270
  false);
235
271
  }
236
- var sel = await _q(
237
- "SELECT nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint " +
238
- "FROM _blamejs_leader WHERE scope = 'leader'"
239
- );
272
+ var sel = await _run(sql().select(LEADER_TABLE, { dialect: dialect })
273
+ .columns(renewCols).where("scope", "leader"));
240
274
  if (!sel.rows || sel.rows.length === 0 ||
241
275
  sel.rows[0].nodeId !== lease.nodeId ||
242
276
  sel.rows[0].leaseId !== lease.leaseId) {
@@ -246,14 +280,10 @@ function create(config) {
246
280
  }
247
281
  row = sel.rows[0];
248
282
  } else {
249
- var sql =
250
- "UPDATE _blamejs_leader SET " +
251
- " expiresAt = " + _placeholder(1) + "," +
252
- " endpoint = " + _placeholder(2) + " " +
253
- "WHERE scope = 'leader' AND nodeId = " + _placeholder(3) +
254
- " AND leaseId = " + _placeholder(4) + " " +
255
- "RETURNING nodeId, leaseId, acquiredAt, expiresAt, fencingToken, endpoint";
256
- var result = await _q(sql, [newExpiresAt, endpoint, lease.nodeId, lease.leaseId]);
283
+ var result = await _run(sql().update(LEADER_TABLE, { dialect: dialect })
284
+ .set({ expiresAt: newExpiresAt, endpoint: endpoint })
285
+ .where("scope", "leader").where("nodeId", lease.nodeId).where("leaseId", lease.leaseId)
286
+ .returning(renewCols));
257
287
  if (!result.rows || result.rows.length === 0) {
258
288
  throw _err("LEASE_LOST",
259
289
  "lease for node '" + lease.nodeId + "' was taken over (renewal rejected)",
@@ -276,19 +306,15 @@ function create(config) {
276
306
  // Clear our row so the next acquire wins immediately. Match on
277
307
  // leaseId so a takeover-then-release race doesn't clear someone
278
308
  // else's lease.
279
- var sql =
280
- "UPDATE _blamejs_leader SET " +
281
- " expiresAt = 0 " +
282
- "WHERE scope = 'leader' AND nodeId = " + _placeholder(1) +
283
- " AND leaseId = " + _placeholder(2);
284
- await _q(sql, [lease.nodeId, lease.leaseId]);
309
+ await _run(sql().update(LEADER_TABLE, { dialect: dialect })
310
+ .set({ expiresAt: 0 })
311
+ .where("scope", "leader").where("nodeId", lease.nodeId).where("leaseId", lease.leaseId));
285
312
  }
286
313
 
287
314
  async function currentLeader() {
288
- var result = await _q(
289
- "SELECT nodeId, expiresAt, fencingToken, endpoint FROM _blamejs_leader " +
290
- "WHERE scope = 'leader'"
291
- );
315
+ var result = await _run(sql().select(LEADER_TABLE, { dialect: dialect })
316
+ .columns(["nodeId", "expiresAt", "fencingToken", "endpoint"])
317
+ .where("scope", "leader"));
292
318
  if (!result.rows || result.rows.length === 0) return null;
293
319
  var row = result.rows[0];
294
320
  if (Number(row.expiresAt) < Date.now()) return null;
@@ -74,6 +74,38 @@ function tableName(local) {
74
74
  return local;
75
75
  }
76
76
 
77
+ /**
78
+ * @primitive b.clusterStorage.dialect
79
+ * @signature b.clusterStorage.dialect()
80
+ * @since 0.15.0
81
+ * @status stable
82
+ * @related b.clusterStorage.execute, b.cluster.dialect, b.frameworkSchema.ensureSchema
83
+ *
84
+ * Resolve the SQL dialect every framework-table data-layer file must pass
85
+ * to `b.sql` so the emitted SQL matches the active backend. In cluster
86
+ * mode it returns the operator-configured backend dialect (`"postgres"` |
87
+ * `"mysql"` | `"sqlite"`, set at `b.cluster.init`); in single-node mode
88
+ * the framework state lives in local node:sqlite, so it returns
89
+ * `"sqlite"`. This is the canonical dialect source for framework-state
90
+ * SQL — `b.sql` defaults to `"sqlite"` when no dialect is passed, which is
91
+ * correct only on the single-node path and on Postgres by accident (both
92
+ * double-quote identifiers); on MySQL the default would emit double-quoted
93
+ * identifiers MySQL reads as string literals, so framework-table SQL must
94
+ * thread this value explicitly.
95
+ *
96
+ * @example
97
+ * var b = require("@blamejs/core");
98
+ * var dialect = b.clusterStorage.dialect();
99
+ * // → "sqlite" (single-node)
100
+ * // → "postgres" (cluster mode, postgres backend)
101
+ * // → "mysql" (cluster mode, mysql backend)
102
+ * var built = b.sql.select("_blamejs_cache", { dialect: dialect })
103
+ * .where("cacheKey", "k").toSql();
104
+ */
105
+ function dialect() {
106
+ return cluster.isClusterMode() ? cluster.dialect() : "sqlite";
107
+ }
108
+
77
109
  // Precomputed rewrite table for resolveTables(). Built once at module
78
110
  // load from the static frozen frameworkSchema.LOCAL_TO_EXTERNAL mapping —
79
111
  // not from operator/request input. Order longest-first so prefix matches
@@ -127,7 +159,19 @@ function _replaceWordBoundaryAll(haystack, needle, replacement) {
127
159
  return out + haystack.slice(cursor);
128
160
  }
129
161
 
130
- var _REWRITE_TABLE = (function () {
162
+ // Rewrite table for resolveTables(), derived from the static frozen
163
+ // frameworkSchema.LOCAL_TO_EXTERNAL mapping — never from operator/request
164
+ // input. The external names are PREFIX-AWARE: resolved through
165
+ // frameworkSchema.tableName(local) so a configured framework-table prefix
166
+ // (set config-time via db.init({tablePrefix})) is honored in cluster-mode
167
+ // DML, matching the prefix the DDL builders created the tables under.
168
+ // Order longest-first so prefix matches don't collide (audit_log before
169
+ // audit). Entries that are identity under the CURRENT prefix are filtered
170
+ // so the loop body has no no-op iterations — under the default prefix this
171
+ // is byte-identical to the old local→external map (the already-prefixed
172
+ // `_blamejs_*` names map to themselves and drop out); under a custom prefix
173
+ // those same names rewrite to `<prefix>*` and stay in.
174
+ function _buildRewriteTable() {
131
175
  var mapping = frameworkSchema.LOCAL_TO_EXTERNAL;
132
176
  var names = Object.keys(mapping).sort(function (a, b) {
133
177
  return b.length - a.length;
@@ -135,12 +179,28 @@ var _REWRITE_TABLE = (function () {
135
179
  var entries = [];
136
180
  for (var i = 0; i < names.length; i++) {
137
181
  var local = names[i];
138
- var external = mapping[local];
139
- if (local === external) continue;
182
+ var external = frameworkSchema.tableName(local); // prefix-aware external name
183
+ if (local === external) continue; // no-op under the current prefix
140
184
  entries.push({ local: local, external: external });
141
185
  }
142
186
  return Object.freeze(entries);
143
- })();
187
+ }
188
+
189
+ var _REWRITE_TABLE = null;
190
+ var _rewriteTablePrefix = null;
191
+
192
+ // The rewrite table for the current framework-table prefix, rebuilt if the
193
+ // prefix changed since the last build. db.init({tablePrefix}) sets the prefix
194
+ // once at boot (config-time), so the rebuild fires at most once; the prefix
195
+ // read is a cheap module-var getter, not request input.
196
+ function _rewriteTable() {
197
+ var prefix = frameworkSchema.getTablePrefix();
198
+ if (_REWRITE_TABLE === null || prefix !== _rewriteTablePrefix) {
199
+ _REWRITE_TABLE = _buildRewriteTable();
200
+ _rewriteTablePrefix = prefix;
201
+ }
202
+ return _REWRITE_TABLE;
203
+ }
144
204
 
145
205
  // Rewrite bare table names in SQL when running in cluster mode. We only
146
206
  // touch tokens that are exactly one of the framework's known table names
@@ -173,9 +233,10 @@ var _REWRITE_TABLE = (function () {
173
233
  */
174
234
  function resolveTables(sql) {
175
235
  if (!cluster.isClusterMode()) return sql;
236
+ var rewrite = _rewriteTable();
176
237
  var translated = sql;
177
- for (var i = 0; i < _REWRITE_TABLE.length; i++) {
178
- var entry = _REWRITE_TABLE[i];
238
+ for (var i = 0; i < rewrite.length; i++) {
239
+ var entry = rewrite[i];
179
240
  translated = _replaceWordBoundaryAll(translated, entry.local, entry.external);
180
241
  }
181
242
  return translated;
@@ -196,12 +257,16 @@ function resolveTables(sql) {
196
257
  * @related b.clusterStorage.execute, b.cluster.dialect
197
258
  *
198
259
  * Translate `?` placeholders to numbered `$1`, `$2`, … form for
199
- * Postgres backends; passthrough for `"sqlite"` and `"mysql"`. The
200
- * walker skips question marks inside single-quoted string literals so
201
- * `WHERE s = '?'` is preserved verbatim. Doubled-quote escapes (`''`)
202
- * inside strings are recognized. The `execute` family calls this on
203
- * every cluster-mode dispatch; reach for it directly only when
204
- * shipping raw SQL through a non-`execute` driver path.
260
+ * Postgres backends; passthrough for `"sqlite"` and `"mysql"`. The walker
261
+ * skips a `?` inside a single-quoted string literal (`WHERE s = '?'`), a
262
+ * double-quoted or backtick-quoted identifier (`"c?l"`), and a `--` or
263
+ * block comment so only a true bind marker is renumbered. This skip set
264
+ * is a SUPERSET of `b.safeSql.countPlaceholders`'s, so the count used to
265
+ * size params and the renumbering done here can never diverge (a `?` one
266
+ * scanner counts but the other rewrites would mis-align bound values).
267
+ * Doubled-quote escapes (`''` / `""`) inside their span are recognized.
268
+ * The `execute` family calls this on every cluster-mode dispatch; reach
269
+ * for it directly only when shipping raw SQL through a non-`execute` path.
205
270
  *
206
271
  * @example
207
272
  * var b = require("@blamejs/core");
@@ -213,21 +278,41 @@ function resolveTables(sql) {
213
278
  */
214
279
  function placeholderize(sql, dialect) {
215
280
  if (dialect !== "postgres") return sql;
216
- // Walk the SQL and replace `?` with $1, $2, … but skip ones inside
217
- // single-quoted string literals.
218
281
  var out = "";
219
282
  var n = 0;
220
- var inStr = false;
221
- for (var i = 0; i < sql.length; i++) {
283
+ var i = 0;
284
+ var len = sql.length;
285
+ while (i < len) {
222
286
  var c = sql.charAt(i);
223
- if (c === "'" && !inStr) { inStr = true; out += c; continue; }
224
- if (c === "'" && inStr) {
225
- // Handle escaped '' inside string
226
- if (sql.charAt(i + 1) === "'") { out += "''"; i += 1; continue; }
227
- inStr = false; out += c; continue;
287
+ var nx = i + 1 < len ? sql.charAt(i + 1) : "";
288
+ // Quote contexts: ' string literal, " identifier, ` mysql identifier.
289
+ // A doubled quote escapes itself within the span.
290
+ if (c === "'" || c === '"' || c === "`") {
291
+ out += c;
292
+ i += 1;
293
+ while (i < len) {
294
+ var q = sql.charAt(i);
295
+ if (q === c) {
296
+ if (sql.charAt(i + 1) === c) { out += c + c; i += 2; continue; }
297
+ out += c; i += 1; break;
298
+ }
299
+ out += q; i += 1;
300
+ }
301
+ continue;
302
+ }
303
+ if (c === "-" && nx === "-") { // line comment
304
+ while (i < len && sql.charAt(i) !== "\n") { out += sql.charAt(i); i += 1; }
305
+ continue;
228
306
  }
229
- if (!inStr && c === "?") { n += 1; out += "$" + n; continue; }
307
+ if (c === "/" && nx === "*") { // block comment
308
+ out += "/*"; i += 2;
309
+ while (i < len && !(sql.charAt(i) === "*" && sql.charAt(i + 1) === "/")) { out += sql.charAt(i); i += 1; }
310
+ if (i < len) { out += "*/"; i += 2; }
311
+ continue;
312
+ }
313
+ if (c === "?") { n += 1; out += "$" + n; i += 1; continue; }
230
314
  out += c;
315
+ i += 1;
231
316
  }
232
317
  return out;
233
318
  }
@@ -299,6 +384,17 @@ async function execute(sql, params) {
299
384
  var result = await externalDb.query(translated, params, {
300
385
  backend: cluster.externalDbBackend(),
301
386
  });
387
+ // Coerce backend-native types back to the framework's canonical JS shape
388
+ // for every clusterStorage reader at once: node-postgres returns BIGINT /
389
+ // int8 as a decimal string and BYTEA as a Buffer, so a framework column
390
+ // read back from Postgres would otherwise be the wrong JS type (a counter
391
+ // compared as a string, a hash/nonce mis-typed). coerceRows only touches
392
+ // columns in the framework's type map; operator columns pass through, and
393
+ // it is idempotent (already-correct types are left alone), so a reader
394
+ // that also coerces locally is unaffected.
395
+ if (result && Array.isArray(result.rows) && result.rows.length > 0) {
396
+ result.rows = frameworkSchema.coerceRows(result.rows);
397
+ }
302
398
  return result;
303
399
  }
304
400
 
@@ -449,6 +545,7 @@ module.exports = {
449
545
  executeAll: executeAll,
450
546
  transaction: transaction,
451
547
  tableName: tableName,
548
+ dialect: dialect,
452
549
  resolveTables: resolveTables,
453
550
  placeholderize: placeholderize,
454
551
  ClusterStorageError: ClusterStorageError,