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.
Files changed (116) hide show
  1. package/README.md +21 -0
  2. package/build/background-jobs/pooled-runner-broker-identity.js +22 -4
  3. package/build/background-jobs/store.js +383 -9
  4. package/build/background-jobs/types.js +1 -0
  5. package/build/cli/commands/db/tenants/migrations/pending.js +45 -0
  6. package/build/configuration-types.js +3 -0
  7. package/build/configuration.js +5 -2
  8. package/build/database/drivers/base.js +135 -1
  9. package/build/database/drivers/mysql/index.js +92 -0
  10. package/build/database/drivers/sqlite/base.js +3 -3
  11. package/build/database/tenants/migration-pending-inspector.js +77 -0
  12. package/build/environment-handlers/base.js +8 -0
  13. package/build/environment-handlers/node.js +14 -0
  14. package/build/mailer/backends/resend-smtp.js +121 -0
  15. package/build/mailer/base.js +52 -22
  16. package/build/mailer/delivery-operation-store.js +152 -0
  17. package/build/mailer/delivery-operation.js +205 -0
  18. package/build/mailer/delivery.js +9 -4
  19. package/build/mailer/index.js +5 -1
  20. package/build/mailer.js +6 -1
  21. package/build/src/background-jobs/pooled-runner-broker-identity.d.ts +10 -1
  22. package/build/src/background-jobs/pooled-runner-broker-identity.d.ts.map +1 -1
  23. package/build/src/background-jobs/pooled-runner-broker-identity.js +23 -5
  24. package/build/src/background-jobs/store.d.ts +154 -0
  25. package/build/src/background-jobs/store.d.ts.map +1 -1
  26. package/build/src/background-jobs/store.js +350 -10
  27. package/build/src/background-jobs/types.d.ts +5 -0
  28. package/build/src/background-jobs/types.d.ts.map +1 -1
  29. package/build/src/background-jobs/types.js +2 -1
  30. package/build/src/cli/commands/db/tenants/migrations/pending.d.ts +15 -0
  31. package/build/src/cli/commands/db/tenants/migrations/pending.d.ts.map +1 -0
  32. package/build/src/cli/commands/db/tenants/migrations/pending.js +40 -0
  33. package/build/src/configuration-types.d.ts +20 -0
  34. package/build/src/configuration-types.d.ts.map +1 -1
  35. package/build/src/configuration-types.js +4 -1
  36. package/build/src/configuration.d.ts +2 -2
  37. package/build/src/configuration.d.ts.map +1 -1
  38. package/build/src/configuration.js +6 -3
  39. package/build/src/database/drivers/base.d.ts +21 -0
  40. package/build/src/database/drivers/base.d.ts.map +1 -1
  41. package/build/src/database/drivers/base.js +131 -2
  42. package/build/src/database/drivers/mysql/index.d.ts +22 -0
  43. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  44. package/build/src/database/drivers/mysql/index.js +91 -1
  45. package/build/src/database/drivers/sqlite/base.js +4 -4
  46. package/build/src/database/tenants/migration-pending-inspector.d.ts +38 -0
  47. package/build/src/database/tenants/migration-pending-inspector.d.ts.map +1 -0
  48. package/build/src/database/tenants/migration-pending-inspector.js +70 -0
  49. package/build/src/environment-handlers/base.d.ts +10 -0
  50. package/build/src/environment-handlers/base.d.ts.map +1 -1
  51. package/build/src/environment-handlers/base.js +8 -1
  52. package/build/src/environment-handlers/node.d.ts +11 -0
  53. package/build/src/environment-handlers/node.d.ts.map +1 -1
  54. package/build/src/environment-handlers/node.js +14 -1
  55. package/build/src/mailer/backends/resend-smtp.d.ts +43 -0
  56. package/build/src/mailer/backends/resend-smtp.d.ts.map +1 -0
  57. package/build/src/mailer/backends/resend-smtp.js +106 -0
  58. package/build/src/mailer/base.d.ts +9 -2
  59. package/build/src/mailer/base.d.ts.map +1 -1
  60. package/build/src/mailer/base.js +41 -19
  61. package/build/src/mailer/delivery-operation-store.d.ts +52 -0
  62. package/build/src/mailer/delivery-operation-store.d.ts.map +1 -0
  63. package/build/src/mailer/delivery-operation-store.js +130 -0
  64. package/build/src/mailer/delivery-operation.d.ts +73 -0
  65. package/build/src/mailer/delivery-operation.d.ts.map +1 -0
  66. package/build/src/mailer/delivery-operation.js +185 -0
  67. package/build/src/mailer/delivery.d.ts +4 -2
  68. package/build/src/mailer/delivery.d.ts.map +1 -1
  69. package/build/src/mailer/delivery.js +9 -5
  70. package/build/src/mailer/index.d.ts +24 -1
  71. package/build/src/mailer/index.d.ts.map +1 -1
  72. package/build/src/mailer/index.js +6 -2
  73. package/build/src/mailer.d.ts +11 -13
  74. package/build/src/mailer.d.ts.map +1 -1
  75. package/build/src/mailer.js +7 -2
  76. package/build/src/sync/local-mutation-log.d.ts +5 -5
  77. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  78. package/build/src/sync/local-mutation-log.js +34 -10
  79. package/build/src/sync/stable-json.d.ts +1 -8
  80. package/build/src/sync/stable-json.d.ts.map +1 -1
  81. package/build/src/sync/stable-json.js +2 -27
  82. package/build/src/sync/sync-envelope-replay-service.d.ts +3 -2
  83. package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
  84. package/build/src/sync/sync-envelope-replay-service.js +12 -8
  85. package/build/src/utils/stable-json.d.ts +7 -0
  86. package/build/src/utils/stable-json.d.ts.map +1 -0
  87. package/build/src/utils/stable-json.js +25 -0
  88. package/build/sync/local-mutation-log.js +37 -6
  89. package/build/sync/stable-json.js +1 -28
  90. package/build/sync/sync-envelope-replay-service.js +11 -7
  91. package/build/tsconfig.tsbuildinfo +1 -1
  92. package/build/utils/stable-json.js +26 -0
  93. package/package.json +1 -1
  94. package/src/background-jobs/pooled-runner-broker-identity.js +22 -4
  95. package/src/background-jobs/store.js +383 -9
  96. package/src/background-jobs/types.js +1 -0
  97. package/src/cli/commands/db/tenants/migrations/pending.js +45 -0
  98. package/src/configuration-types.js +3 -0
  99. package/src/configuration.js +5 -2
  100. package/src/database/drivers/base.js +135 -1
  101. package/src/database/drivers/mysql/index.js +92 -0
  102. package/src/database/drivers/sqlite/base.js +3 -3
  103. package/src/database/tenants/migration-pending-inspector.js +77 -0
  104. package/src/environment-handlers/base.js +8 -0
  105. package/src/environment-handlers/node.js +14 -0
  106. package/src/mailer/backends/resend-smtp.js +121 -0
  107. package/src/mailer/base.js +52 -22
  108. package/src/mailer/delivery-operation-store.js +152 -0
  109. package/src/mailer/delivery-operation.js +205 -0
  110. package/src/mailer/delivery.js +9 -4
  111. package/src/mailer/index.js +5 -1
  112. package/src/mailer.js +6 -1
  113. package/src/sync/local-mutation-log.js +37 -6
  114. package/src/sync/stable-json.js +1 -28
  115. package/src/sync/sync-envelope-replay-service.js +11 -7
  116. 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 = error instanceof Error ? this.retryableDatabaseError(error) : {retry: false, reconnect: false}
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
- error.message?.startsWith("database is locked") ||
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
+ }
@@ -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
- return await enqueuePayload(payload)
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
- if (await isTestingEnvironment()) {
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
- if (await isTestingEnvironment()) {
318
- deliveriesStore.push(payload)
319
- return payload
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
- const {default: mailDeliveryJob} = await import("../jobs/mail-delivery.js")
341
+ if (configuration.getEnvironment() === "test") {
342
+ deliveriesStore.push(persistedPayload)
343
+ return persistedPayload
344
+ }
323
345
 
324
- return await mailDeliveryJob.performLater(payload)
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
  }