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.
- package/README.md +2 -2
- package/build/configuration-types.js +23 -0
- package/build/configuration.js +136 -1
- package/build/database/advisory-lock-backend-policy.js +204 -0
- package/build/database/advisory-lock-file-backend.js +348 -0
- package/build/database/advisory-lock-runner.js +239 -16
- package/build/database/drivers/base.js +318 -15
- package/build/database/drivers/mssql/index.js +1 -1
- package/build/database/drivers/mysql/index.js +4 -2
- package/build/database/drivers/pgsql/index.js +4 -2
- package/build/database/drivers/sqlite/base.js +13 -129
- package/build/database/drivers/sqlite/index.js +41 -62
- package/build/database/migrator.js +120 -50
- package/build/database/record/attachments/store.js +84 -1
- package/build/environment-handlers/base.js +13 -0
- package/build/environment-handlers/node.js +11 -0
- package/build/http-server/server-lock.js +21 -115
- package/build/src/configuration-types.d.ts +68 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +21 -1
- package/build/src/configuration.d.ts +100 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +124 -2
- package/build/src/database/advisory-lock-backend-policy.d.ts +145 -0
- package/build/src/database/advisory-lock-backend-policy.d.ts.map +1 -0
- package/build/src/database/advisory-lock-backend-policy.js +184 -0
- package/build/src/database/advisory-lock-file-backend.d.ts +194 -0
- package/build/src/database/advisory-lock-file-backend.d.ts.map +1 -0
- package/build/src/database/advisory-lock-file-backend.js +313 -0
- package/build/src/database/advisory-lock-runner.d.ts +116 -15
- package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
- package/build/src/database/advisory-lock-runner.js +190 -17
- package/build/src/database/drivers/base.d.ts +125 -4
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +282 -17
- package/build/src/database/drivers/mssql/index.d.ts +1 -1
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +2 -2
- package/build/src/database/drivers/mysql/index.d.ts +4 -2
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +5 -3
- package/build/src/database/drivers/pgsql/index.d.ts +4 -2
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +5 -3
- package/build/src/database/drivers/sqlite/base.d.ts +11 -49
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +14 -114
- package/build/src/database/drivers/sqlite/index.d.ts +28 -19
- package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.js +41 -59
- package/build/src/database/migrator.d.ts.map +1 -1
- package/build/src/database/migrator.js +110 -56
- package/build/src/database/record/attachments/store.d.ts +0 -3
- package/build/src/database/record/attachments/store.d.ts.map +1 -1
- package/build/src/database/record/attachments/store.js +71 -2
- package/build/src/environment-handlers/base.d.ts +14 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +13 -1
- package/build/src/environment-handlers/node.d.ts +11 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +11 -1
- package/build/src/http-server/server-lock.d.ts +13 -37
- package/build/src/http-server/server-lock.d.ts.map +1 -1
- package/build/src/http-server/server-lock.js +22 -109
- package/build/src/testing/test-runner.d.ts +74 -0
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +140 -1
- package/build/src/testing/test-tenant-database-pool.d.ts +183 -0
- package/build/src/testing/test-tenant-database-pool.d.ts.map +1 -0
- package/build/src/testing/test-tenant-database-pool.js +388 -0
- package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -1
- package/build/src/testing/velocious-attempt-executor.js +43 -1
- package/build/testing/test-runner.js +152 -0
- package/build/testing/test-tenant-database-pool.js +438 -0
- package/build/testing/velocious-attempt-executor.js +44 -0
- package/package.json +1 -1
- package/src/configuration-types.js +23 -0
- package/src/configuration.js +136 -1
- package/src/database/advisory-lock-backend-policy.js +204 -0
- package/src/database/advisory-lock-file-backend.js +348 -0
- package/src/database/advisory-lock-runner.js +239 -16
- package/src/database/drivers/base.js +318 -15
- package/src/database/drivers/mssql/index.js +1 -1
- package/src/database/drivers/mysql/index.js +4 -2
- package/src/database/drivers/pgsql/index.js +4 -2
- package/src/database/drivers/sqlite/base.js +13 -129
- package/src/database/drivers/sqlite/index.js +41 -62
- package/src/database/migrator.js +120 -50
- package/src/database/record/attachments/store.js +84 -1
- package/src/environment-handlers/base.js +13 -0
- package/src/environment-handlers/node.js +11 -0
- package/src/http-server/server-lock.js +21 -115
- package/src/testing/test-runner.js +152 -0
- package/src/testing/test-tenant-database-pool.js +438 -0
- 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
|
@@ -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.
|