velocious 1.0.632 → 1.0.634

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 (77) hide show
  1. package/README.md +6 -3
  2. package/build/background-jobs/lifecycle-client.js +3 -3
  3. package/build/background-jobs/local-dispatcher.js +29 -18
  4. package/build/configuration.js +38 -1
  5. package/build/database/drivers/base.js +122 -105
  6. package/build/database/drivers/sqlite/index.web.js +19 -11
  7. package/build/database/migrator.js +8 -5
  8. package/build/database/pool/async-tracked-multi-connection.js +73 -24
  9. package/build/database/pool/base.js +5 -0
  10. package/build/database/pool/single-multi-use.js +43 -11
  11. package/build/database/record/attachments/store.js +35 -26
  12. package/build/environment-handlers/base.js +122 -9
  13. package/build/environment-handlers/node.js +15 -9
  14. package/build/http-server/websocket-events-host.js +10 -8
  15. package/build/src/background-jobs/lifecycle-client.d.ts +1 -0
  16. package/build/src/background-jobs/lifecycle-client.d.ts.map +1 -1
  17. package/build/src/background-jobs/lifecycle-client.js +4 -4
  18. package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -1
  19. package/build/src/background-jobs/local-dispatcher.js +31 -20
  20. package/build/src/configuration.d.ts +19 -0
  21. package/build/src/configuration.d.ts.map +1 -1
  22. package/build/src/configuration.js +36 -2
  23. package/build/src/database/drivers/base.d.ts.map +1 -1
  24. package/build/src/database/drivers/base.js +118 -100
  25. package/build/src/database/drivers/sqlite/index.web.d.ts +2 -0
  26. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  27. package/build/src/database/drivers/sqlite/index.web.js +17 -11
  28. package/build/src/database/migrator.d.ts.map +1 -1
  29. package/build/src/database/migrator.js +9 -6
  30. package/build/src/database/pool/async-tracked-multi-connection.d.ts +15 -0
  31. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  32. package/build/src/database/pool/async-tracked-multi-connection.js +71 -21
  33. package/build/src/database/pool/base.d.ts +2 -0
  34. package/build/src/database/pool/base.d.ts.map +1 -1
  35. package/build/src/database/pool/base.js +5 -1
  36. package/build/src/database/pool/single-multi-use.d.ts.map +1 -1
  37. package/build/src/database/pool/single-multi-use.js +47 -13
  38. package/build/src/database/record/attachments/store.d.ts +6 -0
  39. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  40. package/build/src/database/record/attachments/store.js +31 -23
  41. package/build/src/environment-handlers/base.d.ts +79 -8
  42. package/build/src/environment-handlers/base.d.ts.map +1 -1
  43. package/build/src/environment-handlers/base.js +106 -10
  44. package/build/src/environment-handlers/node.d.ts +3 -6
  45. package/build/src/environment-handlers/node.d.ts.map +1 -1
  46. package/build/src/environment-handlers/node.js +13 -10
  47. package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
  48. package/build/src/http-server/websocket-events-host.js +11 -8
  49. package/build/src/testing/sqljs-test-database.d.ts +41 -0
  50. package/build/src/testing/sqljs-test-database.d.ts.map +1 -0
  51. package/build/src/testing/sqljs-test-database.js +72 -0
  52. package/build/src/testing/test-runner.d.ts +79 -6
  53. package/build/src/testing/test-runner.d.ts.map +1 -1
  54. package/build/src/testing/test-runner.js +498 -139
  55. package/build/src/testing/test.js +2 -2
  56. package/build/testing/sqljs-test-database.js +73 -0
  57. package/build/testing/test-runner.js +529 -141
  58. package/build/testing/test.js +1 -1
  59. package/build/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +1 -1
  61. package/scripts/test-browser.js +16 -29
  62. package/src/background-jobs/lifecycle-client.js +3 -3
  63. package/src/background-jobs/local-dispatcher.js +29 -18
  64. package/src/configuration.js +38 -1
  65. package/src/database/drivers/base.js +122 -105
  66. package/src/database/drivers/sqlite/index.web.js +19 -11
  67. package/src/database/migrator.js +8 -5
  68. package/src/database/pool/async-tracked-multi-connection.js +73 -24
  69. package/src/database/pool/base.js +5 -0
  70. package/src/database/pool/single-multi-use.js +43 -11
  71. package/src/database/record/attachments/store.js +35 -26
  72. package/src/environment-handlers/base.js +122 -9
  73. package/src/environment-handlers/node.js +15 -9
  74. package/src/http-server/websocket-events-host.js +10 -8
  75. package/src/testing/sqljs-test-database.js +73 -0
  76. package/src/testing/test-runner.js +529 -141
  77. package/src/testing/test.js +1 -1
package/README.md CHANGED
@@ -682,8 +682,9 @@ Task.hasOneAttachment("descriptionFile")
682
682
  Task.hasOneAttachment("archivedPdf", {driver: "s3"})
683
683
  ```
684
684
 
685
- See [Backend record attachments](docs/attachments.md) for the complete input,
686
- storage-driver, lifecycle, and path-security contracts.
685
+ `db:migrate` provisions the framework-owned attachment table before runtime
686
+ attachment work begins. See [Backend record attachments](docs/attachments.md)
687
+ for the complete input, storage-driver, lifecycle, and path-security contracts.
687
688
 
688
689
  You can also pass a driver class or instance directly on the attachment:
689
690
 
@@ -2179,6 +2180,8 @@ If you are developing on Velocious, you can run the tests with:
2179
2180
 
2180
2181
  Tests default to a 60-second timeout. Override per test with `{timeoutSeconds: 5}` or set a suite-wide default via `configureTests({defaultTimeoutSeconds: 30})`.
2181
2182
 
2183
+ Database-backed tests default `testArgs.databaseCleaning` to transaction rollback. The configured testing hook uses this metadata to cover `beforeEach`, the test body, and `afterEach` hooks on one pinned connection. Use `{databaseCleaning: {transaction: false, truncate: true}}` only for behavior that requires physical root transactions, independent commits, DDL that auto-commits or cannot run inside the wrapper transaction, lock contention, or genuine concurrency. Transaction-disabled non-request tests use ordinary independently owned checkouts instead of a runner-pinned connection. Tests that own their pool lifecycle or use only private databases can disable configured cleaning with `{databaseCleaning: {transaction: false, truncate: false}}`. See [database cleanup guidance](docs/testing-guidelines.md#preferred-strategy).
2184
+
2182
2185
  Truncation-based test cleanup batches eligible tables into one request on PostgreSQL,
2183
2186
  SQL Server, and SQLite while preserving each driver's existing identity behavior,
2184
2187
  foreign-key restoration, stale-schema retry, and SQL.js persistence guarantees.
@@ -2461,7 +2464,7 @@ npx velocious background-jobs:retire --generation release-20260828.1 --socket /s
2461
2464
  ```
2462
2465
 
2463
2466
  Each lifecycle command sends one request with no retry and has a hard 10000ms
2464
- deadline; `--timeout-ms` accepts 1 through 25000ms. Generation-aware workers,
2467
+ deadline; `--timeout-ms` accepts 1 through 60000ms. Generation-aware workers,
2465
2468
  clients, and reporters require their hello acknowledgement before readiness or
2466
2469
  mutation and bound it to 4000ms by default.
2467
2470
 
@@ -6,7 +6,7 @@ import timeout from "awaitery/build/timeout.js"
6
6
  import JsonSocket from "./json-socket.js"
7
7
 
8
8
  const DEFAULT_REQUEST_TIMEOUT_MS = 10000
9
- const MAX_REQUEST_TIMEOUT_MS = 25000
9
+ export const MAX_LIFECYCLE_REQUEST_TIMEOUT_MS = 60000
10
10
 
11
11
  /** One-request acknowledged lifecycle client. */
12
12
  export default class BackgroundJobsLifecycleClient {
@@ -26,8 +26,8 @@ export default class BackgroundJobsLifecycleClient {
26
26
  })
27
27
  this.generationId = generationConfig.generationId
28
28
  this.socketPath = generationConfig.lifecycleSocketPath
29
- if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs < 1 || requestTimeoutMs > MAX_REQUEST_TIMEOUT_MS) {
30
- throw new TypeError(`requestTimeoutMs must be an integer between 1 and ${MAX_REQUEST_TIMEOUT_MS}`)
29
+ if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs < 1 || requestTimeoutMs > MAX_LIFECYCLE_REQUEST_TIMEOUT_MS) {
30
+ throw new TypeError(`requestTimeoutMs must be an integer between 1 and ${MAX_LIFECYCLE_REQUEST_TIMEOUT_MS}`)
31
31
  }
32
32
  this.requestTimeoutMs = requestTimeoutMs
33
33
  if (!this.generationId) throw new Error("Background jobs lifecycle client requires generationId")
@@ -102,25 +102,36 @@ export default class LocalBackgroundJobsDispatcher {
102
102
  }
103
103
 
104
104
  this._wakeQueued = true
105
- const drain = Promise.resolve().then(async () => {
106
- this._wakeQueued = false
107
- await this._drain()
108
- })
109
- const drainPromise = drain
110
- .catch((error) => {
111
- this._reportFrameworkError({error, stage: "local-background-jobs-drain"})
112
- this._armRecoveryTimer()
113
- })
114
- .finally(() => {
115
- if (this._drainPromise === drainPromise) this._drainPromise = null
116
-
117
- if (this._redrain && this._accepting) {
118
- this._redrain = false
119
- this.wake()
120
- } else {
121
- this._resolveIdleWaiters()
122
- }
105
+ /** @type {Promise<void>} */
106
+ let drainPromise
107
+
108
+ drainPromise = this.configuration.withoutCurrentTestDatabaseAccessScope(() => {
109
+ return this.configuration.withoutCurrentConnectionContexts(() => {
110
+ return Promise
111
+ .resolve()
112
+ .then(async () => {
113
+ this._wakeQueued = false
114
+ await this._drain()
115
+ })
116
+ .catch((error) => {
117
+ try {
118
+ this._reportFrameworkError({error, stage: "local-background-jobs-drain"})
119
+ } finally {
120
+ this._armRecoveryTimer()
121
+ }
122
+ })
123
+ .finally(() => {
124
+ if (this._drainPromise === drainPromise) this._drainPromise = null
125
+
126
+ if (this._redrain && this._accepting) {
127
+ this._redrain = false
128
+ this.wake()
129
+ } else {
130
+ this._resolveIdleWaiters()
131
+ }
132
+ })
123
133
  })
134
+ })
124
135
 
125
136
  this._drainPromise = drainPromise
126
137
  }
@@ -3526,6 +3526,32 @@ export default class VelociousConfiguration {
3526
3526
  return payload
3527
3527
  }
3528
3528
 
3529
+ /**
3530
+ * Runs one test attempt in a revocable database-access context.
3531
+ * @template T
3532
+ * @param {{revoked: boolean}} scope - Attempt-owned access scope.
3533
+ * @param {() => T | Promise<T>} callback - Attempt work.
3534
+ * @returns {T | Promise<T>} - Callback result.
3535
+ */
3536
+ runWithTestDatabaseAccessScope(scope, callback) {
3537
+ return this.getEnvironmentHandler().runWithTestDatabaseAccessScope(scope, callback)
3538
+ }
3539
+
3540
+ /**
3541
+ * Runs persistent framework work without inheriting a test attempt's revocable database-access scope.
3542
+ * @template T
3543
+ * @param {() => T | Promise<T>} callback - Persistent work to run.
3544
+ * @returns {Promise<T>} - Callback result.
3545
+ */
3546
+ async withoutCurrentTestDatabaseAccessScope(callback) {
3547
+ return await this.getEnvironmentHandler().runWithCapturedTestDatabaseAccessScope(undefined, callback)
3548
+ }
3549
+
3550
+ /** Throws when a timed-out test attempt tries to start more database work. */
3551
+ assertDatabaseAccessAllowed() {
3552
+ this.getEnvironmentHandler().assertTestDatabaseAccessAllowed()
3553
+ }
3554
+
3529
3555
  /**
3530
3556
  * Runs with connections.
3531
3557
  * @template T
@@ -3534,6 +3560,7 @@ export default class VelociousConfiguration {
3534
3560
  * @returns {Promise<T>} - Resolves with the callback result.
3535
3561
  */
3536
3562
  async withConnections(optionsOrCallback, callback) {
3563
+ this.assertDatabaseAccessAllowed()
3537
3564
  const {
3538
3565
  callback: actualWithConnectionsCallback,
3539
3566
  databaseIdentifiers,
@@ -3564,6 +3591,7 @@ export default class VelociousConfiguration {
3564
3591
  * @returns {Promise<T>} - Resolves with the callback result.
3565
3592
  */
3566
3593
  async withTransaction({databaseIdentifier, name = "Configuration.withTransaction", ...restArgs}, callback) {
3594
+ this.assertDatabaseAccessAllowed()
3567
3595
  restArgsError(restArgs)
3568
3596
 
3569
3597
  if (!databaseIdentifier) throw new Error("Configuration.withTransaction requires a databaseIdentifier")
@@ -3577,6 +3605,7 @@ export default class VelociousConfiguration {
3577
3605
  const pool = this.getDatabasePool(databaseIdentifier)
3578
3606
 
3579
3607
  return await pool.withOperationConnection({name}, async (connection, owner) => {
3608
+ this.assertDatabaseAccessAllowed()
3580
3609
  const operation = new DatabaseOperation({
3581
3610
  configuration: this,
3582
3611
  databaseConfiguration,
@@ -3588,7 +3617,10 @@ export default class VelociousConfiguration {
3588
3617
  })
3589
3618
 
3590
3619
  try {
3591
- return await operation.transaction(async () => await callback(operation))
3620
+ return await operation.transaction(async () => {
3621
+ this.assertDatabaseAccessAllowed()
3622
+ return await callback(operation)
3623
+ })
3592
3624
  } finally {
3593
3625
  operation.complete()
3594
3626
  }
@@ -3605,6 +3637,7 @@ export default class VelociousConfiguration {
3605
3637
  * @returns {Promise<T>} - Callback result.
3606
3638
  */
3607
3639
  async withDatabaseOperation({databaseConfiguration, databaseIdentifier, name = "Configuration.withDatabaseOperation", schemaGeneration, tenant, ...restArgs}, callback) {
3640
+ this.assertDatabaseAccessAllowed()
3608
3641
  restArgsError(restArgs)
3609
3642
 
3610
3643
  if (!databaseIdentifier) throw new Error("Configuration.withDatabaseOperation requires a databaseIdentifier")
@@ -3615,6 +3648,7 @@ export default class VelociousConfiguration {
3615
3648
  const configurationReuseKey = pool.getConfigurationReuseKey(databaseConfiguration)
3616
3649
 
3617
3650
  return await pool.withCapturedOperationConnection({databaseConfiguration, name}, async (connection, owner) => {
3651
+ this.assertDatabaseAccessAllowed()
3618
3652
  const operation = new DatabaseOperation({
3619
3653
  configuration: this,
3620
3654
  databaseConfiguration,
@@ -3644,6 +3678,7 @@ export default class VelociousConfiguration {
3644
3678
  async withDatabaseIdentifierConnections({callback, dbs, identifiers, name, stackLabel}) {
3645
3679
  const stack = Error().stack
3646
3680
  const actualCallback = async () => {
3681
+ this.assertDatabaseAccessAllowed()
3647
3682
  return await withTrackedStack(stack || stackLabel, async () => {
3648
3683
  return await callback(dbs)
3649
3684
  })
@@ -3677,6 +3712,7 @@ export default class VelociousConfiguration {
3677
3712
  * @returns {Record<string, import("./database/drivers/base.js").default>} A map of database connections with identifier as key
3678
3713
  */
3679
3714
  getCurrentConnections(databaseIdentifiers = this.getDatabaseIdentifiers()) {
3715
+ this.assertDatabaseAccessAllowed()
3680
3716
  /**
3681
3717
  * Dbs.
3682
3718
  * @type {{[key: string]: import("./database/drivers/base.js").default}} */
@@ -3766,6 +3802,7 @@ export default class VelociousConfiguration {
3766
3802
  * @returns {Promise<T>} - Resolves with the callback result.
3767
3803
  */
3768
3804
  async ensureConnections(optionsOrCallback, callback) {
3805
+ this.assertDatabaseAccessAllowed()
3769
3806
  const {
3770
3807
  callback: actualWithConnectionsCallback,
3771
3808
  databaseIdentifiers,
@@ -1376,63 +1376,65 @@ export default class VelociousDatabaseDriversBase {
1376
1376
  async transaction(callback, options = {}) {
1377
1377
  await this._waitForOperationLease(options.operationOwner)
1378
1378
 
1379
- if (this._transactionsCount > 0) {
1380
- return await this._runTransactionAttempt(callback, options)
1381
- }
1382
-
1383
- const args = this.getArgs()
1384
- const maxAttempts = optionalPositiveInteger(args.deadlockMaxRetries, "deadlockMaxRetries") ?? 8
1385
- const configuredBaseWaitMs = optionalPositiveInteger(args.deadlockBaseWaitMs, "deadlockBaseWaitMs")
1386
- const deadlockMaxWaitMs = optionalPositiveInteger(args.deadlockMaxWaitMs, "deadlockMaxWaitMs") ?? 1000
1387
- let attempt = 0
1379
+ return await coordinateSharedTransactionConnection(this, async () => {
1380
+ if (this._transactionsCount > 0) {
1381
+ return await this._runTransactionAttempt(callback, options)
1382
+ }
1388
1383
 
1389
- while (true) {
1390
- attempt++
1391
- const attemptStartedAtMs = this._nowMs()
1384
+ const args = this.getArgs()
1385
+ const maxAttempts = optionalPositiveInteger(args.deadlockMaxRetries, "deadlockMaxRetries") ?? 8
1386
+ const configuredBaseWaitMs = optionalPositiveInteger(args.deadlockBaseWaitMs, "deadlockBaseWaitMs")
1387
+ const deadlockMaxWaitMs = optionalPositiveInteger(args.deadlockMaxWaitMs, "deadlockMaxWaitMs") ?? 1000
1388
+ let attempt = 0
1392
1389
 
1393
- try {
1394
- return await this._runTransactionAttempt(callback, options)
1395
- } catch (error) {
1396
- if (error instanceof VelociousDatabaseAfterCommitCallbackError) throw error.callbackError
1397
- if (!(error instanceof Error)) throw error
1390
+ while (true) {
1391
+ attempt++
1392
+ const attemptStartedAtMs = this._nowMs()
1398
1393
 
1399
- const retryInfo = this.retryableDatabaseError(error)
1400
- const willRetry = Boolean(retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0)
1401
-
1402
- if (willRetry) {
1403
- this._reportDeadlockRetryDiagnostic({
1404
- attempt,
1405
- contentionKind: retryInfo.contentionKind || "deadlock",
1406
- error,
1407
- maxAttempts,
1408
- transactionAttemptDurationMs: Math.max(0, this._nowMs() - attemptStartedAtMs),
1409
- willRetry
1410
- })
1394
+ try {
1395
+ return await this._runTransactionAttempt(callback, options)
1396
+ } catch (error) {
1397
+ if (error instanceof VelociousDatabaseAfterCommitCallbackError) throw error.callbackError
1398
+ if (!(error instanceof Error)) throw error
1399
+
1400
+ const retryInfo = this.retryableDatabaseError(error)
1401
+ const willRetry = Boolean(retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0)
1402
+
1403
+ if (willRetry) {
1404
+ this._reportDeadlockRetryDiagnostic({
1405
+ attempt,
1406
+ contentionKind: retryInfo.contentionKind || "deadlock",
1407
+ error,
1408
+ maxAttempts,
1409
+ transactionAttemptDurationMs: Math.max(0, this._nowMs() - attemptStartedAtMs),
1410
+ willRetry
1411
+ })
1412
+
1413
+ // An explicitly-configured base wins so the tuning knob is effective even on drivers
1414
+ // whose classifier supplies its own `waitMs` (MySQL/MariaDB return a fixed 50ms for
1415
+ // deadlocks); otherwise honor that classifier hint, then fall back to 50ms.
1416
+ const baseWaitMs = configuredBaseWaitMs ?? (typeof retryInfo.waitMs == "number" && retryInfo.waitMs > 0 ? retryInfo.waitMs : 50)
1417
+
1418
+ // Full-jitter exponential backoff: wait a uniform-random duration in
1419
+ // [0, min(base * 2^(attempt-1), cap)]. The doubling ceiling spreads retries out as
1420
+ // contention persists, and the jitter de-correlates transactions that deadlocked in
1421
+ // lockstep so they stop re-colliding on the same wait (the linear `base * attempt`
1422
+ // this replaces had every victim retry after an identical delay). `attempt` is
1423
+ // 1-based here, so 2^(attempt-1) is 1, 2, 4, ... The cap keeps the tail sub-second.
1424
+ const ceilingWaitMs = Math.min(baseWaitMs * (2 ** (attempt - 1)), deadlockMaxWaitMs)
1425
+ const jitteredWaitMs = Math.floor(Math.random() * (ceilingWaitMs + 1))
1426
+
1427
+ const loggedContentionKind = retryInfo.contentionKind || "transaction contention"
1428
+
1429
+ this.logger.warn(`Retrying transaction after ${loggedContentionKind} (attempt ${attempt}/${maxAttempts})`)
1430
+ await this._waitMs(jitteredWaitMs)
1431
+ continue
1432
+ }
1411
1433
 
1412
- // An explicitly-configured base wins so the tuning knob is effective even on drivers
1413
- // whose classifier supplies its own `waitMs` (MySQL/MariaDB return a fixed 50ms for
1414
- // deadlocks); otherwise honor that classifier hint, then fall back to 50ms.
1415
- const baseWaitMs = configuredBaseWaitMs ?? (typeof retryInfo.waitMs == "number" && retryInfo.waitMs > 0 ? retryInfo.waitMs : 50)
1416
-
1417
- // Full-jitter exponential backoff: wait a uniform-random duration in
1418
- // [0, min(base * 2^(attempt-1), cap)]. The doubling ceiling spreads retries out as
1419
- // contention persists, and the jitter de-correlates transactions that deadlocked in
1420
- // lockstep so they stop re-colliding on the same wait (the linear `base * attempt`
1421
- // this replaces had every victim retry after an identical delay). `attempt` is
1422
- // 1-based here, so 2^(attempt-1) is 1, 2, 4, ... The cap keeps the tail sub-second.
1423
- const ceilingWaitMs = Math.min(baseWaitMs * (2 ** (attempt - 1)), deadlockMaxWaitMs)
1424
- const jitteredWaitMs = Math.floor(Math.random() * (ceilingWaitMs + 1))
1425
-
1426
- const loggedContentionKind = retryInfo.contentionKind || "transaction contention"
1427
-
1428
- this.logger.warn(`Retrying transaction after ${loggedContentionKind} (attempt ${attempt}/${maxAttempts})`)
1429
- await this._waitMs(jitteredWaitMs)
1430
- continue
1434
+ throw error
1431
1435
  }
1432
-
1433
- throw error
1434
1436
  }
1435
- }
1437
+ }, options.operationOwner)
1436
1438
  }
1437
1439
 
1438
1440
  /**
@@ -1769,34 +1771,36 @@ export default class VelociousDatabaseDriversBase {
1769
1771
  * @returns {Promise<void>} - Resolves when complete.
1770
1772
  */
1771
1773
  async startTransaction(options = {}) {
1772
- while (true) {
1773
- /** @type {import("../operation-lease.js").default | undefined} */
1774
- let blockingOperationLease
1775
-
1776
- await this._transactionsActionsMutex.sync(async () => {
1777
- const operationLease = this._operationLease
1774
+ await coordinateSharedTransactionConnection(this, async () => {
1775
+ while (true) {
1776
+ /** @type {import("../operation-lease.js").default | undefined} */
1777
+ let blockingOperationLease
1778
1778
 
1779
- if (operationLease && options.operationOwner !== operationLease.owner) {
1780
- blockingOperationLease = operationLease
1781
- return
1782
- }
1779
+ await this._transactionsActionsMutex.sync(async () => {
1780
+ const operationLease = this._operationLease
1783
1781
 
1784
- await this._runProfiledTransactionAction("start", async () => {
1785
- await this._startTransactionAction(options)
1786
- })
1787
- this._transactionsCount++
1782
+ if (operationLease && options.operationOwner !== operationLease.owner) {
1783
+ blockingOperationLease = operationLease
1784
+ return
1785
+ }
1788
1786
 
1789
- if (this._transactionsCount === 1) {
1790
- this._transactionCompletionPromise = new Promise((resolve) => {
1791
- this._resolveTransactionCompletion = resolve
1787
+ await this._runProfiledTransactionAction("start", async () => {
1788
+ await this._startTransactionAction(options)
1792
1789
  })
1793
- }
1794
- })
1790
+ this._transactionsCount++
1795
1791
 
1796
- if (!blockingOperationLease) return
1792
+ if (this._transactionsCount === 1) {
1793
+ this._transactionCompletionPromise = new Promise((resolve) => {
1794
+ this._resolveTransactionCompletion = resolve
1795
+ })
1796
+ }
1797
+ })
1797
1798
 
1798
- await blockingOperationLease.wait(options.operationOwner)
1799
- }
1799
+ if (!blockingOperationLease) return
1800
+
1801
+ await blockingOperationLease.wait(options.operationOwner)
1802
+ }
1803
+ }, options.operationOwner)
1800
1804
  }
1801
1805
 
1802
1806
  /**
@@ -1814,13 +1818,15 @@ export default class VelociousDatabaseDriversBase {
1814
1818
  * @returns {Promise<void>} - Resolves when complete.
1815
1819
  */
1816
1820
  async commitTransaction(options = {}) {
1817
- await this._transactionsActionsMutex.sync(async () => {
1818
- await this._runProfiledTransactionAction("commit", async () => {
1819
- await this._commitTransactionAction(options)
1821
+ await coordinateSharedTransactionConnection(this, async () => {
1822
+ await this._transactionsActionsMutex.sync(async () => {
1823
+ await this._runProfiledTransactionAction("commit", async () => {
1824
+ await this._commitTransactionAction(options)
1825
+ })
1826
+ this._transactionsCount--
1827
+ this._resolveCompletedTransaction()
1820
1828
  })
1821
- this._transactionsCount--
1822
- this._resolveCompletedTransaction()
1823
- })
1829
+ }, options.operationOwner)
1824
1830
  }
1825
1831
 
1826
1832
  /** Resolves the current outer transaction completion when it has finished. */
@@ -2560,23 +2566,28 @@ export default class VelociousDatabaseDriversBase {
2560
2566
  * @returns {Promise<void>} - Resolves when complete.
2561
2567
  */
2562
2568
  async rollbackTransaction(options = {}) {
2563
- await this._transactionsActionsMutex.sync(async () => {
2564
- try {
2565
- await this._runProfiledTransactionAction("rollback", async () => {
2566
- await this._rollbackTransactionAction(options)
2567
- })
2568
- } finally {
2569
- this._transactionsCount--
2570
- this._resolveCompletedTransaction()
2571
-
2572
- // A rolled-back transaction may have reverted DDL (e.g. a CREATE TABLE
2573
- // run lazily inside the transaction), so any cached schema metadata is
2574
- // now stale and must be invalidated. Without this, a later tableExists()
2575
- // check can report a table that the rollback already removed, so callers
2576
- // skip recreating it and then fail with "no such table".
2577
- this.clearSchemaCache()
2578
- }
2579
- })
2569
+ await coordinateSharedTransactionConnection(this, async () => {
2570
+ await this._transactionsActionsMutex.sync(async () => {
2571
+ try {
2572
+ await this._runProfiledTransactionAction("rollback", async () => {
2573
+ await this._rollbackTransactionAction(options)
2574
+ })
2575
+ } finally {
2576
+ // Driver recovery may need to clear a stale physical transaction when
2577
+ // no logical transaction is active. Never let that cleanup underflow
2578
+ // the logical depth and turn the next root transaction into a savepoint.
2579
+ if (this._transactionsCount > 0) this._transactionsCount--
2580
+ this._resolveCompletedTransaction()
2581
+
2582
+ // A rolled-back transaction may have reverted DDL (e.g. a CREATE TABLE
2583
+ // run lazily inside the transaction), so any cached schema metadata is
2584
+ // now stale and must be invalidated. Without this, a later tableExists()
2585
+ // check can report a table that the rollback already removed, so callers
2586
+ // skip recreating it and then fail with "no such table".
2587
+ this.clearSchemaCache()
2588
+ }
2589
+ })
2590
+ }, options.operationOwner)
2580
2591
  }
2581
2592
 
2582
2593
  /**
@@ -2603,9 +2614,11 @@ export default class VelociousDatabaseDriversBase {
2603
2614
  * @returns {Promise<void>} - Resolves when complete.
2604
2615
  */
2605
2616
  async startSavePoint(savePointName, options = {}) {
2606
- await this._transactionsActionsMutex.sync(async () => {
2607
- await this._startSavePointAction(savePointName, options)
2608
- })
2617
+ await coordinateSharedTransactionConnection(this, async () => {
2618
+ await this._transactionsActionsMutex.sync(async () => {
2619
+ await this._startSavePointAction(savePointName, options)
2620
+ })
2621
+ }, options.operationOwner)
2609
2622
  }
2610
2623
 
2611
2624
  /**
@@ -2649,9 +2662,11 @@ export default class VelociousDatabaseDriversBase {
2649
2662
  * @returns {Promise<void>} - Resolves when complete.
2650
2663
  */
2651
2664
  async releaseSavePoint(savePointName, options = {}) {
2652
- await this._transactionsActionsMutex.sync(async () => {
2653
- await this._releaseSavePointAction(savePointName, options)
2654
- })
2665
+ await coordinateSharedTransactionConnection(this, async () => {
2666
+ await this._transactionsActionsMutex.sync(async () => {
2667
+ await this._releaseSavePointAction(savePointName, options)
2668
+ })
2669
+ }, options.operationOwner)
2655
2670
  }
2656
2671
 
2657
2672
  /**
@@ -2683,9 +2698,11 @@ export default class VelociousDatabaseDriversBase {
2683
2698
  * @returns {Promise<void>} - Resolves when complete.
2684
2699
  */
2685
2700
  async rollbackSavePoint(savePointName, options = {}) {
2686
- await this._transactionsActionsMutex.sync(async () => {
2687
- await this._rollbackSavePointAction(savePointName, options)
2688
- })
2701
+ await coordinateSharedTransactionConnection(this, async () => {
2702
+ await this._transactionsActionsMutex.sync(async () => {
2703
+ await this._rollbackSavePointAction(savePointName, options)
2704
+ })
2705
+ }, options.operationOwner)
2689
2706
  }
2690
2707
 
2691
2708
  /**
@@ -17,6 +17,9 @@ export default class VelociousDatabaseDriversSqliteWeb extends Base {
17
17
  * @type {ConnectionSqlJs | undefined} */
18
18
  _connection = undefined
19
19
 
20
+ /** @type {SqliteWebConnection | undefined} */
21
+ _externalConnection = undefined
22
+
20
23
  /**
21
24
  * Runs sql js locate file.
22
25
  * @returns {(file: string) => string} - locateFile callback for sql.js.
@@ -34,18 +37,21 @@ export default class VelociousDatabaseDriversSqliteWeb extends Base {
34
37
  async connect() {
35
38
  this.args = this.getArgs()
36
39
 
37
- if (!this.args.getConnection) {
38
- if (this.args.reset) {
39
- await deleteSqliteWebPersistences({databaseName: this.databaseName()})
40
- }
41
-
42
- const persistence = await createSqliteWebPersistence({databaseName: this.databaseName()})
43
- const SQL = await initSqlJs({locateFile: this.sqlJsLocateFile()})
44
- const databaseContent = await persistence.load()
45
- const connectionSqlJs = new ConnectionSqlJs(this, new SQL.Database(databaseContent), persistence)
40
+ if (this.args.getConnection) {
41
+ this._externalConnection = this.args.getConnection()
42
+ return
43
+ }
46
44
 
47
- this._connection = connectionSqlJs
45
+ if (this.args.reset) {
46
+ await deleteSqliteWebPersistences({databaseName: this.databaseName()})
48
47
  }
48
+
49
+ const persistence = await createSqliteWebPersistence({databaseName: this.databaseName()})
50
+ const SQL = await initSqlJs({locateFile: this.sqlJsLocateFile()})
51
+ const databaseContent = await persistence.load()
52
+ const connectionSqlJs = new ConnectionSqlJs(this, new SQL.Database(databaseContent), persistence)
53
+
54
+ this._connection = connectionSqlJs
49
55
  }
50
56
 
51
57
  async _close() {
@@ -152,7 +158,9 @@ export default class VelociousDatabaseDriversSqliteWeb extends Base {
152
158
  */
153
159
  getConnection() {
154
160
  if (this.args?.getConnection) {
155
- return /** @type {SqliteWebConnection} */ (this.args.getConnection())
161
+ if (!this._externalConnection) throw new Error("SQLite web external connection has not been initialized")
162
+
163
+ return this._externalConnection
156
164
  } else {
157
165
  if (!this._connection) throw new Error("SQLite web connection has not been initialized")
158
166
  return this._connection
@@ -265,15 +265,18 @@ export default class VelociousDatabaseMigrator {
265
265
  const environmentHandler = this.configuration.getEnvironmentHandler()
266
266
  const dbs = await this.configuration.getCurrentConnections()
267
267
  const filteredDbs = Object.fromEntries(
268
- Object.entries(dbs).filter(([dbIdentifier]) => this.handlesDatabaseIdentifier(dbIdentifier))
268
+ Object.entries(dbs).filter(([dbIdentifier]) => {
269
+ if (!this.handlesDatabaseIdentifier(dbIdentifier)) return false
270
+
271
+ return Boolean(this.configuration.getDatabaseIdentifier(dbIdentifier).migrations)
272
+ })
269
273
  )
270
274
 
271
275
  if (!environmentHandler || Object.keys(filteredDbs).length == 0) return
272
276
 
273
- // Ensure velocious' own framework schema (background jobs) before the structure
274
- // dump, and unconditionally — the dump is gated to enabled environments but the
275
- // framework schema must exist after every migrate so `db:migrate` (and thus
276
- // schema:load of the dumped SQL) produces a complete DB in every environment.
277
+ // Ensure Velocious' own framework schema before the structure dump. The dump is
278
+ // gated to enabled environments, but migration-enabled databases must include
279
+ // framework tables so `db:migrate` and schema:load produce a complete database.
277
280
  await environmentHandler.ensureFrameworkSchema({dbs: filteredDbs})
278
281
  await environmentHandler.afterMigrations({dbs: filteredDbs})
279
282
  }