velocious 1.0.618 → 1.0.620

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 (61) hide show
  1. package/README.md +4 -2
  2. package/build/configuration.js +1 -1
  3. package/build/database/pool/async-tracked-multi-connection.js +25 -5
  4. package/build/database/pool/base.js +20 -10
  5. package/build/environment-handlers/base.js +9 -1
  6. package/build/environment-handlers/node.js +22 -4
  7. package/build/src/configuration.js +2 -2
  8. package/build/src/database/pool/async-tracked-multi-connection.d.ts +19 -1
  9. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  10. package/build/src/database/pool/async-tracked-multi-connection.js +26 -4
  11. package/build/src/database/pool/base.d.ts +12 -1
  12. package/build/src/database/pool/base.d.ts.map +1 -1
  13. package/build/src/database/pool/base.js +20 -11
  14. package/build/src/environment-handlers/base.d.ts +9 -1
  15. package/build/src/environment-handlers/base.d.ts.map +1 -1
  16. package/build/src/environment-handlers/base.js +9 -2
  17. package/build/src/environment-handlers/node.d.ts +10 -2
  18. package/build/src/environment-handlers/node.d.ts.map +1 -1
  19. package/build/src/environment-handlers/node.js +24 -5
  20. package/build/src/testing/shared-transaction-broker-client.d.ts +4 -2
  21. package/build/src/testing/shared-transaction-broker-client.d.ts.map +1 -1
  22. package/build/src/testing/shared-transaction-broker-client.js +5 -3
  23. package/build/src/testing/shared-transaction-broker.d.ts +21 -0
  24. package/build/src/testing/shared-transaction-broker.d.ts.map +1 -1
  25. package/build/src/testing/shared-transaction-broker.js +61 -18
  26. package/build/src/testing/shared-transaction-connection-coordinator.d.ts +2 -0
  27. package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
  28. package/build/src/testing/shared-transaction-connection-coordinator.js +79 -12
  29. package/build/src/testing/shared-transaction-proxy-driver.d.ts +22 -3
  30. package/build/src/testing/shared-transaction-proxy-driver.d.ts.map +1 -1
  31. package/build/src/testing/shared-transaction-proxy-driver.js +51 -14
  32. package/build/src/testing/test-transaction-session.d.ts +129 -0
  33. package/build/src/testing/test-transaction-session.d.ts.map +1 -0
  34. package/build/src/testing/test-transaction-session.js +255 -0
  35. package/build/src/testing/test.d.ts +1 -1
  36. package/build/src/testing/test.d.ts.map +1 -1
  37. package/build/src/testing/test.js +2 -2
  38. package/build/testing/shared-transaction-broker-client.js +4 -2
  39. package/build/testing/shared-transaction-broker.js +58 -16
  40. package/build/testing/shared-transaction-connection-coordinator.js +83 -10
  41. package/build/testing/shared-transaction-proxy-driver.js +52 -13
  42. package/build/testing/test-transaction-session.js +240 -0
  43. package/build/testing/test.js +1 -1
  44. package/build/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +2 -1
  46. package/src/configuration.js +1 -1
  47. package/src/database/pool/async-tracked-multi-connection.js +25 -5
  48. package/src/database/pool/base.js +20 -10
  49. package/src/environment-handlers/base.js +9 -1
  50. package/src/environment-handlers/node.js +22 -4
  51. package/src/testing/shared-transaction-broker-client.js +4 -2
  52. package/src/testing/shared-transaction-broker.js +58 -16
  53. package/src/testing/shared-transaction-connection-coordinator.js +83 -10
  54. package/src/testing/shared-transaction-proxy-driver.js +52 -13
  55. package/src/testing/test-transaction-session.js +240 -0
  56. package/src/testing/test.js +1 -1
  57. package/build/src/testing/wait-for-event.d.ts +0 -46
  58. package/build/src/testing/wait-for-event.d.ts.map +0 -1
  59. package/build/src/testing/wait-for-event.js +0 -64
  60. package/build/testing/wait-for-event.js +0 -72
  61. package/src/testing/wait-for-event.js +0 -72
@@ -1,18 +1,30 @@
1
1
  // @ts-check
2
2
 
3
- /** @typedef {{coordinator: (callback: () => Promise<unknown>) => Promise<unknown>, ownedQueue: Promise<void>, owner: symbol}} CoordinatorRegistration */
3
+ /** @typedef {{coordinator: (callback: () => Promise<unknown>) => Promise<unknown>, ownedQueue: Promise<void>, owner: symbol, reentrantOwners: Set<symbol>, rootOwners: Set<symbol>}} CoordinatorRegistration */
4
4
 
5
5
  /** @type {WeakMap<object, CoordinatorRegistration>} */
6
6
  const coordinators = new WeakMap()
7
+ /** @type {WeakMap<object, CoordinatorRegistration>} */
8
+ const connectionRegistrations = new WeakMap()
9
+
10
+ /**
11
+ * Runs work directly when only the connection-local queue remains registered.
12
+ * @param {() => Promise<unknown>} callback - Serialized operation.
13
+ * @returns {Promise<unknown>} - Operation result.
14
+ */
15
+ async function inactiveCoordinator(callback) {
16
+ return await callback()
17
+ }
7
18
 
8
19
  /**
9
20
  * Serializes sibling work that inherited one coordinator owner without re-entering the broker queue.
10
21
  * @template T
22
+ * @param {import("../database/drivers/base.js").default} connection - Parent physical connection.
11
23
  * @param {CoordinatorRegistration} registration - Physical connection registration.
12
24
  * @param {() => Promise<T>} callback - Owned operation.
13
25
  * @returns {Promise<T>} - Operation result.
14
26
  */
15
- async function coordinateOwnedSharedTransactionConnection(registration, callback) {
27
+ async function coordinateOwnedSharedTransactionConnection(connection, registration, callback) {
16
28
  const previous = registration.ownedQueue
17
29
  /**
18
30
  * Releases the next owned sibling operation.
@@ -22,13 +34,54 @@ async function coordinateOwnedSharedTransactionConnection(registration, callback
22
34
 
23
35
  registration.ownedQueue = new Promise((resolve) => { release = resolve })
24
36
  await previous
37
+ const operationOwner = Symbol("shared-transaction-owned-operation")
38
+ const environmentHandler = connection.configuration.getEnvironmentHandler()
39
+
40
+ registration.reentrantOwners.add(operationOwner)
25
41
  try {
26
- return await callback()
42
+ return await environmentHandler.runWithSharedTransactionCoordinatorOwner(connection, operationOwner, callback)
27
43
  } finally {
44
+ registration.reentrantOwners.delete(operationOwner)
28
45
  release()
29
46
  }
30
47
  }
31
48
 
49
+ /**
50
+ * Drains all inherited operations admitted before the root owner is revoked.
51
+ * @param {CoordinatorRegistration} registration - Physical connection registration.
52
+ * @returns {Promise<void>} - Resolves when the owned queue stops advancing.
53
+ */
54
+ async function drainOwnedSharedTransactionConnections(registration) {
55
+ let tail
56
+
57
+ do {
58
+ tail = registration.ownedQueue
59
+ await tail
60
+ } while (tail !== registration.ownedQueue)
61
+ }
62
+
63
+ /**
64
+ * Runs one broker-serialized root while tracking and draining its inherited work.
65
+ * @template T
66
+ * @param {import("../database/drivers/base.js").default} connection - Parent physical connection.
67
+ * @param {CoordinatorRegistration} registration - Physical connection registration.
68
+ * @param {() => Promise<T>} callback - Root operation.
69
+ * @returns {Promise<T>} - Operation result.
70
+ */
71
+ async function coordinateRootSharedTransactionConnection(connection, registration, callback) {
72
+ await drainOwnedSharedTransactionConnections(registration)
73
+ const rootOwner = Symbol("shared-transaction-root-operation")
74
+ const environmentHandler = connection.configuration.getEnvironmentHandler()
75
+
76
+ registration.rootOwners.add(rootOwner)
77
+ try {
78
+ return await environmentHandler.runWithSharedTransactionCoordinatorOwner(connection, rootOwner, callback)
79
+ } finally {
80
+ await drainOwnedSharedTransactionConnections(registration)
81
+ registration.rootOwners.delete(rootOwner)
82
+ }
83
+ }
84
+
32
85
  /**
33
86
  * Registers test-only serialization owned by the active broker.
34
87
  * @param {object} connection - Parent physical connection.
@@ -37,8 +90,17 @@ async function coordinateOwnedSharedTransactionConnection(registration, callback
37
90
  */
38
91
  export function setSharedTransactionCoordinator(connection, coordinator) {
39
92
  const owner = Symbol("shared-transaction-coordinator")
93
+ let registration = connectionRegistrations.get(connection)
94
+
95
+ if (registration) {
96
+ registration.coordinator = coordinator
97
+ registration.owner = owner
98
+ } else {
99
+ registration = {coordinator, ownedQueue: Promise.resolve(), owner, reentrantOwners: new Set(), rootOwners: new Set()}
100
+ connectionRegistrations.set(connection, registration)
101
+ }
40
102
 
41
- coordinators.set(connection, {coordinator, ownedQueue: Promise.resolve(), owner})
103
+ coordinators.set(connection, registration)
42
104
  return owner
43
105
  }
44
106
 
@@ -49,7 +111,12 @@ export function setSharedTransactionCoordinator(connection, coordinator) {
49
111
  * @returns {void}
50
112
  */
51
113
  export function clearSharedTransactionCoordinator(connection, coordinator) {
52
- if (coordinators.get(connection)?.coordinator === coordinator) coordinators.delete(connection)
114
+ const registration = coordinators.get(connection)
115
+
116
+ if (registration?.coordinator === coordinator) {
117
+ coordinators.delete(connection)
118
+ registration.coordinator = inactiveCoordinator
119
+ }
53
120
  }
54
121
 
55
122
  /**
@@ -61,18 +128,24 @@ export function clearSharedTransactionCoordinator(connection, coordinator) {
61
128
  * @returns {Promise<T>} - Operation result.
62
129
  */
63
130
  export async function coordinateSharedTransactionConnection(connection, callback, operationOwner) {
64
- const registration = coordinators.get(connection)
131
+ const activeRegistration = coordinators.get(connection)
132
+ const registration = activeRegistration || connectionRegistrations.get(connection)
65
133
 
66
134
  if (!registration) return await callback()
67
- if (operationOwner === registration.owner) return await callback()
68
135
 
69
136
  const environmentHandler = connection.configuration.getEnvironmentHandler()
137
+ const currentOwner = environmentHandler.getSharedTransactionCoordinatorOwner(connection)
70
138
 
71
- if (environmentHandler.getSharedTransactionCoordinatorOwner(connection) === registration.owner) {
72
- return await coordinateOwnedSharedTransactionConnection(registration, callback)
139
+ if (currentOwner && registration.reentrantOwners.has(currentOwner)) return await callback()
140
+ if (currentOwner && registration.rootOwners.has(currentOwner)) {
141
+ return await coordinateOwnedSharedTransactionConnection(connection, registration, callback)
142
+ }
143
+ if (!activeRegistration) return await coordinateOwnedSharedTransactionConnection(connection, registration, callback)
144
+ if (operationOwner === registration.owner) {
145
+ return await coordinateRootSharedTransactionConnection(connection, registration, callback)
73
146
  }
74
147
 
75
148
  return /** @type {T} */ (await registration.coordinator(async () => {
76
- return await environmentHandler.runWithSharedTransactionCoordinatorOwner(connection, registration.owner, callback)
149
+ return await coordinateRootSharedTransactionConnection(connection, registration, callback)
77
150
  }))
78
151
  }
@@ -1,4 +1,4 @@
1
- /** @typedef {{address?: string, capability?: string, databaseIdentifiers?: string[], expected: boolean}} SharedTransactionBrokerJobConfig */
1
+ /** @typedef {{address?: string, allowDynamicIdentities?: boolean, capability?: string, databaseIdentifiers?: string[], expected: boolean}} SharedTransactionBrokerJobConfig */
2
2
 
3
3
  // @ts-check
4
4
 
@@ -11,6 +11,19 @@ export const BACKGROUND_JOB_CHILD_ENV = "VELOCIOUS_BACKGROUND_JOB_CHILD"
11
11
  /** @type {AsyncLocalStorage<SharedTransactionBrokerJobConfig>} */
12
12
  const pooledJobBrokerConfig = new AsyncLocalStorage()
13
13
 
14
+ /**
15
+ * Returns the active live broker configuration without validating one database route.
16
+ * @returns {SharedTransactionBrokerJobConfig | undefined} - Active live or child configuration.
17
+ */
18
+ function activeSharedTransactionBrokerConfig() {
19
+ const contextualConfig = pooledJobBrokerConfig.getStore()
20
+ if (contextualConfig) return contextualConfig
21
+ if (process.env[BACKGROUND_JOB_CHILD_ENV] !== "1") return undefined
22
+ const serialized = process.env[SHARED_TRANSACTION_BROKER_ENV]
23
+ if (!serialized) return undefined
24
+ return JSON.parse(Buffer.from(serialized, "base64url").toString("utf8"))
25
+ }
26
+
14
27
  /**
15
28
  * Runs one pooled job with dispatch-time broker configuration.
16
29
  * @template T
@@ -22,6 +35,34 @@ export function runWithSharedTransactionBrokerConfig(config, callback) {
22
35
  return pooledJobBrokerConfig.run(config, callback)
23
36
  }
24
37
 
38
+ /**
39
+ * Checks whether the current live join selects one exact session capability.
40
+ * @param {{address: string, capability: string}} identity - Session control-message identity.
41
+ * @returns {boolean} - Whether this async context belongs to that session.
42
+ */
43
+ export function sharedTransactionBrokerContextMatches(identity) {
44
+ const config = pooledJobBrokerConfig.getStore()
45
+ return config?.expected === true && config.address === identity.address && config.capability === identity.capability
46
+ }
47
+
48
+ /**
49
+ * Preserves legacy real tenant connections omitted by automatic TestRunner mode.
50
+ * Explicit dynamic sessions never permit this fallback.
51
+ * @param {string} databaseIdentifier - Logical database identifier.
52
+ * @returns {boolean} - Whether an omitted automatic route stays independent.
53
+ */
54
+ export function automaticSharedTransactionBrokerOmits(databaseIdentifier) {
55
+ const config = activeSharedTransactionBrokerConfig()
56
+ return Boolean(
57
+ config?.expected === true &&
58
+ !config.allowDynamicIdentities &&
59
+ typeof config.address === "string" &&
60
+ typeof config.capability === "string" &&
61
+ Array.isArray(config.databaseIdentifiers) &&
62
+ !config.databaseIdentifiers.includes(databaseIdentifier)
63
+ )
64
+ }
65
+
25
66
  /**
26
67
  * Escapes a PostgreSQL literal without requiring a live child connection.
27
68
  * @param {ReturnType<typeof JSON.parse>} value - PostgreSQL literal value.
@@ -49,24 +90,19 @@ function pgEscapeLiteral(value) {
49
90
  * Parses the test-runner-owned child transport configuration when this logical
50
91
  * database is registered for the active attempt.
51
92
  * @param {string} databaseIdentifier - Logical database identifier.
52
- * @returns {{address: string, capability: string} | undefined} - Broker coordinates.
93
+ * @returns {{address: string, allowDynamicIdentities?: boolean, capability: string} | undefined} - Broker coordinates.
53
94
  */
54
95
  export function sharedTransactionBrokerConfig(databaseIdentifier) {
55
- const contextualConfig = pooledJobBrokerConfig.getStore()
56
- if (contextualConfig) return validatedBrokerConfig(contextualConfig, databaseIdentifier)
57
- if (process.env[BACKGROUND_JOB_CHILD_ENV] !== "1") return undefined
58
-
59
- const serialized = process.env[SHARED_TRANSACTION_BROKER_ENV]
60
- if (!serialized) return undefined
61
-
62
- return validatedBrokerConfig(JSON.parse(Buffer.from(serialized, "base64url").toString("utf8")), databaseIdentifier)
96
+ const config = activeSharedTransactionBrokerConfig()
97
+ if (!config) return undefined
98
+ return validatedBrokerConfig(config, databaseIdentifier)
63
99
  }
64
100
 
65
101
  /**
66
102
  * Validates dispatch-time broker configuration and fails closed when expected.
67
103
  * @param {SharedTransactionBrokerJobConfig} config - Candidate configuration.
68
104
  * @param {string} databaseIdentifier - Logical database identifier.
69
- * @returns {{address: string, capability: string} | undefined} - Broker coordinates.
105
+ * @returns {{address: string, allowDynamicIdentities?: boolean, capability: string} | undefined} - Broker coordinates.
70
106
  */
71
107
  function validatedBrokerConfig(config, databaseIdentifier) {
72
108
  if (config.expected && (!config.address || !config.capability || !config.databaseIdentifiers)) {
@@ -76,10 +112,13 @@ function validatedBrokerConfig(config, databaseIdentifier) {
76
112
  if (typeof config.address !== "string" || typeof config.capability !== "string" || !Array.isArray(config.databaseIdentifiers)) {
77
113
  throw new Error("Invalid shared transaction broker child configuration")
78
114
  }
79
- if (!config.databaseIdentifiers.includes(databaseIdentifier)) {
115
+ if (!config.allowDynamicIdentities && !config.databaseIdentifiers.includes(databaseIdentifier)) {
80
116
  throw new Error(`Transactional pooled job expected broker database identifier: ${databaseIdentifier}`)
81
117
  }
82
118
 
119
+ if (config.allowDynamicIdentities) {
120
+ return {address: config.address, allowDynamicIdentities: true, capability: config.capability}
121
+ }
83
122
  return {address: config.address, capability: config.capability}
84
123
  }
85
124
 
@@ -90,7 +129,7 @@ function validatedBrokerConfig(config, databaseIdentifier) {
90
129
  * @param {import("../configuration-types.js").DatabaseConfigurationType} config - Database configuration.
91
130
  * @param {import("../configuration.js").default} configuration - Child configuration.
92
131
  * @param {string} databaseIdentifier - Logical identifier.
93
- * @param {{address: string, capability: string}} brokerConfig - Broker coordinates.
132
+ * @param {{address: string, capability: string, reuseKey?: string}} brokerConfig - Broker coordinates.
94
133
  * @returns {import("../database/drivers/base.js").default} - Unconnected physical proxy.
95
134
  */
96
135
  export function createSharedTransactionProxyDriver(DriverClass, config, configuration, databaseIdentifier, brokerConfig) {
@@ -0,0 +1,240 @@
1
+ // @ts-check
2
+
3
+ import SharedTransactionBroker from "./shared-transaction-broker.js"
4
+ import { runWithSharedTransactionBrokerConfig, sharedTransactionBrokerContextMatches } from "./shared-transaction-proxy-driver.js"
5
+
6
+ /** @typedef {{connection: import("../database/drivers/base.js").default, databaseIdentifier: string, release: () => Promise<void>, reuseKey: string}} Enrollment */
7
+
8
+ /**
9
+ * Backend-owned, capability-scoped transaction set for long-lived test services.
10
+ * Join coordinates are intentionally obtainable only as a live control message.
11
+ */
12
+ export default class TestTransactionSession {
13
+ /**
14
+ * Creates an unstarted transaction session.
15
+ * @param {import("../configuration.js").default} [configuration] - Backend configuration owning enrolled pools.
16
+ */
17
+ constructor(configuration) {
18
+ this.configuration = configuration
19
+ /** @type {SharedTransactionBroker | undefined} */
20
+ this.broker = undefined
21
+ /** @type {Map<string, Enrollment>} */
22
+ this.enrollments = new Map()
23
+ /** @type {Map<string, {pool: import("../database/pool/base.js").default, registration: import("../database/pool/base.js").TestSharedConnectionRegistration}>} */
24
+ this.sharedConnectionRegistrations = new Map()
25
+ /** @type {Promise<void> | undefined} */
26
+ this.cleanupPromise = undefined
27
+ /** @type {Promise<void> | undefined} */
28
+ this.rollbackPromise = undefined
29
+ }
30
+
31
+ /**
32
+ * Begins a test transaction session.
33
+ * @param {{configuration?: import("../configuration.js").default}} [args] - Backend owner.
34
+ * @returns {Promise<TestTransactionSession>} - Begun session.
35
+ */
36
+ static async begin({configuration} = {}) {
37
+ const session = new TestTransactionSession(configuration)
38
+ session.broker = await SharedTransactionBroker.start({connections: {}})
39
+ return session
40
+ }
41
+
42
+ /**
43
+ * Joins one request/job callback from a live backend control message.
44
+ * @template T
45
+ * @param {{address: string, capability: string}} message - Ephemeral coordinates received over live IPC.
46
+ * @param {() => T} callback - Backend request or worker work.
47
+ * @returns {T} - Callback result.
48
+ */
49
+ static join(message, callback) {
50
+ return runWithSharedTransactionBrokerConfig({...message, allowDynamicIdentities: true, databaseIdentifiers: [], expected: true}, callback)
51
+ }
52
+
53
+ /**
54
+ * Lazily adds an exact physical connection to the common rollback set.
55
+ * @param {Enrollment} enrollment - Checked-out physical connection and owner release hook.
56
+ */
57
+ async enroll(enrollment) {
58
+ const broker = this.requiredBroker()
59
+ const identity = `${enrollment.databaseIdentifier}\0${enrollment.reuseKey}`
60
+ const existing = this.enrollments.get(identity)
61
+ if (existing) {
62
+ if (existing.connection !== enrollment.connection) await enrollment.release()
63
+ return
64
+ }
65
+ try {
66
+ await enrollment.connection.startTransaction()
67
+ } catch (error) {
68
+ /** @type {Error | undefined} */
69
+ let releaseFailure
70
+ try {
71
+ await enrollment.release()
72
+ } catch (releaseError) {
73
+ releaseFailure = this.normalizeError(releaseError)
74
+ }
75
+ if (releaseFailure) {
76
+ throw new AggregateError(
77
+ [this.normalizeError(error), releaseFailure],
78
+ "Test transaction enrollment start and release failed",
79
+ {cause: error}
80
+ )
81
+ }
82
+ throw error
83
+ }
84
+ try {
85
+ broker.enrollConnection(enrollment)
86
+ this.enrollments.set(identity, enrollment)
87
+ } catch (error) {
88
+ await this.rollbackAndRelease(enrollment)
89
+ throw error
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Lazily checks out and enrolls the physical database selected by a tenant descriptor.
95
+ * @param {{databaseIdentifier: string, tenant?: object}} args - Logical and tenant identity.
96
+ */
97
+ async enrollDatabase({databaseIdentifier, tenant}) {
98
+ if (!this.configuration) throw new Error("Test transaction session requires a configuration to enroll a database")
99
+ const pool = this.configuration.getDatabasePool(databaseIdentifier)
100
+ const databaseConfiguration = this.configuration.resolveDatabaseConfiguration(databaseIdentifier, tenant)
101
+ const reuseKey = pool.getConfigurationReuseKey(databaseConfiguration)
102
+ const identity = `${databaseIdentifier}\0${reuseKey}`
103
+ if (this.enrollments.has(identity)) return
104
+ const connection = await this.configuration.runWithTenant(tenant, async () => {
105
+ return await pool.checkout({name: "Test transaction session"})
106
+ })
107
+ await this.enroll({
108
+ connection,
109
+ databaseIdentifier,
110
+ release: async () => { await pool.checkin(connection) },
111
+ reuseKey
112
+ })
113
+ this.installSharedConnectionProvider(databaseIdentifier, pool)
114
+ }
115
+
116
+ /**
117
+ * Makes in-process request/Scoundrel checkouts resolve by current physical identity.
118
+ * @param {string} databaseIdentifier - Logical database identifier.
119
+ * @param {import("../database/pool/base.js").default} pool - Owning pool.
120
+ */
121
+ installSharedConnectionProvider(databaseIdentifier, pool) {
122
+ if (this.sharedConnectionRegistrations.has(databaseIdentifier)) return
123
+ const broker = this.requiredBroker()
124
+ const sessionIdentity = {address: broker.address(), capability: broker.capability()}
125
+ const registration = pool.registerTestSharedConnectionProvider({
126
+ matches: () => sharedTransactionBrokerContextMatches(sessionIdentity),
127
+ provider: () => {
128
+ if (!broker.accepting) throw new Error("Test transaction session capability has been revoked")
129
+ const reuseKey = pool.getConfigurationReuseKey()
130
+ const connection = this.enrollments.get(`${databaseIdentifier}\0${reuseKey}`)?.connection
131
+ if (!connection) throw new Error(`Test transaction physical identity is not enrolled: ${databaseIdentifier}`)
132
+ return connection
133
+ }
134
+ })
135
+ if (registration) this.sharedConnectionRegistrations.set(databaseIdentifier, {pool, registration})
136
+ }
137
+
138
+ /**
139
+ * Returns ephemeral coordinates for one live IPC/control message.
140
+ * @returns {{address: string, capability: string}} - Non-durable join coordinates.
141
+ */
142
+ joinMessage() {
143
+ const broker = this.requiredBroker()
144
+ if (!broker.accepting) throw new Error("Test transaction session capability has been revoked")
145
+ return {address: broker.address(), capability: broker.capability()}
146
+ }
147
+
148
+ /** Stops admission to the capability. */
149
+ revoke() { this.requiredBroker().revoke() }
150
+
151
+ /** Drains work accepted before revocation. */
152
+ async drain() { await this.requiredBroker().drain() }
153
+
154
+ /**
155
+ * Rolls back and releases the complete enrolled set after admission stops.
156
+ * @returns {Promise<void>} - Resolves after rollback and release.
157
+ */
158
+ async rollback() {
159
+ if (this.rollbackPromise) return await this.rollbackPromise
160
+ this.rollbackPromise = this.rollbackActual()
161
+ return await this.rollbackPromise
162
+ }
163
+
164
+ /**
165
+ * Performs rollback and release once.
166
+ * @returns {Promise<void>} - Resolves after actual rollback and release.
167
+ */
168
+ async rollbackActual() {
169
+ const broker = this.requiredBroker()
170
+ if (broker.accepting) throw new Error("Test transaction session must be revoked before rollback")
171
+ /** @type {Array<Error>} */
172
+ const errors = []
173
+ try { await broker.close() } catch (error) { errors.push(this.normalizeError(error)) }
174
+ for (const {pool, registration} of this.sharedConnectionRegistrations.values()) {
175
+ pool.clearTestSharedConnection(registration)
176
+ }
177
+ this.sharedConnectionRegistrations.clear()
178
+ for (const enrollment of this.enrollments.values()) {
179
+ try { await this.rollbackAndRelease(enrollment) } catch (error) { errors.push(this.normalizeError(error)) }
180
+ }
181
+ this.enrollments.clear()
182
+ if (errors.length > 0) throw new AggregateError(errors, "Test transaction session rollback failed")
183
+ }
184
+
185
+ /**
186
+ * Revokes, drains, rolls back, and releases every enrolled physical connection exactly once.
187
+ * @returns {Promise<void>} - Resolves after idempotent cleanup.
188
+ */
189
+ async cleanup() {
190
+ if (this.cleanupPromise) return await this.cleanupPromise
191
+ this.cleanupPromise = this.cleanupActual()
192
+ return await this.cleanupPromise
193
+ }
194
+
195
+ /**
196
+ * Performs idempotent cleanup once.
197
+ * @returns {Promise<void>} - Resolves after actual cleanup.
198
+ */
199
+ async cleanupActual() {
200
+ this.revoke()
201
+ await this.rollback()
202
+ }
203
+
204
+ /**
205
+ * Returns capability-free session diagnostics.
206
+ * @returns {{accepting: boolean, enrollmentCount: number}} - Capability-free diagnostics.
207
+ */
208
+ debugSnapshot() {
209
+ return {accepting: this.broker?.accepting === true, enrollmentCount: this.enrollments.size}
210
+ }
211
+
212
+ /**
213
+ * Returns the begun broker.
214
+ * @returns {SharedTransactionBroker} - Begun broker.
215
+ */
216
+ requiredBroker() {
217
+ if (!this.broker) throw new Error("Test transaction session has not begun")
218
+ return this.broker
219
+ }
220
+
221
+ /**
222
+ * Rolls back and releases one owned physical connection.
223
+ * @param {Enrollment} enrollment - Owned physical connection.
224
+ * @returns {Promise<void>} - Resolves after rollback and release.
225
+ */
226
+ async rollbackAndRelease(enrollment) {
227
+ /** @type {Array<Error>} */
228
+ const errors = []
229
+ try { await enrollment.connection.rollbackTransaction() } catch (error) { errors.push(this.normalizeError(error)) }
230
+ try { await enrollment.release() } catch (error) { errors.push(this.normalizeError(error)) }
231
+ if (errors.length > 0) throw new AggregateError(errors, "Test transaction enrollment cleanup failed")
232
+ }
233
+
234
+ /**
235
+ * Normalizes a thrown cleanup value.
236
+ * @param {unknown} error - Opaque thrown cleanup value narrowed at this boundary.
237
+ * @returns {Error} - Error instance.
238
+ */
239
+ normalizeError(error) { return error instanceof Error ? error : new Error(String(error)) }
240
+ }
@@ -2,9 +2,9 @@
2
2
 
3
3
  import path from "path"
4
4
  import {fileURLToPath} from "url"
5
+ import { waitForEvent } from "@velocious/testing"
5
6
  import EventEmitter from "../utils/event-emitter.js"
6
7
  import Expect from "./expect.js"
7
- import waitForEvent from "./wait-for-event.js"
8
8
  import {arrayContaining, objectContaining} from "./expect-utils.js"
9
9
 
10
10
  /**