velocious 1.0.595 → 1.0.597
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/README.md +21 -0
- package/build/background-jobs/pooled-runner-broker-identity.js +22 -4
- package/build/background-jobs/store.js +383 -9
- package/build/background-jobs/types.js +1 -0
- package/build/cli/commands/db/tenants/migrations/pending.js +45 -0
- package/build/configuration-types.js +3 -0
- package/build/configuration.js +5 -2
- package/build/database/drivers/base.js +135 -1
- package/build/database/drivers/mysql/index.js +92 -0
- package/build/database/drivers/sqlite/base.js +3 -3
- package/build/database/tenants/migration-pending-inspector.js +77 -0
- package/build/environment-handlers/base.js +8 -0
- package/build/environment-handlers/node.js +14 -0
- package/build/mailer/backends/resend-smtp.js +121 -0
- package/build/mailer/base.js +52 -22
- package/build/mailer/delivery-operation-store.js +152 -0
- package/build/mailer/delivery-operation.js +205 -0
- package/build/mailer/delivery.js +9 -4
- package/build/mailer/index.js +5 -1
- package/build/mailer.js +6 -1
- package/build/src/background-jobs/pooled-runner-broker-identity.d.ts +10 -1
- package/build/src/background-jobs/pooled-runner-broker-identity.d.ts.map +1 -1
- package/build/src/background-jobs/pooled-runner-broker-identity.js +23 -5
- package/build/src/background-jobs/store.d.ts +154 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +350 -10
- package/build/src/background-jobs/types.d.ts +5 -0
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +2 -1
- package/build/src/cli/commands/db/tenants/migrations/pending.d.ts +15 -0
- package/build/src/cli/commands/db/tenants/migrations/pending.d.ts.map +1 -0
- package/build/src/cli/commands/db/tenants/migrations/pending.js +40 -0
- package/build/src/configuration-types.d.ts +20 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +4 -1
- package/build/src/configuration.d.ts +2 -2
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +6 -3
- package/build/src/database/drivers/base.d.ts +21 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +131 -2
- package/build/src/database/drivers/mysql/index.d.ts +22 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +91 -1
- package/build/src/database/drivers/sqlite/base.js +4 -4
- package/build/src/database/tenants/migration-pending-inspector.d.ts +38 -0
- package/build/src/database/tenants/migration-pending-inspector.d.ts.map +1 -0
- package/build/src/database/tenants/migration-pending-inspector.js +70 -0
- package/build/src/environment-handlers/base.d.ts +10 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +8 -1
- package/build/src/environment-handlers/node.d.ts +11 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +14 -1
- package/build/src/mailer/backends/resend-smtp.d.ts +43 -0
- package/build/src/mailer/backends/resend-smtp.d.ts.map +1 -0
- package/build/src/mailer/backends/resend-smtp.js +106 -0
- package/build/src/mailer/base.d.ts +9 -2
- package/build/src/mailer/base.d.ts.map +1 -1
- package/build/src/mailer/base.js +41 -19
- package/build/src/mailer/delivery-operation-store.d.ts +52 -0
- package/build/src/mailer/delivery-operation-store.d.ts.map +1 -0
- package/build/src/mailer/delivery-operation-store.js +130 -0
- package/build/src/mailer/delivery-operation.d.ts +73 -0
- package/build/src/mailer/delivery-operation.d.ts.map +1 -0
- package/build/src/mailer/delivery-operation.js +185 -0
- package/build/src/mailer/delivery.d.ts +4 -2
- package/build/src/mailer/delivery.d.ts.map +1 -1
- package/build/src/mailer/delivery.js +9 -5
- package/build/src/mailer/index.d.ts +24 -1
- package/build/src/mailer/index.d.ts.map +1 -1
- package/build/src/mailer/index.js +6 -2
- package/build/src/mailer.d.ts +11 -13
- package/build/src/mailer.d.ts.map +1 -1
- package/build/src/mailer.js +7 -2
- package/build/src/sync/local-mutation-log.d.ts +5 -5
- package/build/src/sync/local-mutation-log.d.ts.map +1 -1
- package/build/src/sync/local-mutation-log.js +34 -10
- package/build/src/sync/stable-json.d.ts +1 -8
- package/build/src/sync/stable-json.d.ts.map +1 -1
- package/build/src/sync/stable-json.js +2 -27
- package/build/src/sync/sync-envelope-replay-service.d.ts +3 -2
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +12 -8
- package/build/src/utils/stable-json.d.ts +7 -0
- package/build/src/utils/stable-json.d.ts.map +1 -0
- package/build/src/utils/stable-json.js +25 -0
- package/build/sync/local-mutation-log.js +37 -6
- package/build/sync/stable-json.js +1 -28
- package/build/sync/sync-envelope-replay-service.js +11 -7
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/utils/stable-json.js +26 -0
- package/package.json +1 -1
- package/src/background-jobs/pooled-runner-broker-identity.js +22 -4
- package/src/background-jobs/store.js +383 -9
- package/src/background-jobs/types.js +1 -0
- package/src/cli/commands/db/tenants/migrations/pending.js +45 -0
- package/src/configuration-types.js +3 -0
- package/src/configuration.js +5 -2
- package/src/database/drivers/base.js +135 -1
- package/src/database/drivers/mysql/index.js +92 -0
- package/src/database/drivers/sqlite/base.js +3 -3
- package/src/database/tenants/migration-pending-inspector.js +77 -0
- package/src/environment-handlers/base.js +8 -0
- package/src/environment-handlers/node.js +14 -0
- package/src/mailer/backends/resend-smtp.js +121 -0
- package/src/mailer/base.js +52 -22
- package/src/mailer/delivery-operation-store.js +152 -0
- package/src/mailer/delivery-operation.js +205 -0
- package/src/mailer/delivery.js +9 -4
- package/src/mailer/index.js +5 -1
- package/src/mailer.js +6 -1
- package/src/sync/local-mutation-log.js +37 -6
- package/src/sync/stable-json.js +1 -28
- package/src/sync/sync-envelope-replay-service.js +11 -7
- package/src/utils/stable-json.js +26 -0
|
@@ -150,6 +150,65 @@ const SQL_PREVIEW_SCAN_LIMIT = 4096
|
|
|
150
150
|
/** Maximum characters inspected when deciding whether a statement invalidates schema metadata. */
|
|
151
151
|
const SCHEMA_INVALIDATION_SCAN_LIMIT = 8192
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Builds a non-reversible, stable SQL fingerprint without retaining SQL text. Literal spelling is
|
|
155
|
+
* normalized first so the same statement shape produces the same fingerprint across values.
|
|
156
|
+
* @param {string} sql - SQL to fingerprint.
|
|
157
|
+
* @returns {string} - Bounded fingerprint.
|
|
158
|
+
*/
|
|
159
|
+
function sqlFingerprint(sql) {
|
|
160
|
+
let fingerprintInput = ""
|
|
161
|
+
|
|
162
|
+
for (let index = 0; index < sql.length;) {
|
|
163
|
+
const character = sql[index]
|
|
164
|
+
const nextCharacter = sql[index + 1]
|
|
165
|
+
|
|
166
|
+
if (character == "'" || character == '"') {
|
|
167
|
+
const quote = character
|
|
168
|
+
fingerprintInput += "?"
|
|
169
|
+
index++
|
|
170
|
+
|
|
171
|
+
while (index < sql.length) {
|
|
172
|
+
if (sql[index] == "\\") {
|
|
173
|
+
index += 2
|
|
174
|
+
} else if (sql[index] == quote && sql[index + 1] == quote) {
|
|
175
|
+
index += 2
|
|
176
|
+
} else if (sql[index] == quote) {
|
|
177
|
+
index++
|
|
178
|
+
break
|
|
179
|
+
} else {
|
|
180
|
+
index++
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else if (character == "/" && nextCharacter == "*") {
|
|
184
|
+
const commentEnd = sql.indexOf("*/", index + 2)
|
|
185
|
+
fingerprintInput += " "
|
|
186
|
+
index = commentEnd == -1 ? sql.length : commentEnd + 2
|
|
187
|
+
} else if ((character == "-" && nextCharacter == "-") || character == "#") {
|
|
188
|
+
const lineEnd = sql.indexOf("\n", index + 1)
|
|
189
|
+
fingerprintInput += " "
|
|
190
|
+
index = lineEnd == -1 ? sql.length : lineEnd + 1
|
|
191
|
+
} else {
|
|
192
|
+
fingerprintInput += character
|
|
193
|
+
index++
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const normalized = fingerprintInput
|
|
198
|
+
.replace(/\b(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)\b/gi, "?")
|
|
199
|
+
.replace(/\s+/g, " ")
|
|
200
|
+
.trim()
|
|
201
|
+
.toLowerCase()
|
|
202
|
+
let hash = 0xcbf29ce484222325n
|
|
203
|
+
|
|
204
|
+
for (let index = 0; index < normalized.length; index++) {
|
|
205
|
+
hash ^= BigInt(normalized.charCodeAt(index))
|
|
206
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return `fnv1a64:${hash.toString(16).padStart(16, "0")}`
|
|
210
|
+
}
|
|
211
|
+
|
|
153
212
|
/**
|
|
154
213
|
* Marks a callback failure that happened after the owning transaction was durably committed.
|
|
155
214
|
* The public transaction boundary unwraps it before deadlock classification.
|
|
@@ -215,6 +274,8 @@ export default class VelociousDatabaseDriversBase {
|
|
|
215
274
|
* Active query.
|
|
216
275
|
* @type {ActiveQueryState | null} */
|
|
217
276
|
_activeQuery = null
|
|
277
|
+
/** @type {WeakMap<Error, {sqlFingerprint: string, sqlOperation: string}>} */
|
|
278
|
+
_failedQueryDiagnostics = new WeakMap()
|
|
218
279
|
/** @type {Map<string, number>} */
|
|
219
280
|
_heldAdvisoryLocks = new Map()
|
|
220
281
|
/**
|
|
@@ -1248,10 +1309,13 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1248
1309
|
return await this._runTransactionAttempt(callback, options)
|
|
1249
1310
|
} catch (error) {
|
|
1250
1311
|
if (error instanceof VelociousDatabaseAfterCommitCallbackError) throw error.callbackError
|
|
1312
|
+
if (!(error instanceof Error)) throw error
|
|
1251
1313
|
|
|
1252
|
-
const retryInfo =
|
|
1314
|
+
const retryInfo = this.retryableDatabaseError(error)
|
|
1253
1315
|
|
|
1254
1316
|
if (retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0) {
|
|
1317
|
+
this._reportDeadlockRetryDiagnostic({attempt, error, maxAttempts})
|
|
1318
|
+
|
|
1255
1319
|
// An explicitly-configured base wins so the tuning knob is effective even on drivers
|
|
1256
1320
|
// whose classifier supplies its own `waitMs` (MySQL/MariaDB return a fixed 50ms for
|
|
1257
1321
|
// deadlocks); otherwise honor that classifier hint, then fall back to 50ms.
|
|
@@ -1286,6 +1350,71 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1286
1350
|
await wait(ms)
|
|
1287
1351
|
}
|
|
1288
1352
|
|
|
1353
|
+
/**
|
|
1354
|
+
* Starts best-effort deadlock diagnostics without joining the retry control flow. Subclasses may
|
|
1355
|
+
* add bounded driver-specific context; capture and event-listener failures cannot affect retry.
|
|
1356
|
+
* @param {{attempt: number, error: Error, maxAttempts: number}} args - Retry metadata.
|
|
1357
|
+
* @returns {void}
|
|
1358
|
+
*/
|
|
1359
|
+
_reportDeadlockRetryDiagnostic({attempt, error, maxAttempts}) {
|
|
1360
|
+
const queryDiagnostic = this._failedQueryDiagnostics.get(error)
|
|
1361
|
+
|
|
1362
|
+
void this._deadlockDiagnosticContext()
|
|
1363
|
+
.then((driverContext) => {
|
|
1364
|
+
const context = {
|
|
1365
|
+
attempt,
|
|
1366
|
+
driverType: this.getType(),
|
|
1367
|
+
maxAttempts,
|
|
1368
|
+
stage: "database-deadlock-retry",
|
|
1369
|
+
willRetry: true,
|
|
1370
|
+
...queryDiagnostic,
|
|
1371
|
+
...driverContext
|
|
1372
|
+
}
|
|
1373
|
+
const payload = {
|
|
1374
|
+
context,
|
|
1375
|
+
error: new Error("Database transaction deadlock will be retried")
|
|
1376
|
+
}
|
|
1377
|
+
const errorEvents = this.configuration.getErrorEvents()
|
|
1378
|
+
|
|
1379
|
+
try {
|
|
1380
|
+
errorEvents.emit("database-deadlock-retry", payload)
|
|
1381
|
+
} catch (eventError) {
|
|
1382
|
+
this.logger.warn("Database deadlock retry diagnostic listener failed", {error: eventError})
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
try {
|
|
1386
|
+
errorEvents.emit("all-error", {...payload, errorType: "database-deadlock-retry"})
|
|
1387
|
+
} catch (eventError) {
|
|
1388
|
+
this.logger.warn("Database deadlock retry all-error listener failed", {error: eventError})
|
|
1389
|
+
}
|
|
1390
|
+
})
|
|
1391
|
+
.catch((diagnosticError) => {
|
|
1392
|
+
const normalizedError = diagnosticError instanceof Error
|
|
1393
|
+
? diagnosticError
|
|
1394
|
+
: new Error("Database deadlock retry diagnostic failed", {cause: diagnosticError})
|
|
1395
|
+
const payload = {
|
|
1396
|
+
context: {stage: "database-deadlock-retry-diagnostic"},
|
|
1397
|
+
error: normalizedError
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
try {
|
|
1401
|
+
const errorEvents = this.configuration.getErrorEvents()
|
|
1402
|
+
errorEvents.emit("framework-error", payload)
|
|
1403
|
+
errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
|
|
1404
|
+
} catch (reportingError) {
|
|
1405
|
+
this.logger.warn("Database deadlock retry diagnostic pipeline failed", {error: normalizedError, reportingError})
|
|
1406
|
+
}
|
|
1407
|
+
})
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Builds driver-specific deadlock context. The base driver has no server diagnostic source.
|
|
1412
|
+
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Safe context fields.
|
|
1413
|
+
*/
|
|
1414
|
+
async _deadlockDiagnosticContext() {
|
|
1415
|
+
return {}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1289
1418
|
/**
|
|
1290
1419
|
* Runs a single transaction attempt: starts a transaction (or a savepoint when nested), runs
|
|
1291
1420
|
* `callback`, and commits — rolling back on error. {@link transaction} wraps this with deadlock
|
|
@@ -1587,6 +1716,11 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1587
1716
|
} catch (error) {
|
|
1588
1717
|
if (!(error instanceof Error)) throw error
|
|
1589
1718
|
|
|
1719
|
+
this._failedQueryDiagnostics.set(error, {
|
|
1720
|
+
sqlFingerprint: sqlFingerprint(sql),
|
|
1721
|
+
sqlOperation: sql.trim().split(/\s+/, 1)[0]?.toUpperCase() || "UNKNOWN"
|
|
1722
|
+
})
|
|
1723
|
+
|
|
1590
1724
|
// A deliberately-aborted query must never be silently re-run — its
|
|
1591
1725
|
// connection was destroyed on purpose, so treat it as terminal.
|
|
1592
1726
|
if (error instanceof QueryAbortedError) throw error
|
|
@@ -31,6 +31,7 @@ import Update from "./sql/update.js"
|
|
|
31
31
|
* realistic critical section) instead.
|
|
32
32
|
*/
|
|
33
33
|
const MYSQL_INDEFINITE_LOCK_TIMEOUT_SECONDS = 60 * 60 * 24 * 365
|
|
34
|
+
const INNODB_DEADLOCK_CAPTURE_TIMEOUT_MS = 250
|
|
34
35
|
|
|
35
36
|
export default class VelociousDatabaseDriversMysql extends Base{
|
|
36
37
|
/** @type {import("mysql").Pool | undefined} */
|
|
@@ -392,6 +393,97 @@ export default class VelociousDatabaseDriversMysql extends Base{
|
|
|
392
393
|
}
|
|
393
394
|
}
|
|
394
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Adds a redacted, bounded excerpt from MySQL's latest InnoDB deadlock report. Capture uses a
|
|
398
|
+
* separate short-lived connection so it cannot queue ahead of rollback or the next retry on this
|
|
399
|
+
* driver's single-connection pool.
|
|
400
|
+
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Safe diagnostic context.
|
|
401
|
+
*/
|
|
402
|
+
async _deadlockDiagnosticContext() {
|
|
403
|
+
try {
|
|
404
|
+
const status = await this._captureInnodbDeadlockStatus()
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
innodbDeadlockSummary: this._innodbDeadlockSummary(status),
|
|
408
|
+
statusCapture: "captured"
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
return {statusCapture: "failed"}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Captures SHOW ENGINE INNODB STATUS on a bounded throwaway connection.
|
|
417
|
+
* @returns {Promise<string>} - Raw server status, retained only inside the redaction path.
|
|
418
|
+
*/
|
|
419
|
+
async _captureInnodbDeadlockStatus() {
|
|
420
|
+
const poolWithConfig = /** @type {{config?: {connectionConfig?: ReturnType<typeof JSON.parse>}} | undefined} */ (this.pool)
|
|
421
|
+
const connectionConfig = poolWithConfig?.config?.connectionConfig
|
|
422
|
+
const captureConfig = connectionConfig || this.connectArgs()
|
|
423
|
+
|
|
424
|
+
return await new Promise((resolve, reject) => {
|
|
425
|
+
/** @type {import("mysql").Connection | undefined} */
|
|
426
|
+
let connection
|
|
427
|
+
let settled = false
|
|
428
|
+
/**
|
|
429
|
+
* Finishes the status capture once and destroys its temporary connection.
|
|
430
|
+
* @param {Error | undefined} error - Capture error, when present.
|
|
431
|
+
* @param {string} [status] - Captured status.
|
|
432
|
+
* @returns {void}
|
|
433
|
+
*/
|
|
434
|
+
const finish = (error, status = "") => {
|
|
435
|
+
if (settled) return
|
|
436
|
+
settled = true
|
|
437
|
+
clearTimeout(timeout)
|
|
438
|
+
if (connection) connection.destroy()
|
|
439
|
+
if (error) reject(error)
|
|
440
|
+
else resolve(status)
|
|
441
|
+
}
|
|
442
|
+
const timeout = setTimeout(() => finish(new Error("InnoDB status capture timed out")), INNODB_DEADLOCK_CAPTURE_TIMEOUT_MS)
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
connection = mysql.createConnection(captureConfig)
|
|
446
|
+
connection.on("error", (error) => finish(error))
|
|
447
|
+
connection.query("SHOW ENGINE INNODB STATUS", (error, rows) => {
|
|
448
|
+
if (error) {
|
|
449
|
+
finish(error)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const firstRow = Array.isArray(rows) ? rows[0] : undefined
|
|
454
|
+
const status = firstRow && typeof firstRow.Status == "string" ? firstRow.Status : ""
|
|
455
|
+
|
|
456
|
+
finish(undefined, status)
|
|
457
|
+
})
|
|
458
|
+
} catch (error) {
|
|
459
|
+
finish(error instanceof Error ? error : new Error("InnoDB status capture failed"))
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Extracts only fixed-format deadlock counters. The server report contains raw SQL, identifiers,
|
|
466
|
+
* and physical record data, so no source text is ever included in an application diagnostic.
|
|
467
|
+
* @param {string} status - SHOW ENGINE INNODB STATUS text.
|
|
468
|
+
* @returns {{transactions: number, victimTransaction: number | null}} - Structural deadlock summary.
|
|
469
|
+
*/
|
|
470
|
+
_innodbDeadlockSummary(status) {
|
|
471
|
+
const deadlockStart = status.indexOf("LATEST DETECTED DEADLOCK")
|
|
472
|
+
const candidate = deadlockStart >= 0 ? status.slice(deadlockStart) : status
|
|
473
|
+
let transactions = 0
|
|
474
|
+
/** @type {number | null} */
|
|
475
|
+
let victimTransaction = null
|
|
476
|
+
|
|
477
|
+
for (const line of candidate.split(/\r?\n/)) {
|
|
478
|
+
const trimmed = line.trim()
|
|
479
|
+
if (/^\*\*\* \(\d+\) TRANSACTION:$/.test(trimmed)) transactions++
|
|
480
|
+
const victimMatch = /^\*\*\* WE ROLL BACK TRANSACTION \((\d+)\)$/.exec(trimmed)
|
|
481
|
+
if (victimMatch) victimTransaction = Number(victimMatch[1])
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {transactions, victimTransaction}
|
|
485
|
+
}
|
|
486
|
+
|
|
395
487
|
/**
|
|
396
488
|
* Runs query actual.
|
|
397
489
|
* @param {string} sql - SQL string.
|
|
@@ -356,13 +356,13 @@ export default class VelociousDatabaseDriversSqliteBase extends Base {
|
|
|
356
356
|
* @returns {import("../base.js").RetryableDatabaseErrorResult} - Retry info.
|
|
357
357
|
*/
|
|
358
358
|
retryableDatabaseError(error) {
|
|
359
|
+
const databaseLocked = Boolean(error.message?.includes("database is locked"))
|
|
359
360
|
const shouldRetry = (
|
|
360
361
|
error.message?.startsWith("attempt to write a readonly database") ||
|
|
361
|
-
|
|
362
|
-
error.message?.includes("→ Caused by: Error code : database is locked")
|
|
362
|
+
databaseLocked
|
|
363
363
|
)
|
|
364
364
|
|
|
365
|
-
return {retry: shouldRetry, reconnect: false}
|
|
365
|
+
return {deadlock: databaseLocked, retry: shouldRetry, reconnect: false}
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
/**
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import MigrationsLedger from "../migrations-ledger.js"
|
|
4
|
+
import TenantIterator from "../../tenants/tenant-iterator.js"
|
|
5
|
+
import restArgsError from "../../utils/rest-args-error.js"
|
|
6
|
+
|
|
7
|
+
export default class TenantMigrationPendingInspector {
|
|
8
|
+
/**
|
|
9
|
+
* Runs constructor.
|
|
10
|
+
* @param {object} args - Options object.
|
|
11
|
+
* @param {import("../../configuration.js").default} args.configuration - Configuration instance.
|
|
12
|
+
* @param {string} args.identifier - Tenant database identifier.
|
|
13
|
+
* @param {number[]} args.migrationVersions - Applicable migration versions.
|
|
14
|
+
* @param {Array<ReturnType<typeof JSON.parse>>} args.tenants - Existing tenant descriptors.
|
|
15
|
+
*/
|
|
16
|
+
constructor({configuration, identifier, migrationVersions, tenants, ...restArgs}) {
|
|
17
|
+
restArgsError(restArgs)
|
|
18
|
+
|
|
19
|
+
this.configuration = configuration
|
|
20
|
+
this.identifier = identifier
|
|
21
|
+
this.migrationVersions = migrationVersions.map((version) => `${version}`)
|
|
22
|
+
this.tenants = tenants
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads every existing tenant ledger and reports aggregate pending state.
|
|
27
|
+
* @returns {Promise<{hasPendingMigrations: boolean, identifier: string, migrationCount: number, pendingTenantCount: number, tenantCount: number}>} - Deploy preflight result.
|
|
28
|
+
*/
|
|
29
|
+
async inspect() {
|
|
30
|
+
let pendingTenantCount = 0
|
|
31
|
+
|
|
32
|
+
for (const tenant of this.tenants) {
|
|
33
|
+
if (await this.tenantHasPendingMigrations(tenant)) pendingTenantCount++
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
hasPendingMigrations: pendingTenantCount > 0,
|
|
38
|
+
identifier: this.identifier,
|
|
39
|
+
migrationCount: this.migrationVersions.length,
|
|
40
|
+
pendingTenantCount,
|
|
41
|
+
tenantCount: this.tenants.length
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reads one tenant's existing migration ledger without preparing or changing it.
|
|
47
|
+
* @param {ReturnType<typeof JSON.parse>} tenant - Tenant descriptor.
|
|
48
|
+
* @returns {Promise<boolean>} - Whether the tenant has an applicable pending migration.
|
|
49
|
+
*/
|
|
50
|
+
async tenantHasPendingMigrations(tenant) {
|
|
51
|
+
return await this.configuration.runWithTenant(tenant, async () => {
|
|
52
|
+
const tenantLabel = TenantIterator.tenantLabel(tenant)
|
|
53
|
+
|
|
54
|
+
if (!this.configuration.isDatabaseIdentifierActive(this.identifier)) {
|
|
55
|
+
throw new Error(`Tenant database identifier ${this.identifier} is inactive for tenant: ${tenantLabel}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return await this.configuration.ensureConnections({
|
|
60
|
+
databaseIdentifiers: [this.identifier],
|
|
61
|
+
name: `Tenant migration pending preflight: ${this.identifier}`
|
|
62
|
+
}, async (dbs) => {
|
|
63
|
+
const db = dbs[this.identifier]
|
|
64
|
+
|
|
65
|
+
if (!db) throw new Error(`Tenant database identifier ${this.identifier} did not open a connection`)
|
|
66
|
+
if (!await MigrationsLedger.tableExists(db)) throw new Error(`${MigrationsLedger.tableName()} ledger does not exist`)
|
|
67
|
+
|
|
68
|
+
const appliedVersions = new Set(await MigrationsLedger.appliedVersions(db))
|
|
69
|
+
|
|
70
|
+
return this.migrationVersions.some((version) => !appliedVersions.has(version))
|
|
71
|
+
})
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`Could not read ${MigrationsLedger.tableName()} for tenant ${tenantLabel}`, {cause: error})
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -19,6 +19,14 @@ import {validateTimeZone} from "../time-zone.js"
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
export default class VelociousEnvironmentHandlerBase {
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the configured database pool type for the current runtime context.
|
|
24
|
+
* Browser and ordinary server contexts retain the application configuration.
|
|
25
|
+
* @param {{configuredPoolType: typeof import("../database/pool/base.js").default, databaseIdentifier: string}} args - Configured pool and logical database identifier.
|
|
26
|
+
* @returns {typeof import("../database/pool/base.js").default} - Pool type for this context.
|
|
27
|
+
*/
|
|
28
|
+
resolveTestSharedTransactionPoolType({configuredPoolType}) { return configuredPoolType }
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* Node test runtimes may replace a physical child connection with a broker
|
|
24
32
|
* proxy. Other environments never participate in this test-only protocol.
|
|
@@ -32,6 +32,7 @@ import path from "path"
|
|
|
32
32
|
import {AsyncLocalStorage as NodeAsyncLocalStorage} from "node:async_hooks"
|
|
33
33
|
import {timingSafeEqual} from "node:crypto"
|
|
34
34
|
import requireContext from "require-context"
|
|
35
|
+
import AsyncTrackedMultiConnectionPool from "../database/pool/async-tracked-multi-connection.js"
|
|
35
36
|
import InitializerFromRequireContext from "../database/initializer-from-require-context.js"
|
|
36
37
|
import toImportSpecifier from "../utils/to-import-specifier.js"
|
|
37
38
|
import {validateTimeZone} from "../time-zone.js"
|
|
@@ -64,6 +65,19 @@ function pathWithinAllowedPrefixes(filePath, allowedPathPrefixes) {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
export default class VelociousEnvironmentHandlerNode extends Base{
|
|
68
|
+
/**
|
|
69
|
+
* Gives concurrent shared-transaction child jobs independent proxy sessions.
|
|
70
|
+
* A configured single-connection pool shares mutable transaction state between
|
|
71
|
+
* async jobs, while the broker requires one root-transaction lease per socket.
|
|
72
|
+
* @param {{configuredPoolType: typeof import("../database/pool/base.js").default, databaseIdentifier: string}} args - Configured pool and logical database identifier.
|
|
73
|
+
* @returns {typeof import("../database/pool/base.js").default} - Pool type for this context.
|
|
74
|
+
*/
|
|
75
|
+
resolveTestSharedTransactionPoolType({configuredPoolType, databaseIdentifier}) {
|
|
76
|
+
if (!sharedTransactionBrokerConfig(databaseIdentifier)) return configuredPoolType
|
|
77
|
+
|
|
78
|
+
return AsyncTrackedMultiConnectionPool
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
/**
|
|
68
82
|
* Creates a test-only child proxy when TestRunner supplied an active broker.
|
|
69
83
|
* @param {{DriverClass: typeof import("../database/drivers/base.js").default, config: import("../configuration-types.js").DatabaseConfigurationType, configuration: import("../configuration.js").default, databaseIdentifier: string}} args - Connection details.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import SmtpMailerBackend from "./smtp.js"
|
|
4
|
+
import VelociousError from "../../velocious-error.js"
|
|
5
|
+
import {deliveryOperationFromPayload} from "../delivery-operation.js"
|
|
6
|
+
|
|
7
|
+
const PROVIDER_KIND = "resend-smtp"
|
|
8
|
+
const RETENTION_MS = 24 * 60 * 60 * 1000
|
|
9
|
+
const IDEMPOTENCY_HEADER = "Resend-Idempotency-Key"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks whether a value contains an SMTP header control character.
|
|
13
|
+
* @param {string} value - Header value.
|
|
14
|
+
* @returns {boolean} - Whether a control character is present.
|
|
15
|
+
*/
|
|
16
|
+
function containsHeaderValueControlCharacter(value) {
|
|
17
|
+
for (const character of value) {
|
|
18
|
+
const codePoint = character.charCodeAt(0)
|
|
19
|
+
|
|
20
|
+
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Keeps the provider-owned operation header out of caller payloads.
|
|
28
|
+
* @param {import("../index.js").MailerDeliveryPayload} payload - Mail payload.
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
function rejectReservedHeaderOverride(payload) {
|
|
32
|
+
if (Object.keys(payload.headers || {}).some((name) => name.toLowerCase() === IDEMPOTENCY_HEADER.toLowerCase())) {
|
|
33
|
+
throw VelociousError.safe(`Reserved mail header ${IDEMPOTENCY_HEADER} is owned by ResendSmtpMailerBackend required delivery operations.`, {
|
|
34
|
+
code: "mail-delivery-idempotency-header-reserved"
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resend SMTP transport with Resend's documented 24-hour idempotency header.
|
|
41
|
+
*/
|
|
42
|
+
export default class ResendSmtpMailerBackend extends SmtpMailerBackend {
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the SMTP sender before it becomes part of the immutable digest.
|
|
45
|
+
* @param {object} args - Preparation input.
|
|
46
|
+
* @param {import("../index.js").MailerDeliveryPayload} args.payload - Rendered payload.
|
|
47
|
+
* @returns {import("../index.js").MailerDeliveryPayload} - Provider-ready payload.
|
|
48
|
+
*/
|
|
49
|
+
prepareDeliveryOperationPayload({payload}) {
|
|
50
|
+
const from = payload.from || this.defaultFrom
|
|
51
|
+
|
|
52
|
+
if (!from) {
|
|
53
|
+
throw VelociousError.safe("Required Resend mail delivery needs a from address.", {
|
|
54
|
+
code: "mail-delivery-from-missing"
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {...payload, from}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Advertises the provider-specific guarantee used by required operations.
|
|
63
|
+
* @returns {import("../index.js").MailerDeliveryIdempotencyCapability} - Capability.
|
|
64
|
+
*/
|
|
65
|
+
deliveryIdempotencyCapability() {
|
|
66
|
+
return {providerKind: PROVIDER_KIND, retentionMs: RETENTION_MS}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validates Resend's documented length and SMTP header-value safety contract.
|
|
71
|
+
* @param {object} args - Validation input.
|
|
72
|
+
* @param {import("../index.js").MailerDeliveryOperationRequest | import("../index.js").MailerDeliveryOperation} args.deliveryOperation - Operation.
|
|
73
|
+
* @param {import("../index.js").MailerDeliveryPayload} args.payload - Rendered or persisted mail payload.
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
validateDeliveryOperation({deliveryOperation, payload}) {
|
|
77
|
+
if (
|
|
78
|
+
typeof deliveryOperation.id !== "string" ||
|
|
79
|
+
deliveryOperation.id.length < 1 ||
|
|
80
|
+
deliveryOperation.id.length > 256 ||
|
|
81
|
+
containsHeaderValueControlCharacter(deliveryOperation.id)
|
|
82
|
+
) {
|
|
83
|
+
throw VelociousError.safe("Resend idempotency keys must contain between 1 and 256 characters without control characters.", {
|
|
84
|
+
code: "mail-delivery-idempotency-key-invalid"
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
rejectReservedHeaderOverride(payload)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Injects the framework-owned Resend operation header before generic SMTP serialization.
|
|
93
|
+
* @param {object} args - Delivery args.
|
|
94
|
+
* @param {import("../index.js").MailerDeliveryPayload} args.payload - Mail payload.
|
|
95
|
+
* @param {import("../../configuration.js").default} [args.configuration] - Active configuration.
|
|
96
|
+
* @returns {Promise<void>} - Resolves when accepted and shut down.
|
|
97
|
+
*/
|
|
98
|
+
async deliver({payload, configuration}) {
|
|
99
|
+
const headers = payload.headers || {}
|
|
100
|
+
|
|
101
|
+
if (payload.deliveryOperation) {
|
|
102
|
+
this.validateDeliveryOperation({deliveryOperation: payload.deliveryOperation, payload})
|
|
103
|
+
} else {
|
|
104
|
+
rejectReservedHeaderOverride(payload)
|
|
105
|
+
}
|
|
106
|
+
const operation = deliveryOperationFromPayload(payload)
|
|
107
|
+
|
|
108
|
+
if (!operation) {
|
|
109
|
+
await super.deliver({payload, configuration})
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await super.deliver({
|
|
114
|
+
configuration,
|
|
115
|
+
payload: {
|
|
116
|
+
...payload,
|
|
117
|
+
headers: {...headers, [IDEMPOTENCY_HEADER]: operation.id}
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
package/build/mailer/base.js
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
import ejs from "ejs"
|
|
4
4
|
import {incorporate} from "incorporator"
|
|
5
5
|
import * as inflection from "inflection"
|
|
6
|
+
import BackgroundJobsClient from "../background-jobs/client.js"
|
|
6
7
|
import configurationResolver from "../configuration-resolver.js"
|
|
7
8
|
import restArgsError from "../utils/rest-args-error.js"
|
|
8
9
|
import MailerDelivery from "./delivery.js"
|
|
10
|
+
import MailerDeliveryOperationStore from "./delivery-operation-store.js"
|
|
11
|
+
import {
|
|
12
|
+
deliveryOperationFromPayload,
|
|
13
|
+
prepareRequiredDeliveryPayload,
|
|
14
|
+
requireDeliveryIdempotencyCapability
|
|
15
|
+
} from "./delivery-operation.js"
|
|
9
16
|
|
|
10
17
|
/**
|
|
11
18
|
* Deliveries store.
|
|
@@ -66,16 +73,6 @@ function inferActionName(mailerClass, stack) {
|
|
|
66
73
|
return actionName
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
/**
|
|
70
|
-
* Runs is testing environment.
|
|
71
|
-
* @returns {Promise<boolean>} - Whether the current environment is test.
|
|
72
|
-
*/
|
|
73
|
-
async function isTestingEnvironment() {
|
|
74
|
-
const configuration = await configurationResolver()
|
|
75
|
-
|
|
76
|
-
return configuration.getEnvironment() === "test"
|
|
77
|
-
}
|
|
78
|
-
|
|
79
76
|
/**
|
|
80
77
|
* Base mailer with view rendering and delivery helpers.
|
|
81
78
|
*/
|
|
@@ -241,10 +238,13 @@ export class VelociousMailerBase {
|
|
|
241
238
|
/**
|
|
242
239
|
* Runs enqueue payload.
|
|
243
240
|
* @param {import("./index.js").MailerDeliveryPayload} payload - Mail delivery payload.
|
|
241
|
+
* @param {import("./index.js").MailerDeliveryLaterOptions} [options] - Delivery execution options.
|
|
244
242
|
* @returns {Promise<string | import("./index.js").MailerDeliveryPayload | null>} - Job id or payload in test mode.
|
|
245
243
|
*/
|
|
246
|
-
async _enqueuePayload(payload) {
|
|
247
|
-
|
|
244
|
+
async _enqueuePayload(payload, options) {
|
|
245
|
+
const configuration = await this._getConfiguration()
|
|
246
|
+
|
|
247
|
+
return await enqueuePayload(payload, {...options, configuration})
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
|
|
@@ -287,14 +287,22 @@ export function getDeliveryHandler() {
|
|
|
287
287
|
* @returns {Promise<import("./index.js").MailerDeliveryPayload | ReturnType<typeof JSON.parse>>} - Handler result.
|
|
288
288
|
*/
|
|
289
289
|
export async function deliverPayload(payload) {
|
|
290
|
-
|
|
290
|
+
const configuration = await configurationResolver()
|
|
291
|
+
const backend = configuration.getMailerBackend()
|
|
292
|
+
const deliveryOperation = deliveryOperationFromPayload(payload)
|
|
293
|
+
|
|
294
|
+
if (deliveryOperation) {
|
|
295
|
+
const capability = requireDeliveryIdempotencyCapability({backend, deliveryOperation, payload})
|
|
296
|
+
const operationStore = new MailerDeliveryOperationStore({configuration})
|
|
297
|
+
|
|
298
|
+
await operationStore.beginAttempt({capability, payload})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (configuration.getEnvironment() === "test") {
|
|
291
302
|
deliveriesStore.push(payload)
|
|
292
303
|
return payload
|
|
293
304
|
}
|
|
294
305
|
|
|
295
|
-
const configuration = await configurationResolver()
|
|
296
|
-
const backend = configuration.getMailerBackend()
|
|
297
|
-
|
|
298
306
|
if (backend?.deliver) {
|
|
299
307
|
return await backend.deliver({payload, configuration})
|
|
300
308
|
}
|
|
@@ -311,15 +319,37 @@ export async function deliverPayload(payload) {
|
|
|
311
319
|
/**
|
|
312
320
|
* Runs the enqueuePayload helper.
|
|
313
321
|
* @param {import("./index.js").MailerDeliveryPayload} payload - Mail delivery payload.
|
|
322
|
+
* @param {object} [options] - Enqueue options.
|
|
323
|
+
* @param {import("../configuration.js").default} [options.configuration] - Owning configuration.
|
|
324
|
+
* @param {import("./index.js").MailerDeliveryOperationRequest} [options.deliveryOperation] - Required provider-backed operation.
|
|
314
325
|
* @returns {Promise<string | import("./index.js").MailerDeliveryPayload | null>} - Job id or payload in test mode.
|
|
315
326
|
*/
|
|
316
|
-
export async function enqueuePayload(payload) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
327
|
+
export async function enqueuePayload(payload, {configuration: suppliedConfiguration, deliveryOperation} = {}) {
|
|
328
|
+
const configuration = suppliedConfiguration || await configurationResolver()
|
|
329
|
+
let persistedPayload = payload
|
|
330
|
+
|
|
331
|
+
if (deliveryOperation) {
|
|
332
|
+
const backend = configuration.getMailerBackend()
|
|
333
|
+
const operationPayload = typeof backend?.prepareDeliveryOperationPayload === "function"
|
|
334
|
+
? backend.prepareDeliveryOperationPayload({payload})
|
|
335
|
+
: payload
|
|
336
|
+
const capability = requireDeliveryIdempotencyCapability({backend, deliveryOperation, payload: operationPayload})
|
|
337
|
+
|
|
338
|
+
persistedPayload = prepareRequiredDeliveryPayload({capability, deliveryOperation, payload: operationPayload})
|
|
320
339
|
}
|
|
321
340
|
|
|
322
|
-
|
|
341
|
+
if (configuration.getEnvironment() === "test") {
|
|
342
|
+
deliveriesStore.push(persistedPayload)
|
|
343
|
+
return persistedPayload
|
|
344
|
+
}
|
|
323
345
|
|
|
324
|
-
|
|
346
|
+
const {default: mailDeliveryJob} = await import("../jobs/mail-delivery.js")
|
|
347
|
+
const client = new BackgroundJobsClient({configuration})
|
|
348
|
+
const jobOptions = mailDeliveryJob._withQueue(deliveryOperation ? {idempotencyKey: deliveryOperation.id} : undefined)
|
|
349
|
+
|
|
350
|
+
return await client.enqueue({
|
|
351
|
+
args: [persistedPayload],
|
|
352
|
+
jobName: mailDeliveryJob.jobName(),
|
|
353
|
+
options: jobOptions
|
|
354
|
+
})
|
|
325
355
|
}
|