@blamejs/core 0.14.26 → 0.15.0

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 (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
@@ -63,17 +63,59 @@ var auditSign = lazyRequire(function () { return require("./audit-sign"); });
63
63
  var ExternalDbMigrateError = defineClass("ExternalDbMigrateError", { alwaysPermanent: true });
64
64
 
65
65
  // Lazy require — external-db imports back into this module via its
66
- // public `migrate` namespace; load-order would cycle without lazy.
66
+ // public `migrate` namespace; load-order would cycle without lazy. The
67
+ // same cycle (external-db -> external-db-migrate -> cluster-storage ->
68
+ // cluster -> cluster-provider-db -> external-db) means clusterStorage +
69
+ // frameworkSchema must be lazy here too, and the table-name constants
70
+ // resolved on first use rather than at module load (frameworkSchema's
71
+ // tableName export is not yet bound while this module evaluates).
67
72
  var externalDb = lazyRequire(function () { return require("./external-db"); });
73
+ var clusterStorage = lazyRequire(function () { return require("./cluster-storage"); });
74
+ var frameworkSchema = lazyRequire(function () { return require("./framework-schema"); });
75
+ var sql = lazyRequire(function () { return require("./sql"); });
76
+
77
+ // The migration runner's own bookkeeping tables, resolved through
78
+ // frameworkSchema so the configurable framework-table prefix is honored
79
+ // (these names are not in the LOCAL_TO_EXTERNAL map, so the resolve only
80
+ // swaps the leading prefix; default prefix is a no-op). b.sql quotes
81
+ // every identifier by construction in the backend's own dialect
82
+ // (double-quote on Postgres / SQLite, backtick on MySQL), with the
83
+ // placeholder form (`$N` on Postgres, `?` on SQLite / MySQL) selected by
84
+ // the resolved backend dialect — see _backendDialect / _bind below.
85
+ function _trackingTable() { return frameworkSchema().tableName("_blamejs_externaldb_migrations"); } // allow:hand-rolled-sql — single canonical logical-name reference
86
+ function _lockTable() { return frameworkSchema().tableName("_blamejs_externaldb_migrations_lock"); } // allow:hand-rolled-sql — single canonical logical-name reference
87
+ function _historyTable() { return frameworkSchema().tableName("_blamejs_schema_version_history"); } // allow:hand-rolled-sql — single canonical logical-name reference
88
+
89
+ // Resolve the SQL dialect of the backend this migration wave targets.
90
+ // The runner emits Postgres / SQLite / MySQL — they diverge on identifier
91
+ // quoting (double-quote vs backtick), the ON CONFLICT / ON DUPLICATE KEY
92
+ // upsert idiom, and placeholder syntax (`$N` vs `?`). Reading the dialect
93
+ // off the backend itself (set at b.externalDb.init) is what keeps the
94
+ // bookkeeping DDL + tracking statements valid on each. Falls back to
95
+ // "postgres" when the backend can't be resolved (uninitialized externalDb
96
+ // surfaces a clearer error upstream at _resolveBackendName); the bare
97
+ // fallback never reaches a real query.
98
+ function _backendDialect(backendName) {
99
+ var listed;
100
+ try { listed = externalDb().listBackends(); }
101
+ catch (_e) { return "postgres"; }
102
+ for (var i = 0; i < listed.length; i++) {
103
+ if (listed[i].name === backendName) {
104
+ return (listed[i].dialect || "postgres").toLowerCase();
105
+ }
106
+ }
107
+ return "postgres";
108
+ }
68
109
 
69
- var TRACKING_TABLE = "_blamejs_externaldb_migrations";
70
- var LOCK_TABLE = "_blamejs_externaldb_migrations_lock";
71
- var HISTORY_TABLE = "_blamejs_schema_version_history";
72
- // Identifiers wrapped in `"..."` per project convention so a reserved-word
73
- // or whitespace-bearing name resolves correctly.
74
- var Q_TRACKING = '"' + TRACKING_TABLE + '"';
75
- var Q_LOCK = '"' + LOCK_TABLE + '"';
76
- var Q_HISTORY = '"' + HISTORY_TABLE + '"';
110
+ // b.sql emits `?` placeholders; the externalDb driver receives SQL
111
+ // verbatim, so translate to the Postgres `$N` form on a Postgres backend
112
+ // (placeholderize is a passthrough for SQLite / MySQL, which keep `?`).
113
+ // dialect is the resolved backend dialect so the placeholder form matches
114
+ // the backend the SQL dispatches to.
115
+ function _bind(builder, dialect) {
116
+ var built = builder.toSql();
117
+ return { sql: clusterStorage().placeholderize(built.sql, dialect), params: built.params };
118
+ }
77
119
 
78
120
  // The migration tracking / history / lock tables hold framework
79
121
  // bookkeeping ("migration X ran at time T"), not region-bound personal
@@ -110,12 +152,11 @@ function _historyPayload(row) {
110
152
  // default introspect just returns the migration name list as a JSON
111
153
  // array, which is enough to detect "someone manually altered the
112
154
  // migrations table."
113
- async function _defaultSchemaIntrospect(xdb) {
114
- var res = await xdb.query(
115
- "SELECT name, appliedAt FROM " + Q_TRACKING +
116
- " ORDER BY appliedAt ASC, name ASC",
117
- []
118
- );
155
+ async function _defaultSchemaIntrospect(xdb, dialect) {
156
+ var q = _bind(sql().select(_trackingTable(), { dialect: dialect })
157
+ .columns(["name", "appliedAt"])
158
+ .orderBy("appliedAt", "asc").orderBy("name", "asc"), dialect);
159
+ var res = await xdb.query(q.sql, q.params);
119
160
  var rows = (res && res.rows) || [];
120
161
  return sha3Hash(Buffer.from(canonicalJson.stringify(rows), "utf8"));
121
162
  }
@@ -149,75 +190,73 @@ function _lockHolderId() {
149
190
  (require("node:os").hostname() || "unknown") + "@" + _BOOT_TOKEN;
150
191
  }
151
192
 
152
- async function _ensureTrackingTable(xdb) {
193
+ async function _ensureTrackingTable(xdb, dialect) {
153
194
  // Tracking table holds the migration history. ISO-8601 timestamp
154
- // strings (TEXT) keep the framework's tracking table portable across
155
- // Postgres/SQLite without dialect-specific type juggling — operators
156
- // who want strict TIMESTAMPTZ for their own ad-hoc queries against
157
- // the table ALTER it post-creation.
158
- await xdb.query(
159
- "CREATE TABLE IF NOT EXISTS " + Q_TRACKING + " (" +
160
- " name TEXT PRIMARY KEY," +
161
- " description TEXT," +
162
- " appliedAt TEXT NOT NULL" +
163
- ")",
164
- []
165
- );
195
+ // strings keep the framework's tracking table portable across
196
+ // Postgres/SQLite/MySQL without dialect-specific type juggling —
197
+ // operators who want strict TIMESTAMPTZ for their own ad-hoc queries
198
+ // against the table ALTER it post-creation. The `name` PK is a bounded
199
+ // VARCHAR, not TEXT: MySQL refuses an unbounded TEXT/BLOB in a key
200
+ // (ER 1170), and a migration filename is length-capped at FILE_NAME_MAX
201
+ // so 255 covers every valid value. Postgres / SQLite treat VARCHAR(255)
202
+ // identically to TEXT for storage.
203
+ await xdb.query(sql().createTable(_trackingTable(), [
204
+ { name: "name", type: "VARCHAR(255)", primaryKey: true },
205
+ { name: "description", type: "TEXT" },
206
+ { name: "appliedAt", type: "TEXT", notNull: true },
207
+ ], { dialect: dialect }).sql, []);
166
208
  }
167
209
 
168
- async function _ensureHistoryTable(xdb) {
210
+ async function _ensureHistoryTable(xdb, dialect) {
169
211
  // Schema-version history table: append-only record of every migrate.up
170
212
  // wave + signature over (version, ranAt, ranBy, schemaIntrospectionHash).
171
213
  // Signature uses ML-DSA-87 / SLH-DSA-SHAKE-256f via b.auditSign — an
172
214
  // attacker tampering with rows after-the-fact cannot forge a matching
173
- // signature without the audit-signing private key.
174
- await xdb.query(
175
- "CREATE TABLE IF NOT EXISTS " + Q_HISTORY + " (" +
176
- " version TEXT NOT NULL," +
177
- " ranAt TEXT NOT NULL," +
178
- " ranBy TEXT NOT NULL," +
179
- " schemaIntrospectionHash TEXT NOT NULL," +
180
- " signature TEXT," +
181
- " publicKeyFingerprint TEXT," +
182
- " PRIMARY KEY (version, ranAt)" +
183
- ")",
184
- []
185
- );
215
+ // signature without the audit-signing private key. version + ranAt form
216
+ // the composite PK, so both are bounded VARCHARs (MySQL refuses an
217
+ // unbounded TEXT/BLOB in a key, ER 1170); version is a filename
218
+ // (length-capped) and ranAt an ISO-8601 string, both within bound.
219
+ await xdb.query(sql().createTable(_historyTable(), [
220
+ { name: "version", type: "VARCHAR(255)", notNull: true },
221
+ { name: "ranAt", type: "VARCHAR(64)", notNull: true },
222
+ { name: "ranBy", type: "TEXT", notNull: true },
223
+ { name: "schemaIntrospectionHash", type: "TEXT", notNull: true },
224
+ { name: "signature", type: "TEXT" },
225
+ { name: "publicKeyFingerprint", type: "TEXT" },
226
+ ], { dialect: dialect, primaryKey: ["version", "ranAt"] }).sql, []);
186
227
  }
187
228
 
188
- async function _writeHistoryRow(xdb, row) {
189
- await xdb.query(
190
- "INSERT INTO " + Q_HISTORY +
191
- " (version, ranAt, ranBy, schemaIntrospectionHash, signature, publicKeyFingerprint) " +
192
- " VALUES ($1, $2, $3, $4, $5, $6)",
193
- [
194
- row.version,
195
- row.ranAt,
196
- row.ranBy,
197
- row.schemaIntrospectionHash,
198
- row.signature,
199
- row.publicKeyFingerprint,
200
- ],
201
- FRAMEWORK_METADATA_OPTS
202
- );
229
+ async function _writeHistoryRow(xdb, row, dialect) {
230
+ var q = _bind(sql().insert(_historyTable(), { dialect: dialect }).values({
231
+ version: row.version,
232
+ ranAt: row.ranAt,
233
+ ranBy: row.ranBy,
234
+ schemaIntrospectionHash: row.schemaIntrospectionHash,
235
+ signature: row.signature,
236
+ publicKeyFingerprint: row.publicKeyFingerprint,
237
+ }), dialect);
238
+ await xdb.query(q.sql, q.params, FRAMEWORK_METADATA_OPTS);
203
239
  }
204
240
 
205
- async function _ensureLockTable(xdb) {
206
- await xdb.query(
207
- "CREATE TABLE IF NOT EXISTS " + Q_LOCK + " (" +
208
- " scope TEXT PRIMARY KEY," +
209
- " lockedAt INTEGER NOT NULL," +
210
- " lockedBy TEXT NOT NULL," +
211
- " CHECK (scope = 'lock')" +
212
- ")",
213
- []
214
- );
241
+ async function _ensureLockTable(xdb, dialect) {
242
+ // The scope CHECK is a static operator-controlled literal, carried as
243
+ // the last column's verbatim constraint (b.sql guards it via
244
+ // allowLiterals). lockedAt holds a ms-epoch value, so the framework INT
245
+ // type (BIGINT on Postgres/MySQL) is required — a 32-bit INTEGER
246
+ // overflows. The scope PK is a bounded VARCHAR (only ever 'lock'): MySQL
247
+ // refuses an unbounded TEXT/BLOB in a key (ER 1170).
248
+ await xdb.query(sql().createTable(_lockTable(), [
249
+ { name: "scope", type: "VARCHAR(64)", primaryKey: true },
250
+ { name: "lockedAt", type: "INTEGER", notNull: true },
251
+ { name: "lockedBy", type: "TEXT", notNull: true,
252
+ constraints: ", CHECK (scope = 'lock')" }, // allow:hand-rolled-sql — static DDL CHECK literal
253
+ ], { dialect: dialect }).sql, []);
215
254
  }
216
255
 
217
256
  // ---- Lock acquire / release ----
218
257
 
219
- async function _acquireLock(xdb, opts) {
220
- await _ensureLockTable(xdb);
258
+ async function _acquireLock(xdb, opts, dialect) {
259
+ await _ensureLockTable(xdb, dialect);
221
260
  var holder = _lockHolderId();
222
261
  var nowMs = Date.now();
223
262
  // See migrations.acquireLock for the same fix — Infinity was
@@ -229,27 +268,67 @@ async function _acquireLock(xdb, opts) {
229
268
  ExternalDbMigrateError, "externalDb-migrate/bad-opt");
230
269
  if (opts.staleAfterMs !== undefined) staleAfterMs = opts.staleAfterMs;
231
270
  }
271
+ // Conflict-safe lock acquire. The INSERT runs inside
272
+ // externalDb.transaction(_acquireLock); on Postgres a plain INSERT that
273
+ // hits the PRIMARY KEY conflict raises SQLSTATE 23505 which ABORTS the
274
+ // surrounding transaction (every later statement then fails with 25P02,
275
+ // "current transaction is aborted"), so the holder-naming SELECT could
276
+ // not run and the operator got a raw aborted-transaction error instead of
277
+ // the documented "migration lock is held by <holder>" message. Emitting
278
+ // `INSERT ... ON CONFLICT (scope) DO NOTHING` (Postgres/SQLite) /
279
+ // `INSERT ... ON DUPLICATE KEY UPDATE scope=scope` (MySQL — a no-op)
280
+ // turns the conflict into a 0-row result rather than a transaction-
281
+ // aborting error, so the inspect SELECT below runs cleanly and names the
282
+ // holder. rowCount === 1 means we won the lock; 0 means it is held.
283
+ function _insertLock() {
284
+ return _bind(sql().upsert(_lockTable(), { dialect: dialect })
285
+ .values({ scope: "lock", lockedAt: nowMs, lockedBy: holder })
286
+ .onConflict(["scope"]).doNothing(), dialect);
287
+ }
288
+ var insRes;
232
289
  try {
233
- await xdb.query(
234
- "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
235
- [nowMs, holder], FRAMEWORK_METADATA_OPTS
236
- );
290
+ var ins = _insertLock();
291
+ insRes = await xdb.query(ins.sql, ins.params, FRAMEWORK_METADATA_OPTS);
292
+ } catch (e0) {
293
+ // A genuine driver/connection fault (not a conflict — the conflict is now
294
+ // a 0-row no-op, never a throw). Surface as lock-busy.
295
+ throw _err("externaldb-migrate/lock-busy",
296
+ "could not acquire migration lock: " + ((e0 && e0.message) || String(e0)));
297
+ }
298
+ if (insRes && insRes.rowCount >= 1) {
237
299
  return { holder: holder, takeoverFrom: null, takeoverAgeMs: 0 };
238
- } catch (_e) {
239
- // PRIMARY KEY conflict → existing lock. Inspect it.
240
- var existingRes = await xdb.query(
241
- "SELECT lockedAt, lockedBy FROM " + Q_LOCK + " WHERE scope = 'lock'",
242
- []
243
- );
300
+ }
301
+ {
302
+ // 0 rows inserted → the lock IS held. Inspect it to name the holder. The
303
+ // conflict was a clean no-op (DO NOTHING), so the transaction is NOT
304
+ // aborted and this SELECT runs.
305
+ var selExisting = _bind(sql().select(_lockTable(), { dialect: dialect })
306
+ .columns(["lockedAt", "lockedBy"]).where("scope", "lock"), dialect);
307
+ var existingRes;
308
+ try {
309
+ existingRes = await xdb.query(selExisting.sql, selExisting.params);
310
+ } catch (_inspectErr) {
311
+ throw _err("externaldb-migrate/lock-held",
312
+ "migration lock is held — another process is running migrations " +
313
+ "(the lock row could not be inspected). Wait for it to finish, or " +
314
+ "pass staleAfterMs to force-replace stale locks.");
315
+ }
244
316
  var existing = existingRes && existingRes.rows && existingRes.rows[0];
245
317
  if (!existing) {
318
+ // Lock row vanished between the no-op insert and the inspect (the
319
+ // holder released concurrently). Retry the acquire once.
246
320
  try {
247
- await xdb.query(
248
- "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
249
- [nowMs, holder], FRAMEWORK_METADATA_OPTS
250
- );
251
- return { holder: holder, takeoverFrom: null, takeoverAgeMs: 0 };
321
+ var insRetry = _insertLock();
322
+ var retryRes = await xdb.query(insRetry.sql, insRetry.params, FRAMEWORK_METADATA_OPTS);
323
+ if (retryRes && retryRes.rowCount >= 1) {
324
+ return { holder: holder, takeoverFrom: null, takeoverAgeMs: 0 };
325
+ }
326
+ throw _err("externaldb-migrate/lock-held",
327
+ "migration lock is held — another process re-acquired it during " +
328
+ "the acquire race. Wait for it to finish, or pass staleAfterMs to " +
329
+ "force-replace stale locks.");
252
330
  } catch (e2) {
331
+ if (e2 && e2.isExternalDbMigrateError) throw e2;
253
332
  throw _err("externaldb-migrate/lock-busy",
254
333
  "could not acquire migration lock: " + ((e2 && e2.message) || String(e2)));
255
334
  }
@@ -259,14 +338,19 @@ async function _acquireLock(xdb, opts) {
259
338
  // Force-replace the stale lock atomically. Stale-takeover is a
260
339
  // SOC2 evidence event — caller emits an audit row.
261
340
  var prevHolder = existing.lockedby || existing.lockedBy;
262
- await xdb.query(
263
- "DELETE FROM " + Q_LOCK + " WHERE scope = 'lock' AND lockedAt = $1",
264
- [Number(existing.lockedat || existing.lockedAt)], FRAMEWORK_METADATA_OPTS
265
- );
266
- await xdb.query(
267
- "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
268
- [nowMs, holder], FRAMEWORK_METADATA_OPTS
269
- );
341
+ var delStale = _bind(sql().delete(_lockTable(), { dialect: dialect })
342
+ .where("scope", "lock")
343
+ .where("lockedAt", Number(existing.lockedat || existing.lockedAt)), dialect);
344
+ await xdb.query(delStale.sql, delStale.params, FRAMEWORK_METADATA_OPTS);
345
+ var insTakeover = _insertLock();
346
+ var takeoverRes = await xdb.query(insTakeover.sql, insTakeover.params, FRAMEWORK_METADATA_OPTS);
347
+ if (!takeoverRes || takeoverRes.rowCount < 1) {
348
+ // Another process slipped a fresh lock in between our DELETE and
349
+ // INSERT (the conflict is a DO NOTHING no-op, so 0 rows = lost race).
350
+ throw _err("externaldb-migrate/lock-held",
351
+ "migration lock was re-acquired by another process during the " +
352
+ "stale-lock takeover. Wait for it to finish, or retry.");
353
+ }
270
354
  return { holder: holder, takeoverFrom: prevHolder, takeoverAgeMs: ageMs };
271
355
  }
272
356
  throw _err("externaldb-migrate/lock-held",
@@ -276,12 +360,11 @@ async function _acquireLock(xdb, opts) {
276
360
  }
277
361
  }
278
362
 
279
- async function _releaseLock(xdb, holder) {
363
+ async function _releaseLock(xdb, holder, dialect) {
280
364
  try {
281
- await xdb.query(
282
- "DELETE FROM " + Q_LOCK + " WHERE scope = 'lock' AND lockedBy = $1",
283
- [holder], FRAMEWORK_METADATA_OPTS
284
- );
365
+ var del = _bind(sql().delete(_lockTable(), { dialect: dialect })
366
+ .where("scope", "lock").where("lockedBy", holder), dialect);
367
+ await xdb.query(del.sql, del.params, FRAMEWORK_METADATA_OPTS);
285
368
  } catch (_e) {
286
369
  // best-effort release; operator can DELETE manually.
287
370
  }
@@ -381,13 +464,13 @@ function create(opts) {
381
464
 
382
465
  async function status() {
383
466
  var backendName = _resolveBackendName(opts);
467
+ var dialect = _backendDialect(backendName);
384
468
  return await externalDb().transaction(async function (xdb) {
385
- await _ensureTrackingTable(xdb);
386
- var res = await xdb.query(
387
- "SELECT name, description, appliedAt FROM " + Q_TRACKING +
388
- " ORDER BY appliedAt ASC, name ASC",
389
- []
390
- );
469
+ await _ensureTrackingTable(xdb, dialect);
470
+ var q = _bind(sql().select(_trackingTable(), { dialect: dialect })
471
+ .columns(["name", "description", "appliedAt"])
472
+ .orderBy("appliedAt", "asc").orderBy("name", "asc"), dialect);
473
+ var res = await xdb.query(q.sql, q.params);
391
474
  var applied = (res && res.rows) || [];
392
475
  var appliedNames = new Set(applied.map(function (r) { return r.name; }));
393
476
  var files = _list(dir);
@@ -403,12 +486,13 @@ function create(opts) {
403
486
 
404
487
  async function up() {
405
488
  var backendName = _resolveBackendName(opts);
489
+ var dialect = _backendDialect(backendName);
406
490
  var ctx = _ctx(backendName);
407
491
 
408
492
  return await externalDb().transaction(async function (xdb) {
409
- await _ensureTrackingTable(xdb);
410
- await _ensureLockTable(xdb);
411
- await _ensureHistoryTable(xdb);
493
+ await _ensureTrackingTable(xdb, dialect);
494
+ await _ensureLockTable(xdb, dialect);
495
+ await _ensureHistoryTable(xdb, dialect);
412
496
  }, { backend: backendName }).then(async function () {
413
497
  // Acquire the lock OUTSIDE the per-migration transaction so the
414
498
  // lock survives across migration boundaries. We use a separate
@@ -416,7 +500,7 @@ function create(opts) {
416
500
  // serializes apply order, so this single-connection lock is
417
501
  // sufficient.
418
502
  var lockResult = await externalDb().transaction(async function (xdb) {
419
- return await _acquireLock(xdb, opts);
503
+ return await _acquireLock(xdb, opts, dialect);
420
504
  }, { backend: backendName });
421
505
  var lockHolder = lockResult.holder;
422
506
 
@@ -432,9 +516,9 @@ function create(opts) {
432
516
  }
433
517
 
434
518
  try {
435
- var appliedRes = await externalDb().query(
436
- "SELECT name FROM " + Q_TRACKING, [], { backend: backendName }
437
- );
519
+ var appliedQ = _bind(sql().select(_trackingTable(), { dialect: dialect })
520
+ .columns(["name"]), dialect);
521
+ var appliedRes = await externalDb().query(appliedQ.sql, appliedQ.params, { backend: backendName });
438
522
  var appliedSet = new Set(((appliedRes && appliedRes.rows) || []).map(function (r) { return r.name; }));
439
523
  var files = _list(dir);
440
524
  var applied = [];
@@ -449,12 +533,9 @@ function create(opts) {
449
533
  await externalDb().transaction(async function (xdb) {
450
534
  await mod.up(xdb, ctx);
451
535
  var ranAt = new Date().toISOString();
452
- await xdb.query(
453
- "INSERT INTO " + Q_TRACKING +
454
- " (name, description, appliedAt) VALUES ($1, $2, $3)",
455
- [file, mod.description || "", ranAt],
456
- FRAMEWORK_METADATA_OPTS
457
- );
536
+ var insTrack = _bind(sql().insert(_trackingTable(), { dialect: dialect })
537
+ .values({ name: file, description: mod.description || "", appliedAt: ranAt }), dialect);
538
+ await xdb.query(insTrack.sql, insTrack.params, FRAMEWORK_METADATA_OPTS);
458
539
  // Schema-version history with signature. Sign post-INSERT
459
540
  // so the introspection hash reflects the row that just
460
541
  // landed. Sign-failure is non-fatal for the migration but
@@ -463,7 +544,7 @@ function create(opts) {
463
544
  version: file,
464
545
  ranAt: ranAt,
465
546
  ranBy: ranBy,
466
- schemaIntrospectionHash: await schemaIntrospect(xdb),
547
+ schemaIntrospectionHash: await schemaIntrospect(xdb, dialect),
467
548
  signature: null,
468
549
  publicKeyFingerprint: null,
469
550
  };
@@ -479,7 +560,7 @@ function create(opts) {
479
560
  (sigErr && sigErr.message) || String(sigErr));
480
561
  }
481
562
  }
482
- await _writeHistoryRow(xdb, historyRow);
563
+ await _writeHistoryRow(xdb, historyRow, dialect);
483
564
  _emit(audit, "migrations.history.appended", "success", {
484
565
  migration: file,
485
566
  schemaIntrospectionHash: historyRow.schemaIntrospectionHash,
@@ -502,7 +583,7 @@ function create(opts) {
502
583
  } finally {
503
584
  try {
504
585
  await externalDb().transaction(async function (xdb) {
505
- await _releaseLock(xdb, lockHolder);
586
+ await _releaseLock(xdb, lockHolder, dialect);
506
587
  }, { backend: backendName });
507
588
  _emit(audit, "externaldb.migrate.lock.released", "success",
508
589
  { holder: lockHolder, backend: backendName }, null);
@@ -519,15 +600,16 @@ function create(opts) {
519
600
  var steps = (typeof downOpts.steps === "number" && downOpts.steps > 0)
520
601
  ? Math.floor(downOpts.steps) : 1;
521
602
  var backendName = _resolveBackendName(opts);
603
+ var dialect = _backendDialect(backendName);
522
604
  var ctx = _ctx(backendName);
523
605
 
524
606
  await externalDb().transaction(async function (xdb) {
525
- await _ensureTrackingTable(xdb);
526
- await _ensureLockTable(xdb);
607
+ await _ensureTrackingTable(xdb, dialect);
608
+ await _ensureLockTable(xdb, dialect);
527
609
  }, { backend: backendName });
528
610
 
529
611
  var lockResultDown = await externalDb().transaction(async function (xdb) {
530
- return await _acquireLock(xdb, opts);
612
+ return await _acquireLock(xdb, opts, dialect);
531
613
  }, { backend: backendName });
532
614
  var lockHolder = lockResultDown.holder;
533
615
 
@@ -540,10 +622,10 @@ function create(opts) {
540
622
  }
541
623
 
542
624
  try {
543
- var appliedRes = await externalDb().query(
544
- "SELECT name FROM " + Q_TRACKING + " ORDER BY appliedAt DESC, name DESC LIMIT $1",
545
- [steps], { backend: backendName }
546
- );
625
+ var downQ = _bind(sql().select(_trackingTable(), { dialect: dialect })
626
+ .columns(["name"])
627
+ .orderBy("appliedAt", "desc").orderBy("name", "desc").limit(steps), dialect);
628
+ var appliedRes = await externalDb().query(downQ.sql, downQ.params, { backend: backendName });
547
629
  var rows = (appliedRes && appliedRes.rows) || [];
548
630
  var reverted = [];
549
631
  for (var i = 0; i < rows.length; i++) {
@@ -557,10 +639,9 @@ function create(opts) {
557
639
  try {
558
640
  await externalDb().transaction(async function (xdb) {
559
641
  await mod.down(xdb, ctx);
560
- await xdb.query(
561
- "DELETE FROM " + Q_TRACKING + " WHERE name = $1",
562
- [file]
563
- );
642
+ var delTrack = _bind(sql().delete(_trackingTable(), { dialect: dialect })
643
+ .where("name", file), dialect);
644
+ await xdb.query(delTrack.sql, delTrack.params);
564
645
  }, { backend: backendName });
565
646
  _emit(audit, "externaldb.migrate.down", "success",
566
647
  { migration: file, durationMs: Date.now() - t0, backend: backendName }, null);
@@ -577,7 +658,7 @@ function create(opts) {
577
658
  } finally {
578
659
  try {
579
660
  await externalDb().transaction(async function (xdb) {
580
- await _releaseLock(xdb, lockHolder);
661
+ await _releaseLock(xdb, lockHolder, dialect);
581
662
  }, { backend: backendName });
582
663
  _emit(audit, "externaldb.migrate.lock.released", "success",
583
664
  { holder: lockHolder, backend: backendName }, null);
@@ -600,13 +681,13 @@ function create(opts) {
600
681
  async function history(historyOpts) {
601
682
  historyOpts = historyOpts || {};
602
683
  var backendName = _resolveBackendName(opts);
684
+ var dialect = _backendDialect(backendName);
603
685
  return await externalDb().transaction(async function (xdb) {
604
- await _ensureHistoryTable(xdb);
605
- var res = await xdb.query(
606
- "SELECT version, ranAt, ranBy, schemaIntrospectionHash, signature, publicKeyFingerprint " +
607
- "FROM " + Q_HISTORY + " ORDER BY ranAt ASC, version ASC",
608
- []
609
- );
686
+ await _ensureHistoryTable(xdb, dialect);
687
+ var histQ = _bind(sql().select(_historyTable(), { dialect: dialect })
688
+ .columns(["version", "ranAt", "ranBy", "schemaIntrospectionHash", "signature", "publicKeyFingerprint"])
689
+ .orderBy("ranAt", "asc").orderBy("version", "asc"), dialect);
690
+ var res = await xdb.query(histQ.sql, histQ.params);
610
691
  var out = [];
611
692
  var rows = (res && res.rows) || [];
612
693
  for (var i = 0; i < rows.length; i++) {
@@ -665,7 +746,16 @@ function create(opts) {
665
746
  module.exports = {
666
747
  create: create,
667
748
  ExternalDbMigrateError: ExternalDbMigrateError,
668
- TRACKING_TABLE: TRACKING_TABLE,
669
- HISTORY_TABLE: HISTORY_TABLE,
670
749
  HISTORY_SIGNATURE_FORMAT: HISTORY_SIGNATURE_FORMAT,
671
750
  };
751
+
752
+ // The resolved table names are exposed as lazy getters: frameworkSchema's
753
+ // tableName export is not bound while this module evaluates (the
754
+ // external-db require cycle), so resolving at access time gives the
755
+ // configurable-prefix-aware concrete name without a load-order trap.
756
+ Object.defineProperty(module.exports, "TRACKING_TABLE", {
757
+ enumerable: true, get: function () { return _trackingTable(); },
758
+ });
759
+ Object.defineProperty(module.exports, "HISTORY_TABLE", {
760
+ enumerable: true, get: function () { return _historyTable(); },
761
+ });