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
@@ -80,15 +80,9 @@ export default class SharedTransactionBroker extends EventEmitter {
80
80
  /** @type {Map<object, symbol>} */
81
81
  this.connectionCoordinatorOwners = new Map()
82
82
  for (const connection of new Set(Object.values(connections))) {
83
- /**
84
- * Serializes parent operations with child broker traffic.
85
- * @param {() => Promise<unknown>} callback - Parent operation.
86
- * @returns {Promise<unknown>} - Operation result.
87
- */
88
- const coordinator = async (callback) => await this.serialize(this.connectionState(connection), callback)
89
- this.connectionCoordinators.set(connection, coordinator)
90
- this.connectionCoordinatorOwners.set(connection, setSharedTransactionCoordinator(connection, coordinator))
83
+ this.installConnectionCoordinator(connection)
91
84
  }
85
+ this.physicalConnections = new Map()
92
86
  this.httpServer = createServer()
93
87
  this.websocketServer = new WebSocketServer({server: this.httpServer, maxPayload: 16 * 1024 * 1024})
94
88
  this.websocketServer.on("connection", (socket) => {
@@ -101,6 +95,37 @@ export default class SharedTransactionBroker extends EventEmitter {
101
95
  })
102
96
  }
103
97
 
98
+ /**
99
+ * Installs serialization ownership for a newly enrolled physical connection.
100
+ * @param {object} connection - Parent-owned physical connection.
101
+ * @returns {void}
102
+ */
103
+ installConnectionCoordinator(connection) {
104
+ if (this.connectionCoordinators.has(connection)) return
105
+ /**
106
+ * Serializes parent operations with child broker traffic.
107
+ * @param {() => Promise<unknown>} callback - Parent operation.
108
+ * @returns {Promise<unknown>} - Operation result.
109
+ */
110
+ const coordinator = async (callback) => await this.serialize(this.connectionState(connection), callback)
111
+ this.connectionCoordinators.set(connection, coordinator)
112
+ this.connectionCoordinatorOwners.set(connection, setSharedTransactionCoordinator(connection, coordinator))
113
+ }
114
+
115
+ /**
116
+ * Enrolls one exact physical database identity in this capability's rollback set.
117
+ * @param {{connection: object, databaseIdentifier: string, reuseKey: string}} args - Physical connection identity.
118
+ * @returns {void}
119
+ */
120
+ enrollConnection({connection, databaseIdentifier, reuseKey}) {
121
+ if (!this.accepting) throw new Error("Shared transaction broker capability has been revoked")
122
+ const identity = `${databaseIdentifier}\0${reuseKey}`
123
+ const existing = this.physicalConnections.get(identity)
124
+ if (existing && existing !== connection) throw new Error(`Shared transaction physical connection identity is already enrolled: ${databaseIdentifier}`)
125
+ this.installConnectionCoordinator(connection)
126
+ this.physicalConnections.set(identity, connection)
127
+ }
128
+
104
129
  /**
105
130
  * Starts a broker on an ephemeral loopback port.
106
131
  * @param {{connections: Record<string, object>}} args - Parent-owned physical connections.
@@ -140,18 +165,22 @@ export default class SharedTransactionBroker extends EventEmitter {
140
165
  async handleRequest(socket, serialized) {
141
166
  let requestId = 0
142
167
  try {
143
- const request = /** @type {{requestId: number, capability: string, databaseIdentifier: string, method: string, args: import("./shared-transaction-codec.js").EncodedBrokerValue}} */ (JSON.parse(serialized))
168
+ const request = /** @type {{requestId: number, capability: string, databaseIdentifier: string, reuseKey?: string, method: string, args: import("./shared-transaction-codec.js").EncodedBrokerValue}} */ (JSON.parse(serialized))
144
169
  requestId = request.requestId
145
170
  if (!this.accepting) throw new Error("Shared transaction broker capability has been revoked")
146
171
  if (!capabilityMatches(request.capability, this.secret)) throw new Error("Unknown shared transaction broker capability")
147
- const connection = this.connections[request.databaseIdentifier]
148
- if (!connection) throw new Error(`Unknown shared transaction database identifier: ${request.databaseIdentifier}`)
172
+ const connection = request.reuseKey
173
+ ? this.physicalConnections.get(`${request.databaseIdentifier}\0${request.reuseKey}`)
174
+ : this.connections[request.databaseIdentifier]
175
+ if (!connection) {
176
+ if (request.reuseKey) throw new Error(`Unenrolled physical connection identity: ${request.databaseIdentifier}`)
177
+ throw new Error(`Unknown shared transaction database identifier: ${request.databaseIdentifier}`)
178
+ }
149
179
  if (!ALLOWED_METHODS.has(request.method)) throw new Error(`Unsupported shared transaction broker method: ${request.method}`)
150
180
  const args = decodeBrokerValue(request.args)
151
181
  if (!Array.isArray(args)) throw new TypeError("Shared transaction broker arguments must be an array")
152
182
  this.emit("work-queued", {connection, databaseIdentifier: request.databaseIdentifier, method: request.method})
153
183
  const result = await this.runConnectionRequest({connection, method: request.method, savePointName: typeof args[0] === "string" ? args[0] : undefined, socket}, async () => {
154
- if (!this.accepting) throw new Error("Shared transaction broker capability has been revoked")
155
184
  if (request.method === "rootTransactionRollback") {
156
185
  await this.rollbackRootSavePoint(connection, /** @type {string} */ (args[0]))
157
186
  return undefined
@@ -258,10 +287,11 @@ export default class SharedTransactionBroker extends EventEmitter {
258
287
 
259
288
  state.queue = previous.then(async () => {
260
289
  try {
261
- if (!this.accepting || socket.readyState !== socket.OPEN) throw new Error("Shared transaction broker root transaction session closed before lease acquisition")
290
+ if (socket.readyState !== socket.OPEN) throw new Error("Shared transaction broker root transaction session closed before lease acquisition")
262
291
  const result = await callback()
263
292
  state.lease = {operations: Promise.resolve(), release, savePointName, socket}
264
293
  resolveStarted(result)
294
+ if (!this.accepting) await this.scheduleSessionCleanup(socket)
265
295
  await held
266
296
  } catch (error) {
267
297
  state.rootSessions.delete(socket)
@@ -418,17 +448,29 @@ export default class SharedTransactionBroker extends EventEmitter {
418
448
  return await this.closePromise
419
449
  }
420
450
 
451
+ /** Revokes admission without interrupting already accepted work. */
452
+ revoke() {
453
+ if (!this.accepting) return
454
+ this.accepting = false
455
+ this.secret = randomBytes(32).toString("base64url")
456
+ }
457
+
458
+ /** Drains all work accepted before capability revocation. */
459
+ async drain() {
460
+ if (this.accepting) throw new Error("Shared transaction broker must be revoked before drain")
461
+ await Promise.all(Array.from(this.connectionStates.values()).map((state) => state.queue))
462
+ }
463
+
421
464
  /**
422
465
  * Performs deterministic transport shutdown and reports cleanup failures last.
423
466
  * @returns {Promise<void>} - Resolves after shutdown or rejects with cleanup errors.
424
467
  */
425
468
  async closeTransport() {
426
- this.accepting = false
427
- this.secret = randomBytes(32).toString("base64url")
469
+ this.revoke()
428
470
  const closingSessions = Array.from(this.sessions)
429
471
  await Promise.all(closingSessions.map(async (socket) => await this.scheduleSessionCleanup(socket)))
472
+ await this.drain()
430
473
  for (const socket of closingSessions) socket.close(1001, "Shared transaction broker closed")
431
- await Promise.allSettled(Array.from(this.connectionStates.values()).map((state) => state.queue))
432
474
  await new Promise((resolve) => this.websocketServer.close(() => resolve(undefined)))
433
475
  await new Promise((resolve) => this.httpServer.close(() => resolve(undefined)))
434
476
  await Promise.all(Array.from(this.sessionCleanup.values()))
@@ -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
  /**