@stratal/testing 0.0.0-canary-ccb3f17 → 0.0.0-canary-e5681b8
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/CHANGELOG.md +603 -0
- package/README.md +137 -36
- package/dist/database/index.d.mts +2 -2
- package/dist/database/index.mjs +2 -2
- package/dist/{database-DeMI4Igi.mjs → database-Rojs69r3.mjs} +112 -60
- package/dist/database-Rojs69r3.mjs.map +1 -0
- package/dist/{decorate-ZDdbRmsv.mjs → decorate-RQD1h28J.mjs} +1 -1
- package/dist/feature-flags/index.mjs +1 -1
- package/dist/{feature-flags-BMYurC1B.mjs → feature-flags-CZ1a4M2g.mjs} +2 -2
- package/dist/{feature-flags-BMYurC1B.mjs.map → feature-flags-CZ1a4M2g.mjs.map} +1 -1
- package/dist/{index-DUScdrKC.d.mts → index-B8Mw1Dxk.d.mts} +46 -33
- package/dist/index-B8Mw1Dxk.d.mts.map +1 -0
- package/dist/{index-Dm6e2N61.d.mts → index-D7FM6EKp.d.mts} +21 -2
- package/dist/index-D7FM6EKp.d.mts.map +1 -0
- package/dist/index.d.mts +55 -28
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +67 -61
- package/dist/index.mjs.map +1 -1
- package/dist/mocks/cloudflare-workers.d.mts +6 -7
- package/dist/mocks/cloudflare-workers.d.mts.map +1 -1
- package/dist/mocks/index.d.mts +1 -1
- package/dist/mocks/index.mjs +1 -1
- package/dist/mocks/noop-rate-limiter-store.d.mts +1 -2
- package/dist/mocks/noop-rate-limiter-store.d.mts.map +1 -1
- package/dist/mocks/zenstack-language.d.mts +21 -21
- package/dist/mocks/zenstack-language.d.mts.map +1 -1
- package/dist/mocks/zenstack-language.mjs +1 -1
- package/dist/mocks/zenstack-language.mjs.map +1 -1
- package/dist/storage/index.d.mts +1 -1
- package/dist/storage/index.mjs +1 -1
- package/dist/{storage-q8RFahgU.mjs → storage-BiGamMA8.mjs} +70 -3
- package/dist/storage-BiGamMA8.mjs.map +1 -0
- package/dist/{test-worker-exports-DMct0Egv.d.mts → test-worker-exports-dCRARYEc.d.mts} +8 -7
- package/dist/test-worker-exports-dCRARYEc.d.mts.map +1 -0
- package/dist/{test-workers-cache-eiGMw9Yj.mjs → test-workers-cache-D02Pt_dE.mjs} +8 -7
- package/dist/{test-workers-cache-eiGMw9Yj.mjs.map → test-workers-cache-D02Pt_dE.mjs.map} +1 -1
- package/dist/vitest-plugin/index.d.mts +9 -9
- package/dist/vitest-plugin/index.d.mts.map +1 -1
- package/dist/vitest-plugin/index.mjs +22 -15
- package/dist/vitest-plugin/index.mjs.map +1 -1
- package/package.json +22 -19
- package/dist/database-DeMI4Igi.mjs.map +0 -1
- package/dist/index-DUScdrKC.d.mts.map +0 -1
- package/dist/index-Dm6e2N61.d.mts.map +0 -1
- package/dist/storage-q8RFahgU.mjs.map +0 -1
- package/dist/test-worker-exports-DMct0Egv.d.mts.map +0 -1
package/README.md
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# @stratal/testing
|
|
2
2
|
|
|
3
|
-
Testing utilities and mocks for [Stratal](https://
|
|
3
|
+
Testing utilities and mocks for [Stratal](https://stratal.dev) applications.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@stratal/testing)
|
|
6
|
+
[](https://github.com/strataljs/stratal/actions/workflows/ci.yml)
|
|
6
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://www.npmjs.com/package/@stratal/testing)
|
|
9
|
+
[](https://www.typescriptlang.org/)
|
|
10
|
+
[](https://github.com/strataljs/stratal/pulls)
|
|
11
|
+
[](https://github.com/strataljs/stratal)
|
|
7
12
|
|
|
8
13
|
## Installation
|
|
9
14
|
|
|
@@ -29,7 +34,22 @@ npx skills add strataljs/stratal
|
|
|
29
34
|
|
|
30
35
|
| Skill | Description |
|
|
31
36
|
|---|---|
|
|
32
|
-
| `stratal` | Build Cloudflare Workers apps with the Stratal framework — modules, DI, controllers, routing, OpenAPI, queues, cron, events, seeders, CLI, auth, database,
|
|
37
|
+
| `stratal` | Build Cloudflare Workers apps with the Stratal framework — modules, DI, controllers, routing, OpenAPI, queues, cron, events, seeders, CLI, auth, database, access control, testing, and more |
|
|
38
|
+
|
|
39
|
+
## Vitest setup
|
|
40
|
+
|
|
41
|
+
`stratalTest()` wraps [`@cloudflare/vitest-plugin`](https://developers.cloudflare.com/workers/testing/vitest-integration/) with Stratal defaults (tslib alias, ZenStack mocks, SSR externals). It needs no database — `Test.createTestingModule()` and unit tests run with no DB wiring at all.
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// vitest.config.ts
|
|
45
|
+
import { fixNobleHashesCjs, fixPgCjs, stratalTest } from '@stratal/testing/vitest-plugin'
|
|
46
|
+
|
|
47
|
+
export default defineConfig({
|
|
48
|
+
plugins: [fixPgCjs(), fixNobleHashesCjs(), stratalTest()],
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A database is opt-in via the `database` option, which gives **each test file** its own database cloned from a migrated template. Omit it entirely for suites that don't touch Postgres.
|
|
33
53
|
|
|
34
54
|
## Quick Start
|
|
35
55
|
|
|
@@ -120,9 +140,17 @@ module.http
|
|
|
120
140
|
// Access fake storage for assertions
|
|
121
141
|
module.storage
|
|
122
142
|
|
|
143
|
+
// Other test clients and fakes
|
|
144
|
+
module.ws('/ws/chat') // WebSocket request builder
|
|
145
|
+
module.sse('/streaming/events') // Server-sent events request builder
|
|
146
|
+
module.quarry('users:create') // Quarry command request builder
|
|
147
|
+
module.cache // Workers Cache stub — inspect purges
|
|
148
|
+
module.featureFlags // Fake feature flag service
|
|
149
|
+
module.sentEmails // Emails sent during the test
|
|
150
|
+
|
|
123
151
|
// Execute code in a request-scoped container
|
|
124
|
-
await module.runInRequestScope(async () => {
|
|
125
|
-
const scoped =
|
|
152
|
+
await module.runInRequestScope(async (container) => {
|
|
153
|
+
const scoped = container.resolve(REQUEST_SCOPED_TOKEN)
|
|
126
154
|
})
|
|
127
155
|
|
|
128
156
|
// Cleanup in afterAll
|
|
@@ -146,6 +174,14 @@ const response = await module.http
|
|
|
146
174
|
|
|
147
175
|
All HTTP methods are supported: `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`.
|
|
148
176
|
|
|
177
|
+
Requests can also act as a user, force JSON, or carry a locale:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
await module.http.get('/api/v1/profile').actingAs({ id: user.id }).send()
|
|
181
|
+
await module.http.get('/api/v1/notes').asJson().send()
|
|
182
|
+
await module.http.get('/api/v1/notes').withLocale('fr').send()
|
|
183
|
+
```
|
|
184
|
+
|
|
149
185
|
### Response assertions
|
|
150
186
|
|
|
151
187
|
```typescript
|
|
@@ -218,59 +254,60 @@ const module = await Test.createTestingModule({
|
|
|
218
254
|
|
|
219
255
|
## Fetch Mocking
|
|
220
256
|
|
|
221
|
-
Mock external HTTP calls with `
|
|
257
|
+
Mock external HTTP calls with `createMockFetch()`, backed by [Mock Service Worker](https://mswjs.io). `http` and `HttpResponse` are re-exported for convenience.
|
|
222
258
|
|
|
223
259
|
```typescript
|
|
224
|
-
import {
|
|
260
|
+
import { createMockFetch, http, HttpResponse, type MockFetch } from '@stratal/testing'
|
|
225
261
|
|
|
226
262
|
describe('GeoService', () => {
|
|
227
|
-
let
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
263
|
+
let mockFetch: MockFetch
|
|
264
|
+
|
|
265
|
+
beforeAll(() => {
|
|
266
|
+
mockFetch = createMockFetch([
|
|
267
|
+
http.get('https://geo.api.com/lookup', () => {
|
|
268
|
+
return HttpResponse.json({ lat: 40.7128, lng: -74.006 })
|
|
269
|
+
}),
|
|
270
|
+
])
|
|
271
|
+
mockFetch.listen()
|
|
234
272
|
})
|
|
235
273
|
|
|
236
|
-
afterEach(() =>
|
|
237
|
-
|
|
238
|
-
})
|
|
274
|
+
afterEach(() => mockFetch.reset())
|
|
275
|
+
afterAll(() => mockFetch.close())
|
|
239
276
|
|
|
240
277
|
it('looks up coordinates', async () => {
|
|
241
|
-
fetchMock.mockJsonResponse('https://geo.api.com/lookup', {
|
|
242
|
-
lat: 40.7128,
|
|
243
|
-
lng: -74.006,
|
|
244
|
-
})
|
|
245
|
-
|
|
246
278
|
const response = await module.http
|
|
247
279
|
.get('/api/geo/lookup?address=NYC')
|
|
248
280
|
.send()
|
|
249
281
|
|
|
250
282
|
response.assertOk()
|
|
251
283
|
await response.assertJsonPath('data.lat', 40.7128)
|
|
252
|
-
fetchMock.assertNoPendingInterceptors()
|
|
253
284
|
})
|
|
285
|
+
})
|
|
286
|
+
```
|
|
254
287
|
|
|
255
|
-
|
|
256
|
-
fetchMock.mockError('https://geo.api.com/lookup', 503, 'Service Unavailable')
|
|
288
|
+
### Lifecycle
|
|
257
289
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
290
|
+
```typescript
|
|
291
|
+
mockFetch.listen() // start intercepting
|
|
292
|
+
mockFetch.reset() // clear runtime handlers, between tests
|
|
293
|
+
mockFetch.close() // stop intercepting
|
|
294
|
+
```
|
|
261
295
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
296
|
+
### Adding handlers for a single test
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
mockFetch.use(
|
|
300
|
+
http.post('https://geo.api.com/submit', () => {
|
|
301
|
+
return HttpResponse.json({ success: true }, { status: 201 })
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
265
304
|
```
|
|
266
305
|
|
|
267
|
-
|
|
306
|
+
### Shorthands
|
|
268
307
|
|
|
269
308
|
```typescript
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
.intercept({ path: '/users', method: 'POST' })
|
|
273
|
-
.reply(201, { id: '1' })
|
|
309
|
+
mockFetch.mockJsonResponse('https://geo.api.com/lookup', { lat: 40.7128 })
|
|
310
|
+
mockFetch.mockError('https://geo.api.com/lookup', 503, 'Service Unavailable')
|
|
274
311
|
```
|
|
275
312
|
|
|
276
313
|
## Storage Testing
|
|
@@ -300,6 +337,59 @@ afterEach(() => {
|
|
|
300
337
|
})
|
|
301
338
|
```
|
|
302
339
|
|
|
340
|
+
## WebSocket Testing
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
const ws = await module.ws('/ws/chat')
|
|
344
|
+
.actingAs({ id: user.id })
|
|
345
|
+
.connect()
|
|
346
|
+
|
|
347
|
+
ws.send('hello')
|
|
348
|
+
await ws.assertMessage('echo:hello')
|
|
349
|
+
|
|
350
|
+
ws.close()
|
|
351
|
+
await ws.waitForClose()
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## SSE Testing
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
const sse = await module.sse('/streaming/events')
|
|
358
|
+
.actingAs({ id: user.id })
|
|
359
|
+
.connect()
|
|
360
|
+
|
|
361
|
+
await sse.assertEvent({ event: 'message', data: 'hello' })
|
|
362
|
+
await sse.assertJsonEventData({ status: 'done' })
|
|
363
|
+
await sse.waitForEnd()
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Command Testing
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
const result = await module
|
|
370
|
+
.quarry('users:create')
|
|
371
|
+
.withInput({ email: 'test@example.com', admin: true })
|
|
372
|
+
.run()
|
|
373
|
+
|
|
374
|
+
result.assertSuccessful()
|
|
375
|
+
result.assertOutputContains('User created')
|
|
376
|
+
result.assertExitCode(0)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Response Cache
|
|
380
|
+
|
|
381
|
+
A `ctx.cache` stub is installed by default, so `@Cacheable` / `@PurgesCache` routes are testable with no configuration.
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
const response = await module.http.get('/blog/hello-world').send()
|
|
385
|
+
response.assertHeader('Cache-Control', 'public, max-age=300')
|
|
386
|
+
|
|
387
|
+
await module.http.post('/posts/hello-world/publish').send()
|
|
388
|
+
expect(module.cache.purges).toEqual([{ tags: ['post:hello-world'] }])
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Pass `cache: false` to `Test.createTestingModule()` to reproduce a runtime where Workers Caching is genuinely unconfigured.
|
|
392
|
+
|
|
303
393
|
## Deep Mocking
|
|
304
394
|
|
|
305
395
|
Create deeply-mocked instances of any interface or class with `createMock()` from [`@golevelup/ts-vitest`](https://github.com/golevelup/nestjs/tree/master/packages/ts-vitest):
|
|
@@ -318,10 +408,21 @@ beforeEach(() => {
|
|
|
318
408
|
## Sub-path Exports
|
|
319
409
|
|
|
320
410
|
```typescript
|
|
321
|
-
import { Test, TestingModule,
|
|
411
|
+
import { Test, TestingModule, createMockFetch, http, HttpResponse } from '@stratal/testing'
|
|
322
412
|
import { createMock, type DeepMocked } from '@stratal/testing/mocks'
|
|
413
|
+
import { stratalTest, fixPgCjs, fixNobleHashesCjs } from '@stratal/testing/vitest-plugin'
|
|
414
|
+
import { FakeStorageService } from '@stratal/testing/storage'
|
|
415
|
+
import { FakeFeatureFlagService } from '@stratal/testing/feature-flags'
|
|
323
416
|
```
|
|
324
417
|
|
|
418
|
+
## Support the project
|
|
419
|
+
|
|
420
|
+
If Stratal is useful to you, **[star the repository](https://github.com/strataljs/stratal)** — it is the simplest way to help others find it.
|
|
421
|
+
|
|
422
|
+
## Maintainer
|
|
423
|
+
|
|
424
|
+
Built and maintained by **Temitayo Fadojutimi** — [@adesege_](https://x.com/adesege_).
|
|
425
|
+
|
|
325
426
|
## License
|
|
326
427
|
|
|
327
428
|
MIT
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as BINDING_ENV_VAR, c as
|
|
2
|
-
export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, type ResetOptions, type TestDatabaseGlobalSetupOptions, buildConnectionString, buildTableDiscoverySql, buildTruncateSql, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString,
|
|
1
|
+
import { a as BINDING_ENV_VAR, c as WorkerDatabaseLease, d as createTestDatabaseGlobalSetup, f as databasePrefix, g as leaseWorkerDatabase, h as deriveWorkerDbName, i as resetWorkerDatabase, l as buildConnectionString, m as deriveTemplateName, n as buildTableDiscoverySql, o as DEFAULT_DB_BINDING, p as deriveAdminConnectionString, r as buildTruncateSql, s as TestDatabaseGlobalSetupOptions, t as ResetOptions, u as cloneWorkerDatabase } from "../index-B8Mw1Dxk.mjs";
|
|
2
|
+
export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, type ResetOptions, type TestDatabaseGlobalSetupOptions, type WorkerDatabaseLease, buildConnectionString, buildTableDiscoverySql, buildTruncateSql, cloneWorkerDatabase, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString, deriveTemplateName, deriveWorkerDbName, leaseWorkerDatabase, resetWorkerDatabase };
|
package/dist/database/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as DEFAULT_DB_BINDING, c as
|
|
2
|
-
export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, buildConnectionString, buildTableDiscoverySql, buildTruncateSql, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString,
|
|
1
|
+
import { a as DEFAULT_DB_BINDING, c as createTestDatabaseGlobalSetup, d as deriveTemplateName, f as deriveWorkerDbName, i as BINDING_ENV_VAR, l as databasePrefix, n as buildTruncateSql, o as buildConnectionString, p as leaseWorkerDatabase, r as resetWorkerDatabase, s as cloneWorkerDatabase, t as buildTableDiscoverySql, u as deriveAdminConnectionString } from "../database-Rojs69r3.mjs";
|
|
2
|
+
export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, buildConnectionString, buildTableDiscoverySql, buildTruncateSql, cloneWorkerDatabase, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString, deriveTemplateName, deriveWorkerDbName, leaseWorkerDatabase, resetWorkerDatabase };
|
|
@@ -5,10 +5,11 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
5
5
|
/**
|
|
6
6
|
* Test database isolation helpers.
|
|
7
7
|
*
|
|
8
|
-
* Implements
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* Implements leased worker databases for parallel runs against Postgres. A
|
|
9
|
+
* migrated **template** database is built once in global setup. Each test file
|
|
10
|
+
* leases one numbered worker database for as long as its isolate lives and
|
|
11
|
+
* receives a fresh clone of the template in it, so a run holds at most as many
|
|
12
|
+
* databases as it runs files at once. See `@stratal/testing/database`.
|
|
12
13
|
*
|
|
13
14
|
* `pg` is imported dynamically so this module loads without it — consumers
|
|
14
15
|
* that don't use a database never pay the dependency.
|
|
@@ -73,9 +74,9 @@ function buildConnectionString(base, dbName) {
|
|
|
73
74
|
url.pathname = `/${dbName}`;
|
|
74
75
|
return url.toString();
|
|
75
76
|
}
|
|
76
|
-
/** The shared prefix for
|
|
77
|
+
/** The shared prefix for worker databases, used as the sweep key. */
|
|
77
78
|
function databasePrefix(base) {
|
|
78
|
-
return `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}
|
|
79
|
+
return `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_w_`;
|
|
79
80
|
}
|
|
80
81
|
/** Name of the migrated template database cloned per worker. */
|
|
81
82
|
function deriveTemplateName(base) {
|
|
@@ -84,18 +85,27 @@ function deriveTemplateName(base) {
|
|
|
84
85
|
return name;
|
|
85
86
|
}
|
|
86
87
|
/**
|
|
87
|
-
* Name of the database
|
|
88
|
-
*
|
|
89
|
-
* share one — because `@cloudflare/vitest-pool-workers` isolates per file and can
|
|
90
|
-
* run a worker's files concurrently; per-file databases make cross-file
|
|
91
|
-
* contamination impossible by construction. Asserted against Postgres' 63-char
|
|
92
|
-
* identifier limit (keep the base name short so `_f_<token>` fits).
|
|
88
|
+
* Name of the worker database behind lease `slot`. Asserted against Postgres'
|
|
89
|
+
* 63-char identifier limit (keep the base name short so `_w_<slot>` fits).
|
|
93
90
|
*/
|
|
94
|
-
function
|
|
95
|
-
const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}
|
|
96
|
-
assertIdentifierLength(name, "
|
|
91
|
+
function deriveWorkerDbName(base, slot) {
|
|
92
|
+
const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_w_${slot}`;
|
|
93
|
+
assertIdentifierLength(name, "worker database name");
|
|
97
94
|
return name;
|
|
98
95
|
}
|
|
96
|
+
/** The lease slot a worker database name carries, or `null` for any other name. */
|
|
97
|
+
function slotOfWorkerDb(prefix, dbName) {
|
|
98
|
+
if (!dbName.startsWith(prefix)) return null;
|
|
99
|
+
const slot = dbName.slice(prefix.length);
|
|
100
|
+
return /^\d+$/.test(slot) ? Number(slot) : null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The advisory-lock key that reserves lease `slot` of `template`. Keyed on the
|
|
104
|
+
* template so two suites with different base databases never compete for slots.
|
|
105
|
+
*/
|
|
106
|
+
function leaseLockKey(template, slot) {
|
|
107
|
+
return `stratal:worker-db-lease:${template}:${slot}`;
|
|
108
|
+
}
|
|
99
109
|
async function withAdminClient(adminConn, fn) {
|
|
100
110
|
const client = new (await (importPg$1())).Client({ connectionString: adminConn });
|
|
101
111
|
await client.connect();
|
|
@@ -105,10 +115,6 @@ async function withAdminClient(adminConn, fn) {
|
|
|
105
115
|
await client.end();
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
|
-
/** True when the error is Postgres' "duplicate database" (concurrent create). */
|
|
109
|
-
function isDuplicateDatabase(error) {
|
|
110
|
-
return error?.code === "42P04";
|
|
111
|
-
}
|
|
112
118
|
/**
|
|
113
119
|
* True for SQLSTATE 55006 ("source database is being accessed by other users") —
|
|
114
120
|
* what `CREATE DATABASE ... TEMPLATE t` raises while another session is using the
|
|
@@ -118,40 +124,31 @@ function isTemplateInUse(error) {
|
|
|
118
124
|
return error?.code === "55006";
|
|
119
125
|
}
|
|
120
126
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
121
|
-
/** True when a database with `dbName` already exists. */
|
|
122
|
-
async function databaseExists(query, dbName) {
|
|
123
|
-
const { rows } = await query(`SELECT 1 AS one FROM pg_database WHERE datname = ${quoteLiteral(dbName)}`);
|
|
124
|
-
return rows.length > 0;
|
|
125
|
-
}
|
|
126
127
|
/**
|
|
127
|
-
*
|
|
128
|
-
* lock-frugal: it first checks `pg_database`, so the common "already exists" path
|
|
129
|
-
* (every file after a slot's first) never issues `CREATE DATABASE ... TEMPLATE`
|
|
130
|
-
* and never takes the clone lock.
|
|
128
|
+
* Replace worker database `dbName` with a fresh clone of `template`.
|
|
131
129
|
*
|
|
132
130
|
* The clone is serialized across all workers and processes by one Postgres
|
|
133
131
|
* advisory lock keyed on the template. Postgres permits only one
|
|
134
132
|
* `CREATE DATABASE ... TEMPLATE t` at a time — a concurrent one fails with
|
|
135
|
-
* SQLSTATE 55006
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
133
|
+
* SQLSTATE 55006 — so without the lock every file starting at once would race
|
|
134
|
+
* and all but one would fail. The lock funnels them one at a time, each
|
|
135
|
+
* blocking *in Postgres* (not busy-waiting) until its turn. A create that still
|
|
136
|
+
* hits a transient 55006 is retried with backoff.
|
|
137
|
+
*
|
|
138
|
+
* `WITH (FORCE)` ends connections the previous lessee's pool left open: its
|
|
139
|
+
* isolate is gone, so nothing will close them.
|
|
141
140
|
*/
|
|
142
|
-
async function
|
|
141
|
+
async function cloneWorkerDatabase(adminConn, dbName, template) {
|
|
143
142
|
await withAdminClient(adminConn, async (query) => {
|
|
144
|
-
if (await databaseExists(query, dbName)) return;
|
|
145
143
|
const lockKey = quoteLiteral(`stratal:worker-db-clone:${template}`);
|
|
146
144
|
await query(`SELECT pg_advisory_lock(hashtext(${lockKey}))`);
|
|
147
145
|
try {
|
|
148
|
-
|
|
146
|
+
await query(`DROP DATABASE IF EXISTS ${quoteIdent$1(dbName)} WITH (FORCE)`);
|
|
149
147
|
const create = `CREATE DATABASE ${quoteIdent$1(dbName)} TEMPLATE ${quoteIdent$1(template)}`;
|
|
150
148
|
for (let attempt = 1;; attempt++) try {
|
|
151
149
|
await query(create);
|
|
152
150
|
return;
|
|
153
151
|
} catch (error) {
|
|
154
|
-
if (isDuplicateDatabase(error)) return;
|
|
155
152
|
if (isTemplateInUse(error) && attempt < 5) {
|
|
156
153
|
await sleep(250 * attempt);
|
|
157
154
|
continue;
|
|
@@ -164,22 +161,73 @@ async function ensureWorkerDatabase(adminConn, dbName, template) {
|
|
|
164
161
|
});
|
|
165
162
|
}
|
|
166
163
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
164
|
+
* The connections holding this isolate's leases. A lease is a session-level
|
|
165
|
+
* advisory lock, so it lasts exactly as long as its connection: kept open here
|
|
166
|
+
* for the isolate's lifetime, it closes — and the slot frees — when the pool
|
|
167
|
+
* disposes the isolate at the end of its test file.
|
|
168
|
+
*/
|
|
169
|
+
const leaseConnections = [];
|
|
170
|
+
/**
|
|
171
|
+
* Lease the lowest free worker database for `base` and fill it with a fresh
|
|
172
|
+
* clone of the template.
|
|
173
|
+
*
|
|
174
|
+
* A slot is free when no other session holds its advisory lock. The pool runs
|
|
175
|
+
* each test file in its own isolate and disposes that isolate when the file
|
|
176
|
+
* ends, so a run never holds more slots than it runs files at once — and so
|
|
177
|
+
* never more worker databases. Every lease re-clones, so a file starts from
|
|
178
|
+
* exactly the template, including anything `prepare` baked into it.
|
|
179
|
+
*/
|
|
180
|
+
async function leaseWorkerDatabase(base) {
|
|
181
|
+
const pg = await importPg$1();
|
|
182
|
+
const adminConn = deriveAdminConnectionString(base);
|
|
183
|
+
const template = deriveTemplateName(base);
|
|
184
|
+
const client = new pg.Client({ connectionString: adminConn });
|
|
185
|
+
await client.connect();
|
|
186
|
+
try {
|
|
187
|
+
let slot = 0;
|
|
188
|
+
for (;; slot++) {
|
|
189
|
+
const { rows } = await client.query(`SELECT pg_try_advisory_lock(hashtext(${quoteLiteral(leaseLockKey(template, slot))})) AS acquired`);
|
|
190
|
+
if (rows[0]?.acquired) break;
|
|
191
|
+
}
|
|
192
|
+
const name = deriveWorkerDbName(base, slot);
|
|
193
|
+
await cloneWorkerDatabase(adminConn, name, template);
|
|
194
|
+
leaseConnections.push(client);
|
|
195
|
+
return {
|
|
196
|
+
name,
|
|
197
|
+
connectionString: buildConnectionString(base, name)
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
await client.end().catch(() => {});
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Drop worker databases nobody is using, leaving a concurrent process's live
|
|
206
|
+
* ones intact.
|
|
169
207
|
*
|
|
170
|
-
* Multiple setups can run at once (CI sharding, several e2e projects)
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* between the check and the drop.
|
|
208
|
+
* Multiple setups can run at once (CI sharding, several e2e projects), so a
|
|
209
|
+
* database is dropped only when it has **no active backend connections** and
|
|
210
|
+
* its lease slot is free. The slot check closes the window between a lease and
|
|
211
|
+
* the lessee's first connection: a just-cloned database has no connections yet
|
|
212
|
+
* but is already spoken for. The sweep takes the slot's lock itself while it
|
|
213
|
+
* drops, so no lease can begin mid-drop.
|
|
177
214
|
*/
|
|
178
|
-
async function sweepStaleDatabases(adminConn, prefix) {
|
|
215
|
+
async function sweepStaleDatabases(adminConn, prefix, template) {
|
|
179
216
|
const likePrefix = prefix.replace(/'/g, "''").replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
180
217
|
await withAdminClient(adminConn, async (query) => {
|
|
181
218
|
const { rows } = await query(`SELECT d.datname FROM pg_database d WHERE d.datname LIKE '${likePrefix}%' ESCAPE '\\' AND NOT EXISTS (SELECT 1 FROM pg_stat_activity a WHERE a.datname = d.datname)`);
|
|
182
|
-
for (const { datname } of rows)
|
|
219
|
+
for (const { datname } of rows) {
|
|
220
|
+
const slot = slotOfWorkerDb(prefix, datname);
|
|
221
|
+
if (slot === null) continue;
|
|
222
|
+
const lockKey = quoteLiteral(leaseLockKey(template, slot));
|
|
223
|
+
const { rows: lock } = await query(`SELECT pg_try_advisory_lock(hashtext(${lockKey})) AS acquired`);
|
|
224
|
+
if (!lock[0]?.acquired) continue;
|
|
225
|
+
try {
|
|
226
|
+
await query(`DROP DATABASE IF EXISTS ${quoteIdent$1(datname)} WITH (FORCE)`);
|
|
227
|
+
} finally {
|
|
228
|
+
await query(`SELECT pg_advisory_unlock(hashtext(${lockKey}))`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
183
231
|
});
|
|
184
232
|
}
|
|
185
233
|
/**
|
|
@@ -274,7 +322,7 @@ function computeSchemaFingerprint(schema, migrate, prepare) {
|
|
|
274
322
|
* Read the template database's stored schema fingerprint (kept as the database
|
|
275
323
|
* COMMENT). Returns `null` when the template does not exist or carries no
|
|
276
324
|
* fingerprint. Database comments are NOT copied by `CREATE DATABASE ...
|
|
277
|
-
* TEMPLATE`, so
|
|
325
|
+
* TEMPLATE`, so worker clones never inherit it.
|
|
278
326
|
*/
|
|
279
327
|
async function readTemplateFingerprint(query, template) {
|
|
280
328
|
const { rows } = await query(`SELECT shobj_description(oid, 'pg_database') AS fingerprint FROM pg_database WHERE datname = ${quoteLiteral(template)}`);
|
|
@@ -284,9 +332,9 @@ async function readTemplateFingerprint(query, template) {
|
|
|
284
332
|
* Build a Vitest `globalSetup` default export that prepares the test database.
|
|
285
333
|
*
|
|
286
334
|
* Under a Postgres advisory lock (so concurrent setups across CI shards /
|
|
287
|
-
* multiple e2e projects don't clobber each other), sweeps
|
|
335
|
+
* multiple e2e projects don't clobber each other), sweeps unused worker
|
|
288
336
|
* databases, then ensures a migrated (and optionally prepared) template exists
|
|
289
|
-
* — ready to be cloned
|
|
337
|
+
* — ready to be cloned into each leased worker database.
|
|
290
338
|
*
|
|
291
339
|
* **Template reuse.** The template is fingerprinted from the `schema` source(s)
|
|
292
340
|
* + the `migrate` routine + the `prepare` routine, and the fingerprint is
|
|
@@ -299,11 +347,11 @@ async function readTemplateFingerprint(query, template) {
|
|
|
299
347
|
*
|
|
300
348
|
* **Concurrency model.** The reuse check + rebuild runs under
|
|
301
349
|
* `pg_advisory_lock(hashtext(<template>))`, so only one process rebuilds the
|
|
302
|
-
* template at a time. The
|
|
303
|
-
*
|
|
304
|
-
* intact
|
|
305
|
-
*
|
|
306
|
-
* the
|
|
350
|
+
* template at a time. The sweep only drops worker databases with **no active
|
|
351
|
+
* connections** and a free lease slot, leaving a sibling process's live databases
|
|
352
|
+
* intact — which is what lets it run at teardown as well as at setup, so a run
|
|
353
|
+
* takes its own databases with it. Teardown deliberately does **not** drop the
|
|
354
|
+
* template: it is shared, and the next run reuses it by fingerprint.
|
|
307
355
|
*
|
|
308
356
|
* @example
|
|
309
357
|
* ```ts
|
|
@@ -324,9 +372,10 @@ function createTestDatabaseGlobalSetup(opts) {
|
|
|
324
372
|
const adminConn = deriveAdminConnectionString(base);
|
|
325
373
|
const template = opts.templateName ?? deriveTemplateName(base);
|
|
326
374
|
const prefix = databasePrefix(base);
|
|
375
|
+
const leaseTemplate = deriveTemplateName(base);
|
|
327
376
|
const fingerprint = computeSchemaFingerprint(opts.schema, opts.migrate, opts.prepare);
|
|
328
377
|
await withAdvisoryLock(adminConn, template, async () => {
|
|
329
|
-
await sweepStaleDatabases(adminConn, prefix);
|
|
378
|
+
await sweepStaleDatabases(adminConn, prefix, leaseTemplate);
|
|
330
379
|
if (await withAdminClient(adminConn, (query) => readTemplateFingerprint(query, template)) === fingerprint) return;
|
|
331
380
|
await withAdminClient(adminConn, async (query) => {
|
|
332
381
|
await query(`DROP DATABASE IF EXISTS ${quoteIdent$1(template)} WITH (FORCE)`);
|
|
@@ -337,6 +386,9 @@ function createTestDatabaseGlobalSetup(opts) {
|
|
|
337
386
|
if (opts.prepare) await opts.prepare(templateConn);
|
|
338
387
|
await withAdminClient(adminConn, (query) => query(`COMMENT ON DATABASE ${quoteIdent$1(template)} IS ${quoteLiteral(fingerprint)}`));
|
|
339
388
|
});
|
|
389
|
+
return async () => {
|
|
390
|
+
await sweepStaleDatabases(adminConn, prefix, leaseTemplate);
|
|
391
|
+
};
|
|
340
392
|
};
|
|
341
393
|
}
|
|
342
394
|
//#endregion
|
|
@@ -396,6 +448,6 @@ async function resetWorkerDatabase(connectionString, opts = {}) {
|
|
|
396
448
|
}
|
|
397
449
|
}
|
|
398
450
|
//#endregion
|
|
399
|
-
export { DEFAULT_DB_BINDING as a,
|
|
451
|
+
export { DEFAULT_DB_BINDING as a, createTestDatabaseGlobalSetup as c, deriveTemplateName as d, deriveWorkerDbName as f, BINDING_ENV_VAR as i, databasePrefix as l, buildTruncateSql as n, buildConnectionString as o, leaseWorkerDatabase as p, resetWorkerDatabase as r, cloneWorkerDatabase as s, buildTableDiscoverySql as t, deriveAdminConnectionString as u };
|
|
400
452
|
|
|
401
|
-
//# sourceMappingURL=database-
|
|
453
|
+
//# sourceMappingURL=database-Rojs69r3.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database-Rojs69r3.mjs","names":["quoteIdent","importPg"],"sources":["../src/database/test-database.ts","../src/database/reset.ts"],"sourcesContent":["/**\n * Test database isolation helpers.\n *\n * Implements leased worker databases for parallel runs against Postgres. A\n * migrated **template** database is built once in global setup. Each test file\n * leases one numbered worker database for as long as its isolate lives and\n * receives a fresh clone of the template in it, so a run holds at most as many\n * databases as it runs files at once. See `@stratal/testing/database`.\n *\n * `pg` is imported dynamically so this module loads without it — consumers\n * that don't use a database never pay the dependency.\n */\n\nimport { createHash } from 'node:crypto'\nimport { readFileSync, readdirSync, statSync } from 'node:fs'\nimport { basename, dirname, join, resolve } from 'node:path'\n\nimport type pg from 'pg'\n\n/** Env var carrying the name of the Hyperdrive binding to isolate. */\nexport const BINDING_ENV_VAR = 'STRATAL_TEST_DB_BINDING'\n\n/** Default Hyperdrive binding name when none is configured. */\nexport const DEFAULT_DB_BINDING = 'DB'\n\n/**\n * Postgres' identifier length limit. Names longer than this are silently\n * truncated by the server, which would cause distinct logical names to collide\n * on the same physical database. We assert against it everywhere a name is\n * derived.\n */\nconst MAX_IDENTIFIER_LENGTH = 63\n\n/** Quote a Postgres identifier for safe interpolation into DDL. */\nfunction quoteIdent(name: string): string {\n return `\"${name.replace(/\"/g, '\"\"')}\"`\n}\n\n/** Quote a Postgres string literal for safe interpolation into SQL. */\nfunction quoteLiteral(value: string): string {\n return `'${value.replace(/'/g, \"''\")}'`\n}\n\n/**\n * Dynamically import `pg` (an optional peer), surfacing an actionable error\n * instead of a raw module-not-found when database isolation is requested\n * without the dependency installed.\n */\nasync function importPg(): Promise<typeof pg> {\n try {\n const { default: pg } = await import('pg')\n return pg\n } catch (error) {\n throw new Error(\n \"[stratal-testing] `pg` is required for database isolation but is not installed. \" +\n 'Install it: `npm install --save-dev pg` (or `yarn add -D pg`).',\n { cause: error },\n )\n }\n}\n\n/**\n * Assert a derived identifier fits within Postgres' {@link MAX_IDENTIFIER_LENGTH}\n * limit. Throws a clear, actionable error instead of letting the server\n * silently truncate and collide names.\n */\nfunction assertIdentifierLength(name: string, kind: string): void {\n if (name.length > MAX_IDENTIFIER_LENGTH) {\n throw new Error(\n `[stratal-testing] Derived ${kind} \"${name}\" is ${name.length} characters, ` +\n `exceeding Postgres' ${MAX_IDENTIFIER_LENGTH}-character identifier limit. ` +\n 'Use a shorter base database name so the test-isolation suffix fits.',\n )\n }\n}\n\n/** Parse the database name out of a Postgres connection URL. */\nfunction databaseNameOf(connectionString: string): string {\n const name = new URL(connectionString).pathname.replace(/^\\//, '')\n return name || 'postgres'\n}\n\n/**\n * Derive an admin connection string pointing at the `postgres` maintenance\n * database. `CREATE`/`DROP DATABASE` cannot run on a connection bound to the\n * target database, so administration always goes through `postgres`.\n */\nexport function deriveAdminConnectionString(connectionString: string): string {\n const url = new URL(connectionString)\n url.pathname = '/postgres'\n return url.toString()\n}\n\n/** Build a connection string identical to `base` but pointing at `dbName`. */\nexport function buildConnectionString(base: string, dbName: string): string {\n const url = new URL(base)\n url.pathname = `/${dbName}`\n return url.toString()\n}\n\n/** The shared prefix for worker databases, used as the sweep key. */\nexport function databasePrefix(base: string): string {\n const baseName = databaseNameOf(base).replace(/[^a-z0-9_]/gi, '_')\n return `${baseName}_w_`\n}\n\n/** Name of the migrated template database cloned per worker. */\nexport function deriveTemplateName(base: string): string {\n const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, '_')}_template`\n assertIdentifierLength(name, 'template database name')\n return name\n}\n\n/**\n * Name of the worker database behind lease `slot`. Asserted against Postgres'\n * 63-char identifier limit (keep the base name short so `_w_<slot>` fits).\n */\nexport function deriveWorkerDbName(base: string, slot: number): string {\n const baseName = databaseNameOf(base).replace(/[^a-z0-9_]/gi, '_')\n const name = `${baseName}_w_${slot}`\n assertIdentifierLength(name, 'worker database name')\n return name\n}\n\n/** The lease slot a worker database name carries, or `null` for any other name. */\nfunction slotOfWorkerDb(prefix: string, dbName: string): number | null {\n if (!dbName.startsWith(prefix)) return null\n const slot = dbName.slice(prefix.length)\n return /^\\d+$/.test(slot) ? Number(slot) : null\n}\n\n/**\n * The advisory-lock key that reserves lease `slot` of `template`. Keyed on the\n * template so two suites with different base databases never compete for slots.\n */\nfunction leaseLockKey(template: string, slot: number): string {\n return `stratal:worker-db-lease:${template}:${slot}`\n}\n\nasync function withAdminClient<T>(adminConn: string, fn: (query: (sql: string) => Promise<unknown>) => Promise<T>): Promise<T> {\n const pg = await importPg()\n const client = new pg.Client({ connectionString: adminConn })\n await client.connect()\n try {\n return await fn((sql) => client.query(sql))\n } finally {\n await client.end()\n }\n}\n\n/**\n * True for SQLSTATE 55006 (\"source database is being accessed by other users\") —\n * what `CREATE DATABASE ... TEMPLATE t` raises while another session is using the\n * template (e.g. a concurrent clone of the same template).\n */\nfunction isTemplateInUse(error: unknown): boolean {\n return (error as { code?: string })?.code === '55006'\n}\n\nconst sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))\n\n/**\n * Replace worker database `dbName` with a fresh clone of `template`.\n *\n * The clone is serialized across all workers and processes by one Postgres\n * advisory lock keyed on the template. Postgres permits only one\n * `CREATE DATABASE ... TEMPLATE t` at a time — a concurrent one fails with\n * SQLSTATE 55006 — so without the lock every file starting at once would race\n * and all but one would fail. The lock funnels them one at a time, each\n * blocking *in Postgres* (not busy-waiting) until its turn. A create that still\n * hits a transient 55006 is retried with backoff.\n *\n * `WITH (FORCE)` ends connections the previous lessee's pool left open: its\n * isolate is gone, so nothing will close them.\n */\nexport async function cloneWorkerDatabase(\n adminConn: string,\n dbName: string,\n template: string,\n): Promise<void> {\n await withAdminClient(adminConn, async (query) => {\n const lockKey = quoteLiteral(`stratal:worker-db-clone:${template}`)\n await query(`SELECT pg_advisory_lock(hashtext(${lockKey}))`)\n try {\n await query(`DROP DATABASE IF EXISTS ${quoteIdent(dbName)} WITH (FORCE)`)\n const create = `CREATE DATABASE ${quoteIdent(dbName)} TEMPLATE ${quoteIdent(template)}`\n for (let attempt = 1; ; attempt++) {\n try {\n await query(create)\n return\n } catch (error) {\n if (isTemplateInUse(error) && attempt < 5) {\n await sleep(250 * attempt)\n continue\n }\n throw error\n }\n }\n } finally {\n await query(`SELECT pg_advisory_unlock(hashtext(${lockKey}))`)\n }\n })\n}\n\n/** A worker database leased to the current isolate. */\nexport interface WorkerDatabaseLease {\n /** The leased database's name (`<base>_w_<slot>`). */\n name: string\n /** `base` pointed at the leased database. */\n connectionString: string\n}\n\n/**\n * The connections holding this isolate's leases. A lease is a session-level\n * advisory lock, so it lasts exactly as long as its connection: kept open here\n * for the isolate's lifetime, it closes — and the slot frees — when the pool\n * disposes the isolate at the end of its test file.\n */\nconst leaseConnections: pg.Client[] = []\n\n/**\n * Lease the lowest free worker database for `base` and fill it with a fresh\n * clone of the template.\n *\n * A slot is free when no other session holds its advisory lock. The pool runs\n * each test file in its own isolate and disposes that isolate when the file\n * ends, so a run never holds more slots than it runs files at once — and so\n * never more worker databases. Every lease re-clones, so a file starts from\n * exactly the template, including anything `prepare` baked into it.\n */\nexport async function leaseWorkerDatabase(base: string): Promise<WorkerDatabaseLease> {\n const pg = await importPg()\n const adminConn = deriveAdminConnectionString(base)\n const template = deriveTemplateName(base)\n const client = new pg.Client({ connectionString: adminConn })\n await client.connect()\n try {\n let slot = 0\n for (; ; slot++) {\n const { rows } = await client.query<{ acquired: boolean }>(\n `SELECT pg_try_advisory_lock(hashtext(${quoteLiteral(leaseLockKey(template, slot))})) AS acquired`,\n )\n if (rows[0]?.acquired) break\n }\n const name = deriveWorkerDbName(base, slot)\n await cloneWorkerDatabase(adminConn, name, template)\n leaseConnections.push(client)\n return { name, connectionString: buildConnectionString(base, name) }\n } catch (error) {\n await client.end().catch(() => {\n // The lease failed; its connection's close error adds nothing.\n })\n throw error\n }\n}\n\n/**\n * Drop worker databases nobody is using, leaving a concurrent process's live\n * ones intact.\n *\n * Multiple setups can run at once (CI sharding, several e2e projects), so a\n * database is dropped only when it has **no active backend connections** and\n * its lease slot is free. The slot check closes the window between a lease and\n * the lessee's first connection: a just-cloned database has no connections yet\n * but is already spoken for. The sweep takes the slot's lock itself while it\n * drops, so no lease can begin mid-drop.\n */\nasync function sweepStaleDatabases(adminConn: string, prefix: string, template: string): Promise<void> {\n // Escape both the SQL-string quote and the LIKE metacharacters (`\\`, `%`, `_`)\n // so a prefix containing `_` (a single-char wildcard) can't over-match and drop\n // an unrelated database. `\\` is the explicit ESCAPE character below.\n const likePrefix = prefix\n .replace(/'/g, \"''\")\n .replace(/[\\\\%_]/g, (c) => `\\\\${c}`)\n await withAdminClient(adminConn, async (query) => {\n const { rows } = (await query(\n `SELECT d.datname FROM pg_database d ` +\n `WHERE d.datname LIKE '${likePrefix}%' ESCAPE '\\\\' ` +\n `AND NOT EXISTS (SELECT 1 FROM pg_stat_activity a WHERE a.datname = d.datname)`,\n )) as { rows: { datname: string }[] }\n for (const { datname } of rows) {\n const slot = slotOfWorkerDb(prefix, datname)\n if (slot === null) continue\n const lockKey = quoteLiteral(leaseLockKey(template, slot))\n const { rows: lock } = (await query(\n `SELECT pg_try_advisory_lock(hashtext(${lockKey})) AS acquired`,\n )) as { rows: { acquired: boolean }[] }\n if (!lock[0]?.acquired) continue\n try {\n await query(`DROP DATABASE IF EXISTS ${quoteIdent(datname)} WITH (FORCE)`)\n } finally {\n await query(`SELECT pg_advisory_unlock(hashtext(${lockKey}))`)\n }\n }\n })\n}\n\n/**\n * Run `fn` while holding a session-level Postgres advisory lock keyed to\n * `lockKey`, serializing template setup across concurrent processes (CI\n * sharding, multiple e2e projects) so they don't drop/recreate the template out\n * from under each other. The lock is released in `finally`.\n */\nasync function withAdvisoryLock<T>(adminConn: string, lockKey: string, fn: () => Promise<T>): Promise<T> {\n return withAdminClient(adminConn, async (query) => {\n await query(`SELECT pg_advisory_lock(hashtext(${quoteLiteral(lockKey)}))`)\n try {\n return await fn()\n } finally {\n await query(`SELECT pg_advisory_unlock(hashtext(${quoteLiteral(lockKey)}))`)\n }\n })\n}\n\n/** Schema source file extensions hashed into the template fingerprint. */\nconst SCHEMA_FILE_RE = /\\.(zmodel|prisma|sql)$/\n\n/** Matches ZModel `import \"...\"` / `import '...'` statements. */\nconst ZMODEL_IMPORT_RE = /^\\s*import\\s+['\"]([^'\"]+)['\"]/gm\n\n/**\n * Follow a ZModel file's `import` graph, collecting the root plus every\n * transitively imported `.zmodel` file — ZenStack supports multi-file schemas,\n * so editing an imported file must invalidate the fingerprint. Import paths\n * resolve relative to the importing file; the `.zmodel` extension is optional.\n * A missing import target is skipped (migration surfaces the real error).\n */\nfunction collectZmodelImports(file: string, seen: Set<string>): void {\n if (seen.has(file)) return\n let content: string\n try {\n content = readFileSync(file, 'utf8')\n } catch {\n return // missing import target — not part of the fingerprint\n }\n seen.add(file)\n for (const [, importPath] of content.matchAll(ZMODEL_IMPORT_RE)) {\n const target = resolve(dirname(file), importPath)\n collectZmodelImports(target.endsWith('.zmodel') ? target : `${target}.zmodel`, seen)\n }\n}\n\n/**\n * Expand a schema path into the concrete files to hash. A directory contributes\n * every schema file in its tree; a single `.zmodel` file contributes its whole\n * `import` graph; any other file contributes itself.\n */\nfunction collectSchemaFiles(path: string): string[] {\n if (statSync(path).isDirectory()) {\n const out: string[] = []\n for (const entry of readdirSync(path, { withFileTypes: true })) {\n const full = join(path, entry.name)\n if (entry.isDirectory()) out.push(...collectSchemaFiles(full))\n else if (SCHEMA_FILE_RE.test(entry.name)) out.push(full)\n }\n return out\n }\n if (path.endsWith('.zmodel')) {\n const seen = new Set<string>()\n collectZmodelImports(path, seen)\n return [...seen]\n }\n return [path]\n}\n\n/**\n * A content-derived fingerprint of the schema source(s) plus the migrate and\n * prepare routines. The template is reused across runs while this is\n * unchanged; any edit to a schema file — or to how migration/preparation run\n * — changes it and forces a rebuild. Uses file basenames (not absolute paths)\n * so it is stable across checkouts.\n *\n * @internal exported for fingerprint unit tests\n */\nexport function computeSchemaFingerprint(\n schema: string | string[],\n migrate: TestDatabaseGlobalSetupOptions['migrate'],\n prepare?: TestDatabaseGlobalSetupOptions['prepare'],\n): string {\n const roots = Array.isArray(schema) ? schema : [schema]\n const files = roots.flatMap(collectSchemaFiles).sort()\n if (files.length === 0) {\n throw new Error(\n `[stratal-testing] No schema files found for fingerprinting under: ${roots.join(', ')}`,\n )\n }\n const hash = createHash('sha256')\n for (const file of files) {\n hash.update(basename(file))\n hash.update('\\0')\n hash.update(readFileSync(file))\n hash.update('\\0')\n }\n hash.update(migrate.toString())\n hash.update('\\0')\n hash.update(prepare ? prepare.toString() : '')\n return hash.digest('hex')\n}\n\n/**\n * Read the template database's stored schema fingerprint (kept as the database\n * COMMENT). Returns `null` when the template does not exist or carries no\n * fingerprint. Database comments are NOT copied by `CREATE DATABASE ...\n * TEMPLATE`, so worker clones never inherit it.\n */\nasync function readTemplateFingerprint(\n query: (sql: string) => Promise<unknown>,\n template: string,\n): Promise<string | null> {\n const { rows } = (await query(\n `SELECT shobj_description(oid, 'pg_database') AS fingerprint ` +\n `FROM pg_database WHERE datname = ${quoteLiteral(template)}`,\n )) as { rows: { fingerprint: string | null }[] }\n return rows.length === 0 ? null : rows[0].fingerprint\n}\n\n/** Options for {@link createTestDatabaseGlobalSetup}. */\nexport interface TestDatabaseGlobalSetupOptions {\n /** Run migrations against the template connection string. */\n migrate: (connectionString: string) => void | Promise<void>\n /**\n * Schema source(s) — file or directory path(s). Contents + `migrate` + `prepare`\n * are hashed into the template fingerprint; the template is reused across runs\n * while unchanged. For a ZenStack multi-file schema pass the root `.zmodel`.\n */\n schema: string | string[]\n /**\n * One-time preparation run against the template **after** migrations (before\n * the fingerprint is stamped). Bake expensive baseline state here — seed data,\n * a default tenant schema, reference rows — so every worker database inherits\n * it via the clone instead of rebuilding it per test.\n *\n * The fingerprint hashes this hook's **source text only** — if `prepare` reads\n * external data files (seed JSON/SQL) at runtime, changing only those files\n * will NOT invalidate the template. Bump the hook's source (e.g. a version\n * comment) or drop the template manually when seed data changes.\n */\n prepare?: (connectionString: string) => void | Promise<void>\n /** Base/admin connection string. Defaults to `process.env.DATABASE_URL`. */\n connectionString?: string\n /** Template database name. Defaults to `<baseDbName>_template`. */\n templateName?: string\n}\n\n/**\n * Build a Vitest `globalSetup` default export that prepares the test database.\n *\n * Under a Postgres advisory lock (so concurrent setups across CI shards /\n * multiple e2e projects don't clobber each other), sweeps unused worker\n * databases, then ensures a migrated (and optionally prepared) template exists\n * — ready to be cloned into each leased worker database.\n *\n * **Template reuse.** The template is fingerprinted from the `schema` source(s)\n * + the `migrate` routine + the `prepare` routine, and the fingerprint is\n * stored as the template's database COMMENT. On each run, a matching\n * fingerprint means nothing has changed and the existing template is reused\n * as-is — `migrate`/`prepare` run **only** on the first run after an edit (or on\n * a fresh database). The fingerprint is stamped only after a successful\n * migrate + prepare, so a match always implies a complete template; there is no\n * force/skip flag — reuse is purely fingerprint-driven.\n *\n * **Concurrency model.** The reuse check + rebuild runs under\n * `pg_advisory_lock(hashtext(<template>))`, so only one process rebuilds the\n * template at a time. The sweep only drops worker databases with **no active\n * connections** and a free lease slot, leaving a sibling process's live databases\n * intact — which is what lets it run at teardown as well as at setup, so a run\n * takes its own databases with it. Teardown deliberately does **not** drop the\n * template: it is shared, and the next run reuses it by fingerprint.\n *\n * @example\n * ```ts\n * // test/global-setup.ts\n * import { createTestDatabaseGlobalSetup } from '@stratal/testing/database'\n *\n * export default createTestDatabaseGlobalSetup({\n * schema: schemaPath, // file or directory — reused-when-unchanged fingerprint\n * migrate: (conn) => execFileSync(zenstackBin, ['db', 'push', '--force-reset', `--schema=${schemaPath}`, '--accept-data-loss'],\n * { env: { ...process.env, DATABASE_URL: conn }, stdio: 'inherit' }),\n * })\n * ```\n */\nexport function createTestDatabaseGlobalSetup(\n opts: TestDatabaseGlobalSetupOptions,\n): () => Promise<() => Promise<void>> {\n return async () => {\n const base = opts.connectionString ?? process.env.DATABASE_URL\n if (!base) {\n throw new Error(\n '[stratal-testing] No connection string for test database setup. Set process.env.DATABASE_URL or pass `connectionString`.',\n )\n }\n\n const adminConn = deriveAdminConnectionString(base)\n const template = opts.templateName ?? deriveTemplateName(base)\n const prefix = databasePrefix(base)\n // Leases are keyed on the derived template name, so the sweep checks the same keys.\n const leaseTemplate = deriveTemplateName(base)\n const fingerprint = computeSchemaFingerprint(opts.schema, opts.migrate, opts.prepare)\n\n // Serialize template rebuild across concurrent setups so they don't drop or\n // migrate the template out from under each other.\n await withAdvisoryLock(adminConn, template, async () => {\n await sweepStaleDatabases(adminConn, prefix, leaseTemplate)\n\n // Reuse the existing template when its stored fingerprint matches — the\n // schema is unchanged, so the migrated template is ready to clone. Only a\n // fingerprint mismatch (or a missing template) pays the migration cost.\n const current = await withAdminClient(adminConn, (query) =>\n readTemplateFingerprint(query, template),\n )\n if (current === fingerprint) return\n\n await withAdminClient(adminConn, async (query) => {\n await query(`DROP DATABASE IF EXISTS ${quoteIdent(template)} WITH (FORCE)`)\n await query(`CREATE DATABASE ${quoteIdent(template)}`)\n })\n const templateConn = buildConnectionString(base, template)\n await opts.migrate(templateConn)\n if (opts.prepare) await opts.prepare(templateConn)\n // Stamp the fingerprint only after a successful migrate + prepare, so a\n // matching fingerprint always implies a complete, ready template. Database\n // COMMENTs are not copied by CREATE DATABASE ... TEMPLATE, so clones stay\n // clean.\n await withAdminClient(adminConn, (query) =>\n query(`COMMENT ON DATABASE ${quoteIdent(template)} IS ${quoteLiteral(fingerprint)}`),\n )\n })\n\n // The run reclaims its own worker databases as it ends.\n //\n // The sweep matches on the base database's name, so the only process that\n // ever sees these databases is one configured with that same name. Nothing\n // else will reach them, which is why the sweep belongs here as well as at\n // setup — and why a pile of them is otherwise bounded by nothing.\n //\n // Connection- and lease-guarded, which is what makes it safe here: by now\n // this run's files have finished and hold neither, while a concurrent\n // process's databases are still leased and are skipped. The template is\n // shared and reused by fingerprint, so it stays.\n //\n // Once per run, not once per file: the drop is trivial, but reaching the\n // maintenance database to issue it is not, and a per-file hook pays that\n // connection on every spec file.\n return async () => {\n await sweepStaleDatabases(adminConn, prefix, leaseTemplate)\n }\n }\n}\n","import type pg from 'pg'\n\n/** Options controlling which tables a per-test reset truncates. */\nexport interface ResetOptions {\n /** Extra schemas to include beyond `current_schema()` (e.g. a tenant schema). */\n schemas?: string[]\n /**\n * Bare table names or SQL LIKE patterns (matched against `tablename` across\n * all target schemas) that must survive the reset — reference/seed data baked\n * into the template. Not schema-qualified: a `schema.table` entry matches a\n * table literally named `schema.table`, not `table` in `schema`. Migration\n * bookkeeping (`_prisma%`) is always preserved.\n */\n preserve?: string[]\n}\n\nfunction quoteIdent(ident: string): string {\n return `\"${ident.replace(/\"/g, '\"\"')}\"`\n}\n\n/**\n * Build a single `TRUNCATE ... RESTART IDENTITY CASCADE` (empty when no tables).\n * Takes `{ schema, table }` pairs and quotes each part independently, so a\n * dotted identifier (a legal quoted name, e.g. `\"my.table\"`) is preserved\n * verbatim rather than mis-split into extra qualifier segments.\n */\nexport function buildTruncateSql(tables: { schema: string; table: string }[]): string {\n if (tables.length === 0) return ''\n const quoted = tables.map((t) => `${quoteIdent(t.schema)}.${quoteIdent(t.table)}`)\n return `TRUNCATE ${quoted.join(', ')} RESTART IDENTITY CASCADE`\n}\n\n/**\n * Sentinel meaning \"the active search-path schema\" (`current_schema()`). A\n * Symbol so it can never collide with a real schema name — a schema literally\n * named `current` is passed through as the string literal `'current'`.\n */\nconst CURRENT_SCHEMA = Symbol('current_schema')\n\n/**\n * Build the SQL that discovers truncatable tables: every base table in the\n * given schemas ({@link CURRENT_SCHEMA} → `current_schema()`), excluding\n * migration bookkeeping (`_prisma%`) and any caller-supplied preserve patterns.\n * Returns a SELECT of `schemaname, tablename`. Escaping lives here so both reset\n * paths share one implementation.\n */\nexport function buildTableDiscoverySql(schemas: string[], preserve: string[]): string {\n const schemaFilter = [CURRENT_SCHEMA, ...schemas]\n .map((s) => (typeof s === 'symbol' ? 'current_schema()' : `'${s.replace(/'/g, \"''\")}'`))\n .join(', ')\n const notLike = ['_prisma%', ...preserve]\n .map((p) => `tablename NOT LIKE '${p.replace(/'/g, \"''\")}'`)\n .join(' AND ')\n return `SELECT schemaname::text AS schemaname, tablename::text AS tablename FROM pg_tables WHERE schemaname IN (${schemaFilter}) AND ${notLike}`\n}\n\nasync function importPg(): Promise<typeof pg> {\n const { default: mod } = await import('pg')\n return mod\n}\n\n/**\n * Reset a worker database between tests: discover every non-preserved table in\n * the target schemas and truncate them in one statement. Fast because the\n * schema/DDL is baked into the worker DB — reset only clears mutable rows and\n * resets identities. Opens and closes a short-lived direct `pg` connection.\n */\nexport async function resetWorkerDatabase(connectionString: string, opts: ResetOptions = {}): Promise<void> {\n const pgMod = await importPg()\n const client = new pgMod.Client({ connectionString })\n await client.connect()\n try {\n const sql = buildTableDiscoverySql(opts.schemas ?? [], opts.preserve ?? [])\n const { rows } = await client.query<{ schemaname: string; tablename: string }>(sql)\n const tables = rows.map((r) => ({ schema: r.schemaname, table: r.tablename }))\n const truncateSql = buildTruncateSql(tables)\n if (truncateSql) await client.query(truncateSql)\n } finally {\n await client.end()\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as FakeFeatureFlagService, t as FEATURE_FLAG_SERVICE_TOKEN } from "../feature-flags-
|
|
1
|
+
import { n as FakeFeatureFlagService, t as FEATURE_FLAG_SERVICE_TOKEN } from "../feature-flags-CZ1a4M2g.mjs";
|
|
2
2
|
export { FEATURE_FLAG_SERVICE_TOKEN, FakeFeatureFlagService };
|