velocious 1.0.698 → 1.0.700

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 (95) hide show
  1. package/README.md +2 -2
  2. package/build/configuration-types.js +23 -0
  3. package/build/configuration.js +136 -1
  4. package/build/database/advisory-lock-backend-policy.js +204 -0
  5. package/build/database/advisory-lock-file-backend.js +348 -0
  6. package/build/database/advisory-lock-runner.js +239 -16
  7. package/build/database/drivers/base.js +318 -15
  8. package/build/database/drivers/mssql/index.js +1 -1
  9. package/build/database/drivers/mysql/index.js +4 -2
  10. package/build/database/drivers/pgsql/index.js +4 -2
  11. package/build/database/drivers/sqlite/base.js +13 -129
  12. package/build/database/drivers/sqlite/index.js +41 -62
  13. package/build/database/migrator.js +120 -50
  14. package/build/database/record/attachments/store.js +84 -1
  15. package/build/environment-handlers/base.js +13 -0
  16. package/build/environment-handlers/node.js +11 -0
  17. package/build/http-server/server-lock.js +21 -115
  18. package/build/src/configuration-types.d.ts +68 -0
  19. package/build/src/configuration-types.d.ts.map +1 -1
  20. package/build/src/configuration-types.js +21 -1
  21. package/build/src/configuration.d.ts +100 -1
  22. package/build/src/configuration.d.ts.map +1 -1
  23. package/build/src/configuration.js +124 -2
  24. package/build/src/database/advisory-lock-backend-policy.d.ts +145 -0
  25. package/build/src/database/advisory-lock-backend-policy.d.ts.map +1 -0
  26. package/build/src/database/advisory-lock-backend-policy.js +184 -0
  27. package/build/src/database/advisory-lock-file-backend.d.ts +194 -0
  28. package/build/src/database/advisory-lock-file-backend.d.ts.map +1 -0
  29. package/build/src/database/advisory-lock-file-backend.js +313 -0
  30. package/build/src/database/advisory-lock-runner.d.ts +116 -15
  31. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  32. package/build/src/database/advisory-lock-runner.js +190 -17
  33. package/build/src/database/drivers/base.d.ts +125 -4
  34. package/build/src/database/drivers/base.d.ts.map +1 -1
  35. package/build/src/database/drivers/base.js +282 -17
  36. package/build/src/database/drivers/mssql/index.d.ts +1 -1
  37. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  38. package/build/src/database/drivers/mssql/index.js +2 -2
  39. package/build/src/database/drivers/mysql/index.d.ts +4 -2
  40. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  41. package/build/src/database/drivers/mysql/index.js +5 -3
  42. package/build/src/database/drivers/pgsql/index.d.ts +4 -2
  43. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  44. package/build/src/database/drivers/pgsql/index.js +5 -3
  45. package/build/src/database/drivers/sqlite/base.d.ts +11 -49
  46. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  47. package/build/src/database/drivers/sqlite/base.js +14 -114
  48. package/build/src/database/drivers/sqlite/index.d.ts +28 -19
  49. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  50. package/build/src/database/drivers/sqlite/index.js +41 -59
  51. package/build/src/database/migrator.d.ts.map +1 -1
  52. package/build/src/database/migrator.js +110 -56
  53. package/build/src/database/record/attachments/store.d.ts +0 -3
  54. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  55. package/build/src/database/record/attachments/store.js +71 -2
  56. package/build/src/environment-handlers/base.d.ts +14 -0
  57. package/build/src/environment-handlers/base.d.ts.map +1 -1
  58. package/build/src/environment-handlers/base.js +13 -1
  59. package/build/src/environment-handlers/node.d.ts +11 -0
  60. package/build/src/environment-handlers/node.d.ts.map +1 -1
  61. package/build/src/environment-handlers/node.js +11 -1
  62. package/build/src/http-server/server-lock.d.ts +13 -37
  63. package/build/src/http-server/server-lock.d.ts.map +1 -1
  64. package/build/src/http-server/server-lock.js +22 -109
  65. package/build/src/testing/test-runner.d.ts +74 -0
  66. package/build/src/testing/test-runner.d.ts.map +1 -1
  67. package/build/src/testing/test-runner.js +140 -1
  68. package/build/src/testing/test-tenant-database-pool.d.ts +183 -0
  69. package/build/src/testing/test-tenant-database-pool.d.ts.map +1 -0
  70. package/build/src/testing/test-tenant-database-pool.js +388 -0
  71. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -1
  72. package/build/src/testing/velocious-attempt-executor.js +43 -1
  73. package/build/testing/test-runner.js +152 -0
  74. package/build/testing/test-tenant-database-pool.js +438 -0
  75. package/build/testing/velocious-attempt-executor.js +44 -0
  76. package/package.json +1 -1
  77. package/src/configuration-types.js +23 -0
  78. package/src/configuration.js +136 -1
  79. package/src/database/advisory-lock-backend-policy.js +204 -0
  80. package/src/database/advisory-lock-file-backend.js +348 -0
  81. package/src/database/advisory-lock-runner.js +239 -16
  82. package/src/database/drivers/base.js +318 -15
  83. package/src/database/drivers/mssql/index.js +1 -1
  84. package/src/database/drivers/mysql/index.js +4 -2
  85. package/src/database/drivers/pgsql/index.js +4 -2
  86. package/src/database/drivers/sqlite/base.js +13 -129
  87. package/src/database/drivers/sqlite/index.js +41 -62
  88. package/src/database/migrator.js +120 -50
  89. package/src/database/record/attachments/store.js +84 -1
  90. package/src/environment-handlers/base.js +13 -0
  91. package/src/environment-handlers/node.js +11 -0
  92. package/src/http-server/server-lock.js +21 -115
  93. package/src/testing/test-runner.js +152 -0
  94. package/src/testing/test-tenant-database-pool.js +438 -0
  95. package/src/testing/velocious-attempt-executor.js +44 -0
@@ -0,0 +1,438 @@
1
+ // @ts-check
2
+
3
+ import MigrationsLedger from "../database/migrations-ledger.js"
4
+ import SchemaCloner from "../database/tenants/schema-cloner.js"
5
+ import StructureSqlLoader from "../database/structure-sql-loader.js"
6
+ import {createTenantDatabase, dropTenantDatabase} from "../tenants/default-tenant-database-provisioning.js"
7
+ import TenantIterator from "../tenants/tenant-iterator.js"
8
+
9
+ /**
10
+ * @typedef {import("../configuration-types.js").TestTenantPoolConfiguration} TestTenantPoolConfiguration
11
+ */
12
+
13
+ /**
14
+ * A leased pool member. Leases are handed out by {@link TestTenantDatabasePool#acquire}
15
+ * and returned by {@link TestTenantDatabasePool#release}.
16
+ * @typedef {object} TestTenantPoolLease
17
+ * @property {string} databaseIdentifier - Tenant database identifier the lease belongs to.
18
+ * @property {ReturnType<typeof JSON.parse>} tenant - The pool member's tenant descriptor.
19
+ * @property {TestTenantPoolMember} member - Internal pool member backing the lease.
20
+ */
21
+
22
+ /**
23
+ * One pre-provisioned pool slot.
24
+ * @typedef {object} TestTenantPoolMember
25
+ * @property {boolean} leased - Whether the member is currently held by a lease.
26
+ * @property {ReturnType<typeof JSON.parse>} tenant - Tenant descriptor resolved by the app's tenantDatabaseResolver.
27
+ */
28
+
29
+ /**
30
+ * Key-stable JSON key for a tenant descriptor.
31
+ * @param {ReturnType<typeof JSON.parse>} value - Value to key-stably stringify.
32
+ * @returns {string} - Deterministic JSON with sorted object keys.
33
+ */
34
+ function stableTenantKey(value) {
35
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null"
36
+ if (Array.isArray(value)) return `[${value.map((entry) => stableTenantKey(entry)).join(",")}]`
37
+
38
+ const keys = Object.keys(value).sort()
39
+
40
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableTenantKey(value[key])}`).join(",")}}`
41
+ }
42
+
43
+ /**
44
+ * Reusable pool of pre-provisioned test tenant databases (STC-135 "keep a couple for
45
+ * re-use"). Instead of create + full structure load + drop per test, the pool provisions
46
+ * N tenant databases once per suite and resets each one between tests with
47
+ * truncate + migration-ledger baseline — the framework generalization of the
48
+ * captured-structure fast path consuming apps had to hand-roll.
49
+ *
50
+ * Provisioning runs the tenant provider's `createDatabase` hook (or the framework
51
+ * default provisioning), then loads the default database's captured structure SQL in
52
+ * one pass via {@link StructureSqlLoader} and baselines the tenant's
53
+ * `schema_migrations` ledger from the default database. Apps whose tenant schema
54
+ * differs from the default database (subset tables, FK-free clones) override
55
+ * `provisionTenant`/`resetTenant` in the pool configuration.
56
+ *
57
+ * Leases are deterministic: acquisition takes the first free member in configured
58
+ * order, so a suite that acquires and releases one lease per test reuses the same
59
+ * members in the same order. Acquisition is synchronous after provisioning, so
60
+ * concurrent acquires can never double-lease one member; exhaustion throws instead
61
+ * of queueing, because a test that holds a lease while waiting would deadlock the
62
+ * single-threaded runner.
63
+ */
64
+ export default class TestTenantDatabasePool {
65
+ /**
66
+ * Creates a pool for one tenant database identifier.
67
+ * @param {object} args - Pool arguments.
68
+ * @param {import("../configuration.js").default} args.configuration - Configuration owning the tenant resolver.
69
+ * @param {string} args.databaseIdentifier - Tenant-only database identifier to pool.
70
+ * @param {TestTenantPoolConfiguration} args.poolConfiguration - Pool size (via `tenants`) and optional provisioning overrides.
71
+ */
72
+ constructor({configuration, databaseIdentifier, poolConfiguration}) {
73
+ if (!configuration) throw new Error("TestTenantDatabasePool requires a configuration")
74
+ if (!databaseIdentifier) throw new Error("TestTenantDatabasePool requires a databaseIdentifier")
75
+ if (!Array.isArray(poolConfiguration.tenants) || poolConfiguration.tenants.length === 0) {
76
+ throw new Error(`Test tenant pool for ${databaseIdentifier} requires a non-empty tenants array`)
77
+ }
78
+
79
+ this.configuration = configuration
80
+ this.databaseIdentifier = databaseIdentifier
81
+ this.poolConfiguration = poolConfiguration
82
+ /** @type {TestTenantPoolMember[]} */
83
+ this.members = poolConfiguration.tenants.map((tenant) => ({leased: false, tenant}))
84
+ /** @type {Map<string, TestTenantPoolMember>} */
85
+ this.membersByTenantKey = new Map()
86
+
87
+ for (const member of this.members) {
88
+ this.membersByTenantKey.set(stableTenantKey(member.tenant), member)
89
+ }
90
+
91
+ /** @type {Promise<void> | undefined} */
92
+ this.provisioningPromise = undefined
93
+ /** @type {Promise<string | null> | undefined} */
94
+ this.structureSqlPromise = undefined
95
+ this.dropped = false
96
+ }
97
+
98
+ /**
99
+ * Number of pool members.
100
+ * @returns {number} - Pool size.
101
+ */
102
+ size() {
103
+ return this.members.length
104
+ }
105
+
106
+ /**
107
+ * Acquires one pool member as a lease. The first free member in configured order is
108
+ * taken; when every member is leased the acquisition fails loudly instead of queueing.
109
+ * @param {{tenant?: ReturnType<typeof JSON.parse>}} [args] - Optional explicit pool tenant to pin.
110
+ * @returns {Promise<TestTenantPoolLease>} - Held lease; pass it to {@link release}.
111
+ */
112
+ async acquire({tenant} = {}) {
113
+ if (this.dropped) {
114
+ throw new Error(`Test tenant database pool for ${this.databaseIdentifier} was already torn down`)
115
+ }
116
+
117
+ await this.ensureProvisioned()
118
+
119
+ /** @type {TestTenantPoolMember | undefined} */
120
+ let member
121
+
122
+ if (tenant !== undefined) {
123
+ member = this.membersByTenantKey.get(stableTenantKey(tenant))
124
+
125
+ if (!member) {
126
+ throw new Error(`Tenant ${TenantIterator.tenantLabel(tenant)} is not a member of the test tenant pool for ${this.databaseIdentifier}`)
127
+ }
128
+ if (member.leased) {
129
+ throw new Error(`Test tenant pool member ${TenantIterator.tenantLabel(tenant)} for ${this.databaseIdentifier} is already leased`)
130
+ }
131
+ } else {
132
+ member = this.members.find((candidate) => !candidate.leased)
133
+
134
+ if (!member) {
135
+ throw new Error(`Test tenant database pool exhausted for ${this.databaseIdentifier} (size ${this.members.length}, all leased). Release a lease before acquiring another tenant.`)
136
+ }
137
+ }
138
+
139
+ member.leased = true
140
+
141
+ return {databaseIdentifier: this.databaseIdentifier, member, tenant: member.tenant}
142
+ }
143
+
144
+ /**
145
+ * Resets a leased member (truncate + ledger baseline, or the configured `resetTenant`
146
+ * override) and returns it to the free pool. Idempotent for an already-free member.
147
+ * A failed reset is followed by a full re-provision so a corrupted member cannot be
148
+ * handed to the next test; if re-provisioning also fails the member stays leased and
149
+ * the aggregated error is thrown.
150
+ * @param {TestTenantPoolLease} lease - Lease returned by {@link acquire}.
151
+ * @returns {Promise<void>} - Resolves once the member is clean and free again.
152
+ */
153
+ async release(lease) {
154
+ if (lease.databaseIdentifier !== this.databaseIdentifier) {
155
+ throw new Error(`Lease belongs to pool ${lease.databaseIdentifier}, not ${this.databaseIdentifier}`)
156
+ }
157
+
158
+ const member = lease.member
159
+
160
+ if (!member.leased) return
161
+
162
+ try {
163
+ await this.resetMember(member)
164
+ } catch (resetError) {
165
+ try {
166
+ await this.provisionMember(member)
167
+ } catch (provisionError) {
168
+ throw new AggregateError(
169
+ [resetError, provisionError],
170
+ `Failed to reset and re-provision test tenant pool member ${TenantIterator.tenantLabel(member.tenant)} for ${this.databaseIdentifier}`,
171
+ {cause: provisionError}
172
+ )
173
+ }
174
+ }
175
+
176
+ member.leased = false
177
+ }
178
+
179
+ /**
180
+ * Drops every pool member's tenant database through the provider's `dropDatabase`
181
+ * hook (or the framework default) and closes the pool. Idempotent.
182
+ * @returns {Promise<void>} - Resolves after all members were dropped.
183
+ */
184
+ async teardown() {
185
+ if (this.dropped) return
186
+
187
+ this.dropped = true
188
+
189
+ const errors = []
190
+
191
+ for (const member of this.members) {
192
+ try {
193
+ await this.dropMember(member)
194
+ } catch (error) {
195
+ errors.push(error)
196
+ }
197
+ }
198
+
199
+ if (errors.length === 1) throw errors[0]
200
+ if (errors.length > 0) throw new AggregateError(errors, `Failed to tear down the test tenant database pool for ${this.databaseIdentifier}`)
201
+ }
202
+
203
+ /**
204
+ * Provisions every member exactly once (concurrent acquisitions share one
205
+ * provisioning pass).
206
+ * @returns {Promise<void>} - Resolves when all members are provisioned.
207
+ */
208
+ async ensureProvisioned() {
209
+ this.provisioningPromise ??= (async () => {
210
+ for (const member of this.members) {
211
+ await this.provisionMember(member)
212
+ }
213
+ })()
214
+
215
+ await this.provisioningPromise
216
+ }
217
+
218
+ /**
219
+ * Provisions one member: creates the physical tenant database, then loads or clones
220
+ * the default database's structure and baselines the ledger.
221
+ * @param {TestTenantPoolMember} member - Pool member to provision.
222
+ * @returns {Promise<void>} - Resolves when the member's tenant database is ready.
223
+ */
224
+ async provisionMember(member) {
225
+ if (typeof this.poolConfiguration.provisionTenant === "function") {
226
+ await this.poolConfiguration.provisionTenant({
227
+ configuration: this.configuration,
228
+ databaseIdentifier: this.databaseIdentifier,
229
+ tenant: member.tenant
230
+ })
231
+
232
+ return
233
+ }
234
+
235
+ await this.createTenantDatabaseForMember(member)
236
+
237
+ // The tenantDatabaseResolver rewrites every identifier inside a tenant context
238
+ // (including `default`), so the structure/ledger source must be read outside the
239
+ // tenant scope while the member is reached through a dedicated pool checkout.
240
+ await this.withMemberConnection(member.tenant, async (tenantDb) => {
241
+ await this.configuration.ensureConnections({databaseIdentifiers: ["default"], name: `Test tenant pool provision: ${this.databaseIdentifier}`}, async (dbs) => {
242
+ await this.applyDefaultStructure({...dbs, [this.databaseIdentifier]: tenantDb})
243
+ })
244
+ })
245
+ }
246
+
247
+ /**
248
+ * Creates the physical tenant database via the provider's `createDatabase` hook or
249
+ * the framework default provisioning.
250
+ * @param {TestTenantPoolMember} member - Pool member whose database must exist.
251
+ * @returns {Promise<void>} - Resolves when the tenant database exists.
252
+ */
253
+ async createTenantDatabaseForMember(member) {
254
+ const provider = this.configuration.getTenantDatabaseProviders()[this.databaseIdentifier]
255
+ const createDatabase = provider && typeof provider.createDatabase === "function"
256
+ ? provider.createDatabase.bind(provider)
257
+ : createTenantDatabase
258
+
259
+ await this.configuration.runWithTenant(member.tenant, async () => {
260
+ if (!this.configuration.isDatabaseIdentifierActive(this.databaseIdentifier)) {
261
+ throw new Error(`Tenant database identifier ${this.databaseIdentifier} is inactive for tenant: ${TenantIterator.tenantLabel(member.tenant)}`)
262
+ }
263
+
264
+ await createDatabase({
265
+ configuration: this.configuration,
266
+ databaseConfiguration: this.configuration.resolveDatabaseConfiguration(this.databaseIdentifier),
267
+ identifier: this.databaseIdentifier,
268
+ tenant: member.tenant
269
+ })
270
+ })
271
+ }
272
+
273
+ /**
274
+ * Loads the captured default-database structure into the current tenant connection
275
+ * and baselines its ledger. Drivers without a structure-SQL generator fall back to
276
+ * {@link SchemaCloner} over every default table.
277
+ * @param {Record<string, import("../database/drivers/base.js").default>} dbs - Active connections (must include `default` and the pool identifier).
278
+ * @returns {Promise<void>} - Resolves when the tenant schema and ledger are baselined.
279
+ */
280
+ async applyDefaultStructure(dbs) {
281
+ const tenantDb = this.tenantConnection(dbs)
282
+ const defaultDb = this.defaultConnection(dbs)
283
+ const structureSql = await this.capturedStructureSql()
284
+
285
+ if (structureSql !== null) {
286
+ await new StructureSqlLoader().load({db: tenantDb, structureSql})
287
+ } else {
288
+ const cloner = new SchemaCloner({sourceDb: defaultDb, targetDb: tenantDb})
289
+ const tableNames = (await defaultDb.getTables())
290
+ .map((table) => table.getName())
291
+ .filter((tableName) => tableName !== MigrationsLedger.tableName())
292
+
293
+ // syncTable (not syncTables) so the ledger is never reconciled against a
294
+ // default database that has no ledger table yet; baselineLedger owns the
295
+ // ledger step and skips when there is nothing to baseline from.
296
+ for (const tableName of tableNames) {
297
+ await cloner.syncTable(tableName)
298
+ }
299
+ }
300
+
301
+ await this.baselineLedger(tenantDb)
302
+ }
303
+
304
+ /**
305
+ * Resets one member between tests: truncate every tenant table (the ledger table is
306
+ * preserved by `truncateAllTables`) and re-baseline the ledger from the default
307
+ * database, so the next test starts from the same schema state without a structure load.
308
+ * @param {TestTenantPoolMember} member - Leased member to clean.
309
+ * @returns {Promise<void>} - Resolves when the member is empty and ledger-current.
310
+ */
311
+ async resetMember(member) {
312
+ if (typeof this.poolConfiguration.resetTenant === "function") {
313
+ await this.poolConfiguration.resetTenant({
314
+ configuration: this.configuration,
315
+ databaseIdentifier: this.databaseIdentifier,
316
+ tenant: member.tenant
317
+ })
318
+
319
+ return
320
+ }
321
+
322
+ await this.withMemberConnection(member.tenant, async (tenantDb) => {
323
+ await tenantDb.truncateAllTables()
324
+ await this.baselineLedger(tenantDb)
325
+ })
326
+ }
327
+
328
+ /**
329
+ * Baselines a tenant connection's ledger from the default database. Skips when the
330
+ * default database has no ledger yet (nothing to baseline from).
331
+ * @param {import("../database/drivers/base.js").default} tenantDb - Tenant connection whose ledger is refreshed.
332
+ * @returns {Promise<void>} - Resolves when the ledger matches the default database.
333
+ */
334
+ async baselineLedger(tenantDb) {
335
+ await this.configuration.ensureConnections({databaseIdentifiers: ["default"], name: `Test tenant pool ledger: ${this.databaseIdentifier}`}, async (dbs) => {
336
+ const defaultDb = this.defaultConnection(dbs)
337
+
338
+ if (!await MigrationsLedger.tableExists(defaultDb)) return
339
+
340
+ await MigrationsLedger.baselineFromDatabase({sourceDb: defaultDb, targetDb: tenantDb})
341
+ })
342
+ }
343
+
344
+ /**
345
+ * Drops one member's tenant database through the provider's `dropDatabase` hook or
346
+ * the framework default.
347
+ * @param {TestTenantPoolMember} member - Pool member to drop.
348
+ * @returns {Promise<void>} - Resolves when the tenant database is gone.
349
+ */
350
+ async dropMember(member) {
351
+ const provider = this.configuration.getTenantDatabaseProviders()[this.databaseIdentifier]
352
+ const dropDatabase = provider && typeof provider.dropDatabase === "function"
353
+ ? provider.dropDatabase.bind(provider)
354
+ : dropTenantDatabase
355
+
356
+ await this.configuration.runWithTenant(member.tenant, async () => {
357
+ if (!this.configuration.isDatabaseIdentifierActive(this.databaseIdentifier)) {
358
+ throw new Error(`Tenant database identifier ${this.databaseIdentifier} is inactive for tenant: ${TenantIterator.tenantLabel(member.tenant)}`)
359
+ }
360
+
361
+ await dropDatabase({
362
+ configuration: this.configuration,
363
+ databaseConfiguration: this.configuration.resolveDatabaseConfiguration(this.databaseIdentifier),
364
+ identifier: this.databaseIdentifier,
365
+ tenant: member.tenant
366
+ })
367
+ })
368
+ }
369
+
370
+ /**
371
+ * Captures the default database's structure SQL once per pool (the framework
372
+ * generalization of the consuming apps' captured-structure fast path). Returns null
373
+ * for drivers without a structure generator, which selects the SchemaCloner fallback.
374
+ * @returns {Promise<string | null>} - Captured structure SQL or null.
375
+ */
376
+ capturedStructureSql() {
377
+ this.structureSqlPromise ??= (async () => {
378
+ return await this.configuration.ensureConnections({databaseIdentifiers: ["default"], name: "Test tenant pool structure capture"}, async (dbs) => {
379
+ return await dbs.default.structureSql()
380
+ })
381
+ })()
382
+
383
+ return this.structureSqlPromise
384
+ }
385
+
386
+ /**
387
+ * Runs tenant connection.
388
+ * @param {Record<string, import("../database/drivers/base.js").default>} dbs - Active connections.
389
+ * @returns {import("../database/drivers/base.js").default} - The pool identifier's connection.
390
+ */
391
+ tenantConnection(dbs) {
392
+ const tenantDb = dbs[this.databaseIdentifier]
393
+
394
+ if (!tenantDb) throw new Error(`Expected a ${this.databaseIdentifier} connection for the test tenant pool`)
395
+
396
+ return tenantDb
397
+ }
398
+
399
+ /**
400
+ * Runs default connection.
401
+ * @param {Record<string, import("../database/drivers/base.js").default>} dbs - Active connections.
402
+ * @returns {import("../database/drivers/base.js").default} - The default connection (ledger baseline source).
403
+ */
404
+ defaultConnection(dbs) {
405
+ const defaultDb = dbs.default
406
+
407
+ if (!defaultDb) throw new Error(`Expected a default connection for the test tenant pool on ${this.databaseIdentifier}`)
408
+
409
+ return defaultDb
410
+ }
411
+
412
+ /**
413
+ * Runs with a dedicated physical connection for one member. The reset path truncates
414
+ * committed data, so it must not reuse a test-pinned or async-context connection that
415
+ * a later rollback would undo — it checks out through the pool exactly like
416
+ * `registerTransactionalTenant` does and releases the connection back to the pool.
417
+ * @param {ReturnType<typeof JSON.parse>} tenant - Pool member tenant descriptor.
418
+ * @template T
419
+ * @param {(db: import("../database/drivers/base.js").default) => Promise<T>} callback - Operation on the dedicated connection.
420
+ * @returns {Promise<T>} - Callback result.
421
+ */
422
+ async withMemberConnection(tenant, callback) {
423
+ const pool = this.configuration.getDatabasePool(this.databaseIdentifier)
424
+ const databaseConfiguration = this.configuration.resolveDatabaseConfiguration(this.databaseIdentifier, tenant)
425
+
426
+ if (!databaseConfiguration.tenantOnly) {
427
+ throw new Error(`Test tenant pool requires a tenantOnly database: ${this.databaseIdentifier}`)
428
+ }
429
+
430
+ const connection = await pool.checkoutForConfiguration(databaseConfiguration, {name: `Test tenant pool member: ${this.databaseIdentifier}`})
431
+
432
+ try {
433
+ return await callback(connection)
434
+ } finally {
435
+ await pool.checkin(connection)
436
+ }
437
+ }
438
+ }
@@ -147,6 +147,21 @@ export default class VelociousAttemptExecutor {
147
147
  testArgs.registerTransactionalTenant = async (args) => {
148
148
  await testRunner.registerTransactionalTenant(args, transactionalTenantRegistrations)
149
149
  }
150
+ /** @type {import("./test-runner.js").TenantCleanupRegistration[]} */
151
+ const tenantCleanupRegistrations = []
152
+ /** @type {import("./test-tenant-database-pool.js").TestTenantPoolLease[]} */
153
+ const testTenantPoolLeases = []
154
+ testArgs.registerTenantForCleanup = (args) => {
155
+ testRunner.registerTenantForCleanup(args, tenantCleanupRegistrations)
156
+ }
157
+ testArgs.acquireTestTenant = async (args = {}) => {
158
+ const databaseIdentifier = args.databaseIdentifier ?? testRunner.singleTestTenantPoolIdentifier()
159
+ const pool = testRunner.getTestTenantPool(databaseIdentifier)
160
+ const lease = await pool.acquire({tenant: args.tenant})
161
+ testTenantPoolLeases.push(lease)
162
+
163
+ return lease
164
+ }
150
165
  const profiler = testRunner._profiler
151
166
  const profileTestData = {
152
167
  ...test,
@@ -249,6 +264,20 @@ export default class VelociousAttemptExecutor {
249
264
  } catch (error) {
250
265
  lifecycleErrors.push(error)
251
266
  }
267
+
268
+ try {
269
+ await testRunner.cleanupTenantRegistrations(tenantCleanupRegistrations)
270
+ } catch (error) {
271
+ lifecycleErrors.push(error)
272
+ }
273
+
274
+ try {
275
+ for (const lease of testTenantPoolLeases) {
276
+ await testRunner.getTestTenantPool(lease.databaseIdentifier).release(lease)
277
+ }
278
+ } catch (error) {
279
+ lifecycleErrors.push(error)
280
+ }
252
281
  }
253
282
 
254
283
  if (testSharedConnectionsActive) {
@@ -355,6 +384,21 @@ export default class VelociousAttemptExecutor {
355
384
  })
356
385
  }
357
386
 
387
+ const poolRelease = (async () => {
388
+ for (const lease of testTenantPoolLeases) {
389
+ await testRunner.getTestTenantPool(lease.databaseIdentifier).release(lease)
390
+ }
391
+ })()
392
+ const poolReleaseOutcome = await awaitSettledOrGrace(poolRelease, effectiveTimeoutMs ?? 60000)
393
+
394
+ if (poolReleaseOutcome.settled && poolReleaseOutcome.status === "rejected") {
395
+ emergencyCleanupErrors.push(poolReleaseOutcome.reason)
396
+ } else if (!poolReleaseOutcome.settled) {
397
+ void poolRelease.catch((cleanupError) => {
398
+ testRunner.recordTimeoutCleanupFailure(cleanupError, "test tenant pool lease release", recordedTimeoutCleanupErrors)
399
+ })
400
+ }
401
+
358
402
  if (emergencyCleanupErrors.length > 0) {
359
403
  caughtError = new AggregateError(
360
404
  [caughtError, ...emergencyCleanupErrors],
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "velocious": "build/bin/velocious.js"
4
4
  },
5
5
  "name": "velocious",
6
- "version": "1.0.698",
6
+ "version": "1.0.700",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -758,6 +758,17 @@
758
758
  * @property {string} [token] - Bearer token required in the `Authorization: Bearer <token>` header. When set, requests without a matching token are not routed (404), so the endpoint stays hidden.
759
759
  */
760
760
 
761
+ /**
762
+ * @typedef {object} TestTenantPoolConfiguration
763
+ * @property {Array<ReturnType<typeof JSON.parse>>} tenants - Pool members: the tenant descriptors whose databases the pool pre-provisions and re-uses. Pool size is the array length.
764
+ * @property {(args: {configuration: import("./configuration.js").default, databaseIdentifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [provisionTenant] - App-owned provisioning override for one member (runs instead of the framework create + structure-load + ledger-baseline default).
765
+ * @property {(args: {configuration: import("./configuration.js").default, databaseIdentifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [resetTenant] - App-owned per-test reset override (runs instead of the framework truncate + ledger-baseline default).
766
+ */
767
+
768
+ /**
769
+ * @typedef {Record<string, TestTenantPoolConfiguration>} TestTenantPoolsConfiguration
770
+ */
771
+
761
772
  /**
762
773
  * @typedef {object} TenantDatabaseProviderType
763
774
  * @property {(args: {configuration: import("./configuration.js").default, identifier: string}) => Array<ReturnType<typeof JSON.parse>> | Promise<Array<ReturnType<typeof JSON.parse>>>} listTenants - Lists tenants that should be created, checked, or migrated for this database identifier.
@@ -769,6 +780,16 @@
769
780
  * @property {(args: {configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, migrationsApplied: number, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [afterMigrateTenant] - Runs app-owned tenant work after generic migrations for one tenant. `migrationsApplied` is how many migrations actually ran (0 when the tenant was already up to date), so the app can skip expensive per-tenant work on no-op deploys.
770
781
  */
771
782
 
783
+ /**
784
+ * Advisory-lock backend chain configuration (STC-210 design §10, file tier
785
+ * implemented by STC-213). Everything defaults to today's driver-native-only
786
+ * behavior; the file backend is disabled by default and only selectable by
787
+ * explicit configuration.
788
+ * @typedef {object} AdvisoryLocksConfiguration
789
+ * @property {{file?: {directory?: string, enabled?: boolean, leaseMs?: number, renewIntervalMs?: number}}} [backends] - Backend enablement. `file.enabled` defaults to false; enabling it is the deployment's explicit single-host declaration.
790
+ * @property {import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[]} [policy] - Per-lock-name minimum/allowed/preferred tier rules (design §5).
791
+ */
792
+
772
793
  /**
773
794
  * @typedef {object} ConfigurationArgsType
774
795
  * @property {boolean} [autoload] - Globally enable auto-batch-preload of relationships on lazy access. Default true.
@@ -776,6 +797,7 @@
776
797
  * @property {string} [cookieSecret] - Secret for encrypting cookies.
777
798
  * @property {AbilityResourceClassType[]} [abilityResources] - Resource classes used to define abilities per model.
778
799
  * @property {AbilityResolverType} [abilityResolver] - Resolver for creating request-scoped ability instances.
800
+ * @property {AdvisoryLocksConfiguration} [advisoryLocks] - Advisory-lock backend chain configuration (backends + per-lock-name tier policy). Defaults to today's driver-native-only behavior.
779
801
  * @property {AttachmentsConfiguration} [attachments] - Attachment storage configuration.
780
802
  * @property {BackendProjectConfiguration[]} [backendProjects] - Backend project definitions used for frontend model generation.
781
803
  * @property {VelociousPackageConfiguration[]} [packages] - External Velocious packages that contribute models, frontend-model resources and migrations.
@@ -807,6 +829,7 @@
807
829
  * @property {TenantResolverType} [tenantResolver] - Resolver for creating request-scoped tenant context objects.
808
830
  * @property {TenantDatabaseResolverType} [tenantDatabaseResolver] - Resolver for deriving tenant-specific database config overrides.
809
831
  * @property {Record<string, TenantDatabaseProviderType>} [tenantDatabaseProviders] - Tenant database lifecycle providers keyed by database identifier.
832
+ * @property {TestTenantPoolsConfiguration} [testTenantPools] - Opt-in reusable test tenant-database pools keyed by tenant database identifier. Consumed only by the test harness; ordinary runtime ignores it.
810
833
  * @property {string} [testing] - Path to the testing configuration file.
811
834
  * @property {string | (() => string | undefined)} [timeZone] - Default timezone for timezone-less datetime strings.
812
835
  * @property {number | (() => number)} [timezoneOffsetMinutes] - Default timezone offset in minutes.