@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +603 -0
  2. package/README.md +137 -36
  3. package/dist/database/index.d.mts +2 -2
  4. package/dist/database/index.mjs +2 -2
  5. package/dist/{database-DeMI4Igi.mjs → database-Rojs69r3.mjs} +112 -60
  6. package/dist/database-Rojs69r3.mjs.map +1 -0
  7. package/dist/{decorate-ZDdbRmsv.mjs → decorate-RQD1h28J.mjs} +1 -1
  8. package/dist/feature-flags/index.mjs +1 -1
  9. package/dist/{feature-flags-BMYurC1B.mjs → feature-flags-CZ1a4M2g.mjs} +2 -2
  10. package/dist/{feature-flags-BMYurC1B.mjs.map → feature-flags-CZ1a4M2g.mjs.map} +1 -1
  11. package/dist/{index-DUScdrKC.d.mts → index-B8Mw1Dxk.d.mts} +46 -33
  12. package/dist/index-B8Mw1Dxk.d.mts.map +1 -0
  13. package/dist/{index-Dm6e2N61.d.mts → index-D7FM6EKp.d.mts} +21 -2
  14. package/dist/index-D7FM6EKp.d.mts.map +1 -0
  15. package/dist/index.d.mts +55 -28
  16. package/dist/index.d.mts.map +1 -1
  17. package/dist/index.mjs +67 -61
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/mocks/cloudflare-workers.d.mts +6 -7
  20. package/dist/mocks/cloudflare-workers.d.mts.map +1 -1
  21. package/dist/mocks/index.d.mts +1 -1
  22. package/dist/mocks/index.mjs +1 -1
  23. package/dist/mocks/noop-rate-limiter-store.d.mts +1 -2
  24. package/dist/mocks/noop-rate-limiter-store.d.mts.map +1 -1
  25. package/dist/mocks/zenstack-language.d.mts +21 -21
  26. package/dist/mocks/zenstack-language.d.mts.map +1 -1
  27. package/dist/mocks/zenstack-language.mjs +1 -1
  28. package/dist/mocks/zenstack-language.mjs.map +1 -1
  29. package/dist/storage/index.d.mts +1 -1
  30. package/dist/storage/index.mjs +1 -1
  31. package/dist/{storage-q8RFahgU.mjs → storage-BiGamMA8.mjs} +70 -3
  32. package/dist/storage-BiGamMA8.mjs.map +1 -0
  33. package/dist/{test-worker-exports-DMct0Egv.d.mts → test-worker-exports-dCRARYEc.d.mts} +8 -7
  34. package/dist/test-worker-exports-dCRARYEc.d.mts.map +1 -0
  35. package/dist/{test-workers-cache-eiGMw9Yj.mjs → test-workers-cache-D02Pt_dE.mjs} +8 -7
  36. package/dist/{test-workers-cache-eiGMw9Yj.mjs.map → test-workers-cache-D02Pt_dE.mjs.map} +1 -1
  37. package/dist/vitest-plugin/index.d.mts +9 -9
  38. package/dist/vitest-plugin/index.d.mts.map +1 -1
  39. package/dist/vitest-plugin/index.mjs +22 -15
  40. package/dist/vitest-plugin/index.mjs.map +1 -1
  41. package/package.json +22 -19
  42. package/dist/database-DeMI4Igi.mjs.map +0 -1
  43. package/dist/index-DUScdrKC.d.mts.map +0 -1
  44. package/dist/index-Dm6e2N61.d.mts.map +0 -1
  45. package/dist/storage-q8RFahgU.mjs.map +0 -1
  46. 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://github.com/strataljs/stratal) framework applications.
3
+ Testing utilities and mocks for [Stratal](https://stratal.dev) applications.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@stratal/testing)](https://www.npmjs.com/package/@stratal/testing)
6
+ [![CI](https://github.com/strataljs/stratal/actions/workflows/ci.yml/badge.svg)](https://github.com/strataljs/stratal/actions/workflows/ci.yml)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
8
+ [![npm downloads](https://img.shields.io/npm/dm/@stratal/testing)](https://www.npmjs.com/package/@stratal/testing)
9
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
10
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/strataljs/stratal/pulls)
11
+ [![GitHub stars](https://img.shields.io/github/stars/strataljs/stratal?style=social)](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, RBAC, testing, and more |
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 = module.get(REQUEST_SCOPED_TOKEN)
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 `createFetchMock()`, backed by [undici MockAgent](https://undici.nodejs.org/#/docs/api/MockAgent):
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 { createFetchMock, type FetchMock } from '@stratal/testing'
260
+ import { createMockFetch, http, HttpResponse, type MockFetch } from '@stratal/testing'
225
261
 
226
262
  describe('GeoService', () => {
227
- let module: TestingModule
228
- let fetchMock: FetchMock
229
-
230
- beforeEach(() => {
231
- fetchMock = createFetchMock()
232
- fetchMock.activate()
233
- fetchMock.disableNetConnect()
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
- fetchMock.reset()
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
- it('handles API errors', async () => {
256
- fetchMock.mockError('https://geo.api.com/lookup', 503, 'Service Unavailable')
288
+ ### Lifecycle
257
289
 
258
- const response = await module.http
259
- .get('/api/geo/lookup?address=NYC')
260
- .send()
290
+ ```typescript
291
+ mockFetch.listen() // start intercepting
292
+ mockFetch.reset() // clear runtime handlers, between tests
293
+ mockFetch.close() // stop intercepting
294
+ ```
261
295
 
262
- response.assertServerError()
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
- For advanced scenarios, access the undici `MockPool` directly:
306
+ ### Shorthands
268
307
 
269
308
  ```typescript
270
- fetchMock
271
- .get('https://api.example.com')
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, createFetchMock } from '@stratal/testing'
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 buildConnectionString, d as deriveAdminConnectionString, f as deriveFileDbName, i as resetWorkerDatabase, l as createTestDatabaseGlobalSetup, m as ensureWorkerDatabase, n as buildTableDiscoverySql, o as DEFAULT_DB_BINDING, p as deriveTemplateName, r as buildTruncateSql, s as TestDatabaseGlobalSetupOptions, t as ResetOptions, u as databasePrefix } from "../index-DUScdrKC.mjs";
2
- export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, type ResetOptions, type TestDatabaseGlobalSetupOptions, buildConnectionString, buildTableDiscoverySql, buildTruncateSql, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString, deriveFileDbName, deriveTemplateName, ensureWorkerDatabase, resetWorkerDatabase };
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 };
@@ -1,2 +1,2 @@
1
- import { a as DEFAULT_DB_BINDING, c as databasePrefix, d as deriveTemplateName, f as ensureWorkerDatabase, i as BINDING_ENV_VAR, l as deriveAdminConnectionString, n as buildTruncateSql, o as buildConnectionString, r as resetWorkerDatabase, s as createTestDatabaseGlobalSetup, t as buildTableDiscoverySql, u as deriveFileDbName } from "../database-DeMI4Igi.mjs";
2
- export { BINDING_ENV_VAR, DEFAULT_DB_BINDING, buildConnectionString, buildTableDiscoverySql, buildTruncateSql, createTestDatabaseGlobalSetup, databasePrefix, deriveAdminConnectionString, deriveFileDbName, deriveTemplateName, ensureWorkerDatabase, resetWorkerDatabase };
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 database-per-worker isolation for parallel e2e runs against
9
- * Postgres. A migrated **template** database is built once in global setup;
10
- * each worker clones it once into a per-worker database that is reused (and
11
- * reset between tests, not dropped per file). See `@stratal/testing/database`.
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 per-file databases, used as the leak-sweep key. */
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, "_")}_f_`;
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 owned by a single test FILE, keyed by a per-file `token`.
88
- * Each test file clones the template into its OWN database — no two files ever
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 deriveFileDbName(base, token) {
95
- const name = `${databaseNameOf(base).replace(/[^a-z0-9_]/gi, "_")}_f_${token}`;
96
- assertIdentifierLength(name, "per-file database 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
- * Ensure worker database `dbName` exists, cloned from `template`. Idempotent and
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. On a fresh run every worker's first compile races to clone the
136
- * same template, so without this they'd all fire `CREATE ... TEMPLATE` at once
137
- * and all but one would fail their first file. The lock funnels them one-at-a-
138
- * time, each blocking *in Postgres* (not busy-waiting) until its turn. A create
139
- * that still hits a transient 55006 is retried with backoff; one that loses the
140
- * race for the same name (SQLSTATE 42P04) is treated as success.
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 ensureWorkerDatabase(adminConn, dbName, template) {
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
- if (await databaseExists(query, dbName)) return;
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
- * Drop leaked per-file databases matching the prefix while leaving a concurrent
168
- * process's **live** databases intact.
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). A blanket
171
- * "drop everything matching the prefix" sweep would delete a sibling process's
172
- * in-flight per-file databases. So we only drop databases that currently have
173
- * **no active backend connections** — i.e. true leaks from a crashed prior run.
174
- * A live per-file database always has the test worker's pool connected, so it is
175
- * skipped. The `WITH (FORCE)` covers the narrow race where a connection appears
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) await query(`DROP DATABASE IF EXISTS ${quoteIdent$1(datname)} WITH (FORCE)`);
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 per-file clones never inherit it.
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 leaked per-worker
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 per worker.
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 stale-database sweep only drops per-worker databases
303
- * with **no active connections**, leaving a sibling process's live databases
304
- * intact. Teardown deliberately does **not** sweep or drop the template: a
305
- * concurrent process may still be using both, and the next run's setup sweep is
306
- * the backstop for any leak.
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, databasePrefix as c, deriveTemplateName as d, ensureWorkerDatabase as f, BINDING_ENV_VAR as i, deriveAdminConnectionString as l, buildTruncateSql as n, buildConnectionString as o, resetWorkerDatabase as r, createTestDatabaseGlobalSetup as s, buildTableDiscoverySql as t, deriveFileDbName as u };
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-DeMI4Igi.mjs.map
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.140.0/helpers/esm/decorate.js
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-BMYurC1B.mjs";
1
+ import { n as FakeFeatureFlagService, t as FEATURE_FLAG_SERVICE_TOKEN } from "../feature-flags-CZ1a4M2g.mjs";
2
2
  export { FEATURE_FLAG_SERVICE_TOKEN, FakeFeatureFlagService };