velocious 1.0.617 → 1.0.618

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 (41) hide show
  1. package/README.md +3 -1
  2. package/build/background-jobs/client.js +12 -3
  3. package/build/background-jobs/socket-request.js +4 -0
  4. package/build/database/migration/index.js +2 -1
  5. package/build/database/pool/async-tracked-multi-connection.js +78 -1
  6. package/build/database/pool/base.js +32 -0
  7. package/build/database/pool/single-multi-use.js +65 -4
  8. package/build/src/background-jobs/client.d.ts +4 -1
  9. package/build/src/background-jobs/client.d.ts.map +1 -1
  10. package/build/src/background-jobs/client.js +12 -4
  11. package/build/src/background-jobs/socket-request.d.ts.map +1 -1
  12. package/build/src/background-jobs/socket-request.js +4 -1
  13. package/build/src/database/migration/index.d.ts +3 -2
  14. package/build/src/database/migration/index.d.ts.map +1 -1
  15. package/build/src/database/migration/index.js +3 -2
  16. package/build/src/database/pool/async-tracked-multi-connection.d.ts +20 -0
  17. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  18. package/build/src/database/pool/async-tracked-multi-connection.js +75 -2
  19. package/build/src/database/pool/base.d.ts +25 -0
  20. package/build/src/database/pool/base.d.ts.map +1 -1
  21. package/build/src/database/pool/base.js +30 -1
  22. package/build/src/database/pool/single-multi-use.d.ts +30 -3
  23. package/build/src/database/pool/single-multi-use.d.ts.map +1 -1
  24. package/build/src/database/pool/single-multi-use.js +58 -5
  25. package/build/src/testing/shared-transaction-connection-coordinator.d.ts +5 -0
  26. package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
  27. package/build/src/testing/shared-transaction-connection-coordinator.js +28 -4
  28. package/build/src/testing/test-runner.d.ts +75 -0
  29. package/build/src/testing/test-runner.d.ts.map +1 -1
  30. package/build/src/testing/test-runner.js +201 -10
  31. package/build/testing/shared-transaction-connection-coordinator.js +29 -3
  32. package/build/testing/test-runner.js +198 -9
  33. package/package.json +1 -1
  34. package/src/background-jobs/client.js +12 -3
  35. package/src/background-jobs/socket-request.js +4 -0
  36. package/src/database/migration/index.js +2 -1
  37. package/src/database/pool/async-tracked-multi-connection.js +78 -1
  38. package/src/database/pool/base.js +32 -0
  39. package/src/database/pool/single-multi-use.js +65 -4
  40. package/src/testing/shared-transaction-connection-coordinator.js +29 -3
  41. package/src/testing/test-runner.js +198 -9
@@ -45,6 +45,8 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
45
45
  /** @type {Set<() => boolean>} */
46
46
  capacityWaiters = new Set()
47
47
  closeGeneration = 0
48
+ /** @type {Map<string, {connection: import("../drivers/base.js").default, registration: import("./base.js").TestSharedConnectionRegistration}>} */
49
+ testSharedConnectionsByReuseKey = new Map()
48
50
 
49
51
  /**
50
52
  * Checks a connection back into its keyed physical entry.
@@ -80,6 +82,24 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
80
82
  if (!entry.lifecycleRetained && (!entry.retained || this.capacityWaiters.size > 0)) await this.removeAndCloseEntry(entry)
81
83
  }
82
84
 
85
+ /**
86
+ * Permanently removes and closes a checked-out connection.
87
+ * @param {import("../drivers/base.js").default} connection - Connection that must not return to the pool.
88
+ */
89
+ async discard(connection) {
90
+ const entry = this.entryForConnection(connection)
91
+
92
+ if (entry) {
93
+ this.activeCheckoutCount -= entry.activeCheckoutCount
94
+ entry.activeCheckoutCount = 0
95
+ entry.checkoutNames = []
96
+ await this.removeAndCloseEntry(entry)
97
+ return
98
+ }
99
+
100
+ await connection.close()
101
+ }
102
+
83
103
  /**
84
104
  * Checks out the ambient configuration and retains it as the single mutable
85
105
  * browser fallback connection.
@@ -93,11 +113,11 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
93
113
  /**
94
114
  * Checks out an explicitly captured physical configuration.
95
115
  * @param {import("../../configuration-types.js").DatabaseConfigurationType} databaseConfiguration - Captured configuration.
96
- * @param {import("./base.js").ConnectionCheckoutOptions} options - Checkout options.
97
- * @param {{retain: boolean}} args - Whether this becomes the ambient retained connection.
116
+ * @param {import("./base.js").ConnectionCheckoutOptions} [options] - Checkout options.
117
+ * @param {{retain: boolean}} [args] - Whether this becomes the ambient retained connection.
98
118
  * @returns {Promise<import("../drivers/base.js").default>} - Checked-out connection.
99
119
  */
100
- async checkoutForConfiguration(databaseConfiguration, options, {retain}) {
120
+ async checkoutForConfiguration(databaseConfiguration, options = {}, {retain} = {retain: false}) {
101
121
  const reuseKey = this.getConfigurationReuseKey(databaseConfiguration)
102
122
  let entry = this.connectionEntries.get(reuseKey)
103
123
 
@@ -460,6 +480,7 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
460
480
 
461
481
  this.connectionEntries.clear()
462
482
  this.connectionEntrySpawnPromises.clear()
483
+ this.testSharedConnectionsByReuseKey.clear()
463
484
  this.connection = undefined
464
485
  this.activeCheckoutCount = 0
465
486
 
@@ -476,6 +497,46 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
476
497
  return this.connection
477
498
  }
478
499
 
500
+ /**
501
+ * Registers an attempt-owned connection for exactly one physical configuration.
502
+ * @param {import("../drivers/base.js").default} connection - Attempt-owned connection.
503
+ * @param {string} reuseKey - Resolved physical configuration identity.
504
+ * @returns {import("./base.js").TestSharedConnectionRegistration} - Opaque registration handle.
505
+ */
506
+ setTestSharedConnectionForConfiguration(connection, reuseKey) {
507
+ const registration = {owner: Symbol("test-shared-physical-connection")}
508
+
509
+ this.testSharedConnectionsByReuseKey.set(reuseKey, {connection, registration})
510
+ return registration
511
+ }
512
+
513
+ /**
514
+ * Clears an attempt-owned shared physical connection without revoking a newer owner.
515
+ * @param {import("./base.js").TestSharedConnectionRegistration} [registration] - Registration to clear conditionally.
516
+ */
517
+ clearTestSharedConnection(registration) {
518
+ if (!registration) {
519
+ this.testSharedConnectionsByReuseKey.clear()
520
+ return
521
+ }
522
+
523
+ for (const [reuseKey, entry] of this.testSharedConnectionsByReuseKey) {
524
+ if (entry.registration !== registration) continue
525
+ this.testSharedConnectionsByReuseKey.delete(reuseKey)
526
+ return
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Resolves the attempt-owned connection for the current physical configuration.
532
+ * @returns {import("../drivers/base.js").default | undefined} - Shared connection.
533
+ */
534
+ testSharedConnection() {
535
+ const reuseKey = this.getConfigurationReuseKey()
536
+
537
+ return this.testSharedConnectionsByReuseKey.get(reuseKey)?.connection
538
+ }
539
+
479
540
  /**
480
541
  * Returns the current context fallback connection when it is not suppressed.
481
542
  * @returns {import("../drivers/base.js").default | undefined} - Current fallback connection.
@@ -483,7 +544,7 @@ export default class VelociousDatabasePoolSingleMultiUser extends BasePool {
483
544
  getCurrentContextConnection() {
484
545
  if (this.suppressedConnectionContextCount > 0) return undefined
485
546
 
486
- return this.connection
547
+ return this.testSharedConnection() || this.connection
487
548
  }
488
549
 
489
550
  /**
@@ -1,8 +1,34 @@
1
1
  // @ts-check
2
2
 
3
- /** @type {WeakMap<object, {coordinator: (callback: () => Promise<unknown>) => Promise<unknown>, owner: symbol}>} */
3
+ /** @typedef {{coordinator: (callback: () => Promise<unknown>) => Promise<unknown>, ownedQueue: Promise<void>, owner: symbol}} CoordinatorRegistration */
4
+
5
+ /** @type {WeakMap<object, CoordinatorRegistration>} */
4
6
  const coordinators = new WeakMap()
5
7
 
8
+ /**
9
+ * Serializes sibling work that inherited one coordinator owner without re-entering the broker queue.
10
+ * @template T
11
+ * @param {CoordinatorRegistration} registration - Physical connection registration.
12
+ * @param {() => Promise<T>} callback - Owned operation.
13
+ * @returns {Promise<T>} - Operation result.
14
+ */
15
+ async function coordinateOwnedSharedTransactionConnection(registration, callback) {
16
+ const previous = registration.ownedQueue
17
+ /**
18
+ * Releases the next owned sibling operation.
19
+ * @type {() => void}
20
+ */
21
+ let release = () => {}
22
+
23
+ registration.ownedQueue = new Promise((resolve) => { release = resolve })
24
+ await previous
25
+ try {
26
+ return await callback()
27
+ } finally {
28
+ release()
29
+ }
30
+ }
31
+
6
32
  /**
7
33
  * Registers test-only serialization owned by the active broker.
8
34
  * @param {object} connection - Parent physical connection.
@@ -12,7 +38,7 @@ const coordinators = new WeakMap()
12
38
  export function setSharedTransactionCoordinator(connection, coordinator) {
13
39
  const owner = Symbol("shared-transaction-coordinator")
14
40
 
15
- coordinators.set(connection, {coordinator, owner})
41
+ coordinators.set(connection, {coordinator, ownedQueue: Promise.resolve(), owner})
16
42
  return owner
17
43
  }
18
44
 
@@ -43,7 +69,7 @@ export async function coordinateSharedTransactionConnection(connection, callback
43
69
  const environmentHandler = connection.configuration.getEnvironmentHandler()
44
70
 
45
71
  if (environmentHandler.getSharedTransactionCoordinatorOwner(connection) === registration.owner) {
46
- return await callback()
72
+ return await coordinateOwnedSharedTransactionConnection(registration, callback)
47
73
  }
48
74
 
49
75
  return /** @type {T} */ (await registration.coordinator(async () => {
@@ -39,6 +39,7 @@ import { SHARED_TRANSACTION_BROKER_ENV } from "./shared-transaction-proxy-driver
39
39
  * @property {string[] | string} [tags] - Tags for filtering.
40
40
  * @property {number} [timeoutSeconds] - Timeout in seconds for the test.
41
41
  * @property {string} [type] - Test type identifier.
42
+ * @property {(args: {databaseIdentifier: string, tenant: object}) => Promise<void>} [registerTransactionalTenant] - Registers one resolved tenant database transaction for this attempt.
42
43
  */
43
44
  /**
44
45
  * TestData type.
@@ -118,6 +119,18 @@ import { SHARED_TRANSACTION_BROKER_ENV } from "./shared-transaction-proxy-driver
118
119
  * @property {boolean} environmentPublished - Whether child-process coordinates were published.
119
120
  * @property {string | undefined} previousEnvironment - Environment value to restore after publication.
120
121
  */
122
+ /**
123
+ * TransactionalTenantRegistration type.
124
+ * @typedef {object} TransactionalTenantRegistration
125
+ * @property {Promise<{connection: import("../database/drivers/base.js").default | undefined, error: Error | undefined}> | undefined} [checkoutPromise] - Attempt-owned physical checkout outcome.
126
+ * @property {import("../database/drivers/base.js").default | undefined} connection - Attempt-owned physical connection once checkout resolves.
127
+ * @property {Promise<void> | undefined} [cleanupPromise] - Single cleanup operation shared by emergency and eventual lifecycle cleanup.
128
+ * @property {boolean | undefined} [discardOnCleanup] - Whether timeout emergency cleanup must quarantine this connection.
129
+ * @property {import("../database/pool/base.js").default} pool - Owning logical pool.
130
+ * @property {boolean} revoked - Whether this attempt may still publish the physical registration.
131
+ * @property {string} reuseKey - Resolved physical configuration identity.
132
+ * @property {import("../database/pool/base.js").TestSharedConnectionRegistration | undefined} sharedRegistration - Physical-key shared registration once published.
133
+ */
121
134
 
122
135
  /**
123
136
  * Runs run with timeout.
@@ -641,6 +654,138 @@ export default class TestRunner {
641
654
  }
642
655
  }
643
656
 
657
+ /**
658
+ * Checks out and registers one physical tenant transaction for the current attempt.
659
+ * @param {{databaseIdentifier: string, tenant: object}} args - Logical identifier and tenant descriptor.
660
+ * @param {TransactionalTenantRegistration[]} registrations - Current attempt registrations.
661
+ * @returns {Promise<void>}
662
+ */
663
+ async registerTransactionalTenant({databaseIdentifier, tenant, ...restArgs}, registrations) {
664
+ restArgsError(restArgs)
665
+ if (!databaseIdentifier) throw new Error("registerTransactionalTenant requires a databaseIdentifier")
666
+ if (!tenant) throw new Error("registerTransactionalTenant requires a tenant")
667
+
668
+ const configuration = this.getConfiguration()
669
+ const pool = configuration.getDatabasePool(databaseIdentifier)
670
+ const databaseConfiguration = configuration.resolveDatabaseConfiguration(databaseIdentifier, tenant)
671
+ if (!databaseConfiguration.tenantOnly) {
672
+ throw new Error(`registerTransactionalTenant requires a tenantOnly database: ${databaseIdentifier}`)
673
+ }
674
+ const reuseKey = pool.getConfigurationReuseKey(databaseConfiguration)
675
+ if (registrations.some((registration) => registration.pool === pool && registration.reuseKey === reuseKey)) return
676
+
677
+ /** @type {TransactionalTenantRegistration} */
678
+ const registration = {
679
+ connection: undefined,
680
+ pool,
681
+ reuseKey,
682
+ revoked: false,
683
+ sharedRegistration: undefined
684
+ }
685
+
686
+ registrations.push(registration)
687
+ registration.checkoutPromise = pool
688
+ .checkoutForConfiguration(databaseConfiguration, {name: "Transactional tenant test registration"})
689
+ .then(
690
+ (connection) => ({connection, error: undefined}),
691
+ (error) => ({
692
+ connection: undefined,
693
+ error: error instanceof Error ? error : new Error("Transactional tenant connection checkout failed", {cause: error})
694
+ })
695
+ )
696
+
697
+ try {
698
+ const checkoutOutcome = await registration.checkoutPromise
699
+
700
+ if (checkoutOutcome.error) throw checkoutOutcome.error
701
+ if (!checkoutOutcome.connection) throw new Error("Transactional tenant connection checkout returned no connection")
702
+ registration.connection = checkoutOutcome.connection
703
+ if (registration.revoked) throw new Error("Transactional tenant test registration attempt is no longer active")
704
+
705
+ await registration.connection.startTransaction()
706
+ if (registration.revoked) throw new Error("Transactional tenant test registration attempt is no longer active")
707
+
708
+ const sharedRegistration = pool.setTestSharedConnectionForConfiguration(registration.connection, reuseKey)
709
+ if (!sharedRegistration) throw new Error(`Database pool does not support transactional tenant test connections: ${databaseIdentifier}`)
710
+ registration.sharedRegistration = sharedRegistration
711
+ if (registration.revoked) {
712
+ pool.clearTestSharedConnection(sharedRegistration)
713
+ throw new Error("Transactional tenant test registration attempt is no longer active")
714
+ }
715
+ } catch (error) {
716
+ registration.revoked = true
717
+ try {
718
+ await this.cleanupTransactionalTenants([registration], {discard: registration.discardOnCleanup === true})
719
+ } catch (cleanupError) {
720
+ throw new AggregateError([error, cleanupError], "Failed to register and clean up a transactional tenant test connection", {cause: cleanupError})
721
+ }
722
+ throw error
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Revokes attempt registrations before rolling back and releasing their connections.
728
+ * @param {TransactionalTenantRegistration[]} registrations - Attempt registrations.
729
+ * @param {{discard?: boolean}} [options] - Whether connections must be discarded instead of returned to the pool.
730
+ * @returns {Promise<void>}
731
+ */
732
+ async cleanupTransactionalTenants(registrations, {discard = false} = {}) {
733
+ for (const registration of registrations) {
734
+ registration.revoked = true
735
+ if (discard) registration.discardOnCleanup = true
736
+ if (registration.sharedRegistration) registration.pool.clearTestSharedConnection(registration.sharedRegistration)
737
+ }
738
+ const cleanupResults = await Promise.allSettled([...registrations].reverse().map((registration) => {
739
+ registration.cleanupPromise ??= this.cleanupTransactionalTenantRegistration(registration)
740
+
741
+ return registration.cleanupPromise
742
+ }))
743
+ const errors = cleanupResults
744
+ .filter((result) => result.status === "rejected")
745
+ .map((result) => result.reason)
746
+
747
+ if (errors.length === 1) throw errors[0]
748
+ if (errors.length > 1) throw new AggregateError(errors, "Failed to clean up transactional tenant test connections")
749
+ }
750
+
751
+ /**
752
+ * Cleans one attempt registration exactly once, including a checkout that was still pending at revocation.
753
+ * @param {TransactionalTenantRegistration} registration - Attempt-owned registration.
754
+ * @returns {Promise<void>} - Resolves after rollback and release or quarantine.
755
+ */
756
+ async cleanupTransactionalTenantRegistration(registration) {
757
+ let connection = registration.connection
758
+
759
+ if (!connection && registration.checkoutPromise) {
760
+ const checkoutOutcome = await registration.checkoutPromise
761
+
762
+ if (checkoutOutcome.error) return
763
+ connection = checkoutOutcome.connection
764
+ registration.connection = connection
765
+ }
766
+ if (!connection) return
767
+
768
+ const errors = []
769
+
770
+ try {
771
+ if (connection.insideTransaction()) await connection.rollbackTransaction()
772
+ } catch (error) {
773
+ errors.push(error)
774
+ } finally {
775
+ try {
776
+ if (registration.discardOnCleanup) {
777
+ await registration.pool.discard(connection)
778
+ } else {
779
+ await registration.pool.checkin(connection)
780
+ }
781
+ } catch (error) {
782
+ errors.push(error)
783
+ }
784
+ }
785
+ if (errors.length === 1) throw errors[0]
786
+ if (errors.length > 1) throw new AggregateError(errors, "Failed to clean up a transactional tenant test connection")
787
+ }
788
+
644
789
  /**
645
790
  * Selects the current non-tenant connections eligible for shared transaction work.
646
791
  * @param {{transactionsOnly: boolean}} args - Selection options.
@@ -1014,6 +1159,29 @@ export default class TestRunner {
1014
1159
  console.error(error)
1015
1160
  }
1016
1161
 
1162
+ /**
1163
+ * Records a rollback/discard failure that settled after timeout cleanup moved on.
1164
+ * @param {unknown} reason - Detached cleanup rejection.
1165
+ * @returns {void}
1166
+ */
1167
+ recordTransactionalTenantCleanupFailure(reason) {
1168
+ const error = reason instanceof Error ? reason : new Error(`Transactional tenant cleanup failed: ${String(reason)}`)
1169
+ const near = this._lastTestContext
1170
+ const attribution = near ? `, near test: ${near.fullDescription} (${near.filePath}:${near.line})` : ""
1171
+
1172
+ this._failedTests = (this._failedTests || 0) + 1
1173
+ this._failedTestDetails.push({
1174
+ fullDescription: `<transactional tenant emergency cleanup failure${attribution}>`,
1175
+ filePath: near ? near.filePath : "<test runner>",
1176
+ line: near ? near.line : 0,
1177
+ error,
1178
+ consoleOutput: undefined
1179
+ })
1180
+
1181
+ console.error(picocolors.red(`\n[test-runner] transactional tenant emergency cleanup failed after the bounded timeout grace.${attribution}`))
1182
+ console.error(error)
1183
+ }
1184
+
1017
1185
  async run() {
1018
1186
  /**
1019
1187
  * Handles a process-level unhandled rejection during the run.
@@ -1232,6 +1400,11 @@ export default class TestRunner {
1232
1400
  let sharedTransactionBrokerRegistration
1233
1401
  /** @type {SharedTransactionBrokerRegistration | undefined} */
1234
1402
  let sharedTransactionBrokerPreparation
1403
+ /** @type {TransactionalTenantRegistration[]} */
1404
+ const transactionalTenantRegistrations = []
1405
+ testArgs.registerTransactionalTenant = async (args) => {
1406
+ await this.registerTransactionalTenant(args, transactionalTenantRegistrations)
1407
+ }
1235
1408
  const stopConsoleCapture = this.startConsoleCapture({
1236
1409
  passthrough: testConfig.consoleOutput === "live"
1237
1410
  })
@@ -1324,15 +1497,19 @@ export default class TestRunner {
1324
1497
  sharedTransactionBrokerRegistration = undefined
1325
1498
  sharedTransactionBrokerPreparation = undefined
1326
1499
  } finally {
1327
- for (const afterEachData of newAfterEaches) {
1328
- await this.runProfileSpan({
1329
- phase: "afterEach",
1330
- declarationIndex: afterEachData.declarationIndex,
1331
- declarationScopeId: afterEachData.declarationScopeId,
1332
- filePath: afterEachData.ownerFilePath
1333
- }, async () => {
1334
- await afterEachData.callback({configuration: this.getConfiguration(), testArgs, testData})
1335
- })
1500
+ try {
1501
+ for (const afterEachData of newAfterEaches) {
1502
+ await this.runProfileSpan({
1503
+ phase: "afterEach",
1504
+ declarationIndex: afterEachData.declarationIndex,
1505
+ declarationScopeId: afterEachData.declarationScopeId,
1506
+ filePath: afterEachData.ownerFilePath
1507
+ }, async () => {
1508
+ await afterEachData.callback({configuration: this.getConfiguration(), testArgs, testData})
1509
+ })
1510
+ }
1511
+ } finally {
1512
+ await this.cleanupTransactionalTenants(transactionalTenantRegistrations)
1336
1513
  }
1337
1514
  }
1338
1515
  }
@@ -1401,6 +1578,18 @@ export default class TestRunner {
1401
1578
  await this.stopSharedTransactionBroker(sharedTransactionBrokerRegistration || sharedTransactionBrokerPreparation)
1402
1579
  sharedTransactionBrokerRegistration = undefined
1403
1580
  sharedTransactionBrokerPreparation = undefined
1581
+ const emergencyCleanup = this.cleanupTransactionalTenants(transactionalTenantRegistrations, {discard: true})
1582
+ const emergencyCleanupSettled = await awaitSettledOrGrace(emergencyCleanup, timeoutMs ?? 60000)
1583
+
1584
+ if (emergencyCleanupSettled) {
1585
+ await emergencyCleanup
1586
+ } else {
1587
+ // The timed-out attempt must not block the runner indefinitely, but a
1588
+ // later rollback/discard failure still becomes a visible test failure.
1589
+ void emergencyCleanup.catch((cleanupError) => {
1590
+ this.recordTransactionalTenantCleanupFailure(cleanupError)
1591
+ })
1592
+ }
1404
1593
  }
1405
1594
 
1406
1595
  willRetry = retriesUsed < retryCount