@flefebvre/prisma-test-helper 0.1.0
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/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/database.d.ts +74 -0
- package/dist/database.js +93 -0
- package/dist/global-setup.d.ts +78 -0
- package/dist/global-setup.js +176 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/setup.d.ts +43 -0
- package/dist/setup.js +62 -0
- package/dist/transaction.d.ts +100 -0
- package/dist/transaction.js +240 -0
- package/package.json +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florian Lefebvre
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# @flefebvre/prisma-test-helper
|
|
2
|
+
|
|
3
|
+
An isolated, fast Postgres test database for Prisma + Vitest projects: one throwaway
|
|
4
|
+
container per test run, migrations applied once to a Template Database, one Worker
|
|
5
|
+
Database cloned per Vitest worker, and every test wrapped in a transaction that is rolled
|
|
6
|
+
back.
|
|
7
|
+
|
|
8
|
+
**Postgres-only.** The harness starts a real Postgres in Docker and relies on
|
|
9
|
+
`CREATE DATABASE … TEMPLATE` for cloning; no other database is supported. Tested against
|
|
10
|
+
**Prisma 7** and **Vitest 4** on **Node >= 20**.
|
|
11
|
+
|
|
12
|
+
## What you get
|
|
13
|
+
|
|
14
|
+
- **No cleanup code.** Each test runs inside a transaction that is rolled back when it
|
|
15
|
+
ends. The Worker Database never changes, so every test starts from the same pristine
|
|
16
|
+
clone — no truncation, no ordering constraints, no leftover rows.
|
|
17
|
+
- **Real Postgres.** Not SQLite, not a mock. Your migrations, your constraints, your
|
|
18
|
+
triggers, your `citext` columns.
|
|
19
|
+
- **Parallel by default.** One database per Vitest worker slot, so files running in
|
|
20
|
+
parallel cannot see each other's writes.
|
|
21
|
+
- **Nothing to tear down.** The container is removed when the run ends, including after a
|
|
22
|
+
crash.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Node >= 20, ESM project
|
|
27
|
+
- Docker (or another container runtime Testcontainers can reach)
|
|
28
|
+
- Peer dependencies: `vitest` ^4 and `prisma` ^7, with your migrations committed
|
|
29
|
+
(`prisma migrate deploy` is what the harness runs)
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pnpm add -D @flefebvre/prisma-test-helper
|
|
35
|
+
# or: npm i -D / yarn add -D / bun add -d
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Wiring
|
|
39
|
+
|
|
40
|
+
Five files. The blocks below are the wiring this repo's own test suite runs — see
|
|
41
|
+
`prisma.config.ts`, `tests/global-setup.ts`, `tests/setup.ts`, `tests/db/client.ts`, and
|
|
42
|
+
`vitest.config.ts`.
|
|
43
|
+
|
|
44
|
+
### 1. Prisma config
|
|
45
|
+
|
|
46
|
+
Prisma 7 reads the datasource URL from `prisma.config.ts`, not from the schema — and
|
|
47
|
+
`prisma migrate deploy` **fails without it**. The harness runs your project's own
|
|
48
|
+
`migrate deploy` with `DATABASE_URL` pointed at the throwaway container, so read it from
|
|
49
|
+
the environment here. The placeholder keeps database-free commands (`prisma generate`)
|
|
50
|
+
working.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// prisma.config.ts
|
|
54
|
+
import { defineConfig } from "prisma/config";
|
|
55
|
+
|
|
56
|
+
export default defineConfig({
|
|
57
|
+
schema: "prisma/schema.prisma",
|
|
58
|
+
migrations: { path: "prisma/migrations" },
|
|
59
|
+
datasource: {
|
|
60
|
+
url: process.env.DATABASE_URL ?? "postgresql://unset:unset@localhost:5432/unset",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 2. Your Prisma client module
|
|
66
|
+
|
|
67
|
+
The harness assumes your app reaches the database through a single module it can
|
|
68
|
+
intercept. If you already have one, use it as-is.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// src/db/client.ts
|
|
72
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
73
|
+
|
|
74
|
+
import { PrismaClient } from "../generated/prisma/client.js";
|
|
75
|
+
|
|
76
|
+
const connectionString = process.env.DATABASE_URL;
|
|
77
|
+
if (!connectionString) {
|
|
78
|
+
throw new Error("DATABASE_URL is not set");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const db = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The import path follows your own `generator client { output = … }` — adjust it to wherever
|
|
85
|
+
your schema emits the client.
|
|
86
|
+
|
|
87
|
+
Throwing when `DATABASE_URL` is unset is worth keeping. It is the tripwire for the
|
|
88
|
+
ordering rule in step 4: if the setup file ever imports your app statically, this throw
|
|
89
|
+
fires loudly instead of silently pointing the suite at your dev database.
|
|
90
|
+
|
|
91
|
+
### 3. Global setup
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// tests/global-setup.ts
|
|
95
|
+
import { createGlobalSetup } from "@flefebvre/prisma-test-helper/global-setup";
|
|
96
|
+
|
|
97
|
+
export default createGlobalSetup();
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 4. Per-worker setup
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// tests/setup.ts
|
|
104
|
+
import { vi } from "vitest";
|
|
105
|
+
|
|
106
|
+
import { installTestTransaction } from "@flefebvre/prisma-test-helper";
|
|
107
|
+
import { setupWorkerDatabase } from "@flefebvre/prisma-test-helper/setup";
|
|
108
|
+
|
|
109
|
+
const { databaseUrl, databaseName } = setupWorkerDatabase();
|
|
110
|
+
|
|
111
|
+
vi.mock("../src/db/client.js", async (importOriginal) => {
|
|
112
|
+
const actual = await importOriginal<typeof import("../src/db/client.js")>();
|
|
113
|
+
return { db: installTestTransaction(actual.db, databaseUrl, databaseName) };
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The factory returns `{ db }` because the client module above exports only `db`. If yours
|
|
118
|
+
exports anything else — types, a re-exported `Prisma` namespace, helpers — spread the
|
|
119
|
+
original too, or those exports become `undefined` at every call site:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
return { ...actual, db: installTestTransaction(actual.db, databaseUrl, databaseName) };
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
> **This file must keep zero static imports of your app.** ES imports hoist above
|
|
126
|
+
> statements, so a static `import { db } from "../src/db/client.js"` would build the
|
|
127
|
+
> client _before_ `setupWorkerDatabase()` assigns `DATABASE_URL` — pointing your whole
|
|
128
|
+
> suite at your dev database. Reach for app modules through the lazy `vi.mock` factory
|
|
129
|
+
> (as above) or a dynamic `import()`. Library imports are safe: they read no environment
|
|
130
|
+
> at import time.
|
|
131
|
+
|
|
132
|
+
### 5. Vitest config
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// vitest.config.ts
|
|
136
|
+
import { defineConfig } from "vitest/config";
|
|
137
|
+
|
|
138
|
+
export default defineConfig({
|
|
139
|
+
test: {
|
|
140
|
+
globalSetup: ["tests/global-setup.ts"],
|
|
141
|
+
setupFiles: ["tests/setup.ts"],
|
|
142
|
+
// Both are Vitest defaults, set explicitly because the harness depends on them:
|
|
143
|
+
// together they re-run `setupFiles` in a fresh process per test file.
|
|
144
|
+
pool: "forks",
|
|
145
|
+
isolate: true,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`pool: "forks"` and `isolate: true` are load-bearing. Under `isolate: false` module state
|
|
151
|
+
leaks across files, and the `setupDatabase()` opt-in guard relies on its module
|
|
152
|
+
re-instantiating per file.
|
|
153
|
+
|
|
154
|
+
### Writing a test
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
// tests/author.test.ts
|
|
158
|
+
import { expect, test } from "vitest";
|
|
159
|
+
|
|
160
|
+
import { setupDatabase } from "@flefebvre/prisma-test-helper";
|
|
161
|
+
import { db } from "../src/db/client.js";
|
|
162
|
+
|
|
163
|
+
setupDatabase();
|
|
164
|
+
|
|
165
|
+
test("persists an author", async () => {
|
|
166
|
+
await db.author.create({ data: { name: "Ada" } });
|
|
167
|
+
expect(await db.author.count()).toBe(1);
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`setupDatabase()` is the opt-in: call it once, at the top of any file that touches the
|
|
172
|
+
database. Files that never call it are untouched by the harness.
|
|
173
|
+
|
|
174
|
+
## What happens per run
|
|
175
|
+
|
|
176
|
+
One tuned throwaway Postgres container starts (tmpfs data directory, `fsync=off`). Your
|
|
177
|
+
project's `prisma migrate deploy` runs once against the **Template Database**. One
|
|
178
|
+
**Worker Database** is cloned per Vitest worker slot (`<databaseName>_1..N`) before any
|
|
179
|
+
worker forks. Each worker points `DATABASE_URL` at its own clone. Each test opens a
|
|
180
|
+
transaction and rolls it back. The container is removed when the run ends.
|
|
181
|
+
|
|
182
|
+
## API
|
|
183
|
+
|
|
184
|
+
### `createGlobalSetup(options?)`
|
|
185
|
+
|
|
186
|
+
| Option | Default | What it does |
|
|
187
|
+
| -------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
188
|
+
| `image` | `postgres:17-alpine` | Postgres image to run. Pin it to the same major as your production database, so a test never runs against a different Postgres than you deploy on. |
|
|
189
|
+
| `databaseName` | `prisma_test` | Name of the Template Database: migrated once per run, then cloned into `<databaseName>_1..N`. It never hosts a test. |
|
|
190
|
+
|
|
191
|
+
The Template Database connection URI is `provide`d to your tests, fully typed:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { inject } from "vitest";
|
|
195
|
+
|
|
196
|
+
const uri = inject("templateDatabaseUri");
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Typing works only if your global-setup file is part of your TypeScript program — that
|
|
200
|
+
file carries the module augmentation that declares these keys. If it sits outside
|
|
201
|
+
`tsconfig.json`'s `include`, `inject("templateDatabaseUri")` will not typecheck.
|
|
202
|
+
|
|
203
|
+
### `setupWorkerDatabase(): { databaseUrl, databaseName }`
|
|
204
|
+
|
|
205
|
+
Points this worker at its own Worker Database. Call it at the top of your setup file,
|
|
206
|
+
above the `vi.mock`. It reads what `createGlobalSetup` provided, rewrites the URI onto
|
|
207
|
+
this worker's clone, sets `process.env.DATABASE_URL`, and returns both halves —
|
|
208
|
+
`installTestTransaction` needs both.
|
|
209
|
+
|
|
210
|
+
### `installTestTransaction(client, databaseUrl, databaseName): client`
|
|
211
|
+
|
|
212
|
+
Wraps your real Prisma client and returns a routing proxy under the same type. Every
|
|
213
|
+
importer of your client module receives it, so their queries land in whichever test
|
|
214
|
+
transaction is live. Called once per worker, from the `vi.mock` factory.
|
|
215
|
+
|
|
216
|
+
### `setupDatabase(): void`
|
|
217
|
+
|
|
218
|
+
Opts a test file into the database. Wraps every test in its own transaction: a
|
|
219
|
+
`beforeEach` opens it and runs the reset hooks, an `afterEach` rolls it back.
|
|
220
|
+
|
|
221
|
+
### `isDatabaseSetUp(): boolean`
|
|
222
|
+
|
|
223
|
+
Whether `setupDatabase()` has run in this file. Factories read it to fail loudly when a
|
|
224
|
+
file forgot to opt in, rather than run outside any transaction — where their writes would
|
|
225
|
+
commit.
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
if (!isDatabaseSetUp()) {
|
|
229
|
+
throw new Error("call setupDatabase() at the top of this test file");
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### `registerResetHook(hook): void`
|
|
234
|
+
|
|
235
|
+
Register a callback to run in every `beforeEach`, after the transaction opens. This is the
|
|
236
|
+
seam factories plug into. Hooks receive `{ testName, seed }`, where `seed` is a
|
|
237
|
+
deterministic 32-bit hash (FNV-1a) of the test name alone — never the worker id, the file,
|
|
238
|
+
or the run order. Rerunning one test with `-t` therefore hands it the same seed a
|
|
239
|
+
full-suite run did.
|
|
240
|
+
|
|
241
|
+
Faker is deliberately **not** a dependency of this package; the harness only hands you the
|
|
242
|
+
seed.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// tests/factories.ts
|
|
246
|
+
import { faker } from "@faker-js/faker";
|
|
247
|
+
|
|
248
|
+
import { registerResetHook } from "@flefebvre/prisma-test-helper";
|
|
249
|
+
import { db } from "../src/db/client.js";
|
|
250
|
+
|
|
251
|
+
registerResetHook(({ seed }) => {
|
|
252
|
+
faker.seed(seed);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export function buildAuthor() {
|
|
256
|
+
return db.author.create({ data: { name: faker.person.fullName() } });
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Hooks are held in a `Set`, so registering the same function twice runs it once. They run
|
|
261
|
+
sequentially, in registration order, and are awaited.
|
|
262
|
+
|
|
263
|
+
## Rules and caveats
|
|
264
|
+
|
|
265
|
+
**Per-test cleanup belongs in `afterEach`, registered after `setupDatabase()`.** Vitest
|
|
266
|
+
runs `afterEach` hooks in reverse registration order, so one your file registers _after_
|
|
267
|
+
that call still runs while the transaction is live. Cleanup registered with
|
|
268
|
+
`onTestFinished` does **not**: the runner fires those after every `afterEach`, so they land
|
|
269
|
+
past the rollback, where the database is out of reach.
|
|
270
|
+
|
|
271
|
+
**Call `setupDatabase()` once per file.** A second call in the same file fails with
|
|
272
|
+
`a test transaction is already live`, which does not point at the duplicate call.
|
|
273
|
+
|
|
274
|
+
**Build test data inside tests, not in `beforeAll`.** `beforeAll` runs before any
|
|
275
|
+
transaction is open, so writes there would commit to the Worker Database and leak into
|
|
276
|
+
every later test. The harness throws rather than let that happen.
|
|
277
|
+
|
|
278
|
+
## When something is mis-wired
|
|
279
|
+
|
|
280
|
+
Every error below comes from this package. Find the one you got.
|
|
281
|
+
|
|
282
|
+
| Error | What is mis-wired |
|
|
283
|
+
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
284
|
+
| `The test suite could not reach a container runtime.` | Docker is not running. Start it and run the tests again. |
|
|
285
|
+
| ``Could not resolve the `prisma` CLI from <cwd>.`` | The `prisma` peer dependency is not installed. The harness migrates with your project's own Prisma. |
|
|
286
|
+
| `` `prisma migrate deploy` failed: `` | Your migrations failed to apply; Prisma's own output follows. If it reads `The datasource.url property is required in your Prisma config file`, you are missing the `prisma.config.ts` from step 1. |
|
|
287
|
+
| `cloning the Worker Databases from <name> failed:` | `CREATE DATABASE … TEMPLATE` failed — usually another connection to the template, or a Postgres image that does not support the clone. |
|
|
288
|
+
| `VITEST_POOL_ID is not set —` | `setupWorkerDatabase()` ran outside a Vitest worker. It must run from a file listed in `setupFiles`, with `pool: "forks"` set. |
|
|
289
|
+
| `No Template Database was provided to this run —` | The global setup did not run. Register your global-setup file as `globalSetup: ["tests/global-setup.ts"]`. |
|
|
290
|
+
| `refusing to open a test transaction on <name> — only Worker Databases` | Your client is pointed at something that is not a Worker Database — **check `<name>` in the message first**. If it is your dev database, the setup file's ordering broke: a static app import ran before `setupWorkerDatabase()` (see step 4). If it is the template, or a near-miss name, the `databaseName` passed to `installTestTransaction` does not match the one `createGlobalSetup` used — pass the value `setupWorkerDatabase()` returned. This guard is the last line stopping a suite from running against a real database. |
|
|
291
|
+
| `no test transaction is installed —` | Your client module was imported without going through the setup file's `vi.mock`. Check the mock path matches the import path your app uses. |
|
|
292
|
+
| `a test transaction is already live —` | `setupDatabase()` was called twice in one file. |
|
|
293
|
+
| `the database was touched with no test transaction live —` | The file never called `setupDatabase()`, or data was built in `beforeAll` instead of inside a test. |
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** What every Reset Hook receives when it runs. */
|
|
2
|
+
export interface ResetHookContext {
|
|
3
|
+
/** Vitest's name for the running test, describe blocks included. */
|
|
4
|
+
testName: string;
|
|
5
|
+
/**
|
|
6
|
+
* A deterministic 32-bit seed derived from `testName` alone — never from the worker
|
|
7
|
+
* id, the file, or the run order. Rerunning one test with `-t` therefore hands it the
|
|
8
|
+
* same seed the full-suite run did, so faker produces the same data both times.
|
|
9
|
+
*/
|
|
10
|
+
seed: number;
|
|
11
|
+
}
|
|
12
|
+
/** A callback registered with {@link registerResetHook}. May be async; it is awaited. */
|
|
13
|
+
export type ResetHook = (context: ResetHookContext) => void | Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether {@link setupDatabase} has run in this test file.
|
|
16
|
+
*
|
|
17
|
+
* Factories read it to fail loudly when a file forgot to opt in, rather than run against
|
|
18
|
+
* an unseeded generator or — worse — outside any Test Transaction, where their writes
|
|
19
|
+
* would commit.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* if (!isDatabaseSetUp()) {
|
|
23
|
+
* throw new Error("call setupDatabase() at the top of this test file");
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
export declare function isDatabaseSetUp(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Register a callback to run in every `beforeEach`, after the Test Transaction opens.
|
|
29
|
+
*
|
|
30
|
+
* This is the seam factories plug into: reset a per-test sequence counter, seed faker
|
|
31
|
+
* from `seed`, or anything else that must be fresh per test. Hooks are held in a Set, so
|
|
32
|
+
* registering the same function twice runs it once. The library itself never depends on
|
|
33
|
+
* faker — it only hands you the seed.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* registerResetHook(({ seed }) => {
|
|
37
|
+
* faker.seed(seed);
|
|
38
|
+
* nextId = 0;
|
|
39
|
+
* });
|
|
40
|
+
*/
|
|
41
|
+
export declare function registerResetHook(hook: ResetHook): void;
|
|
42
|
+
/**
|
|
43
|
+
* Opt a test file into the database. Call it once, at the top of the file.
|
|
44
|
+
*
|
|
45
|
+
* Wraps every test in its own Test Transaction: a `beforeEach` opens it and runs the
|
|
46
|
+
* Reset Hooks; an `afterEach` rolls it back, discarding every write — the Worker
|
|
47
|
+
* Database itself never changes, so each test starts from the same pristine clone.
|
|
48
|
+
*
|
|
49
|
+
* Vitest runs `afterEach` hooks in reverse registration order (`sequence.hooks:
|
|
50
|
+
* "stack"`, the default), so an `afterEach` your file registers *after* this call — or
|
|
51
|
+
* one inside a nested `describe` — still runs while the transaction is live. Cleanup
|
|
52
|
+
* registered with `onTestFinished` does *not*: the runner fires those after every
|
|
53
|
+
* `afterEach`, so they land past the rollback, where the database is out of reach.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* import { setupDatabase } from "@flefebvre/prisma-test-helper";
|
|
57
|
+
* import { db } from "../src/db/client.js";
|
|
58
|
+
*
|
|
59
|
+
* setupDatabase();
|
|
60
|
+
*
|
|
61
|
+
* test("persists an author", async () => {
|
|
62
|
+
* await db.author.create({ data: { name: "Ada" } });
|
|
63
|
+
* expect(await db.author.count()).toBe(1);
|
|
64
|
+
* });
|
|
65
|
+
*/
|
|
66
|
+
export declare function setupDatabase(): void;
|
|
67
|
+
/**
|
|
68
|
+
* FNV-1a, 32-bit — a small, fast, well-mixed string hash. Exported for its own unit
|
|
69
|
+
* test; consumers receive its result as `seed` rather than calling it.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* hashName("persists an author"); // → a stable uint32
|
|
73
|
+
*/
|
|
74
|
+
export declare function hashName(name: string): number;
|
package/dist/database.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect } from "vitest";
|
|
2
|
+
import { testTransaction } from "./transaction.js";
|
|
3
|
+
// Vitest isolates test files (`isolate: true`), so this module is re-instantiated per
|
|
4
|
+
// file: the flag below resets for each, and hooks registered by one file's imports never
|
|
5
|
+
// leak into another's.
|
|
6
|
+
let databaseSetUp = false;
|
|
7
|
+
/**
|
|
8
|
+
* Whether {@link setupDatabase} has run in this test file.
|
|
9
|
+
*
|
|
10
|
+
* Factories read it to fail loudly when a file forgot to opt in, rather than run against
|
|
11
|
+
* an unseeded generator or — worse — outside any Test Transaction, where their writes
|
|
12
|
+
* would commit.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* if (!isDatabaseSetUp()) {
|
|
16
|
+
* throw new Error("call setupDatabase() at the top of this test file");
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
export function isDatabaseSetUp() {
|
|
20
|
+
return databaseSetUp;
|
|
21
|
+
}
|
|
22
|
+
const resetHooks = new Set();
|
|
23
|
+
/**
|
|
24
|
+
* Register a callback to run in every `beforeEach`, after the Test Transaction opens.
|
|
25
|
+
*
|
|
26
|
+
* This is the seam factories plug into: reset a per-test sequence counter, seed faker
|
|
27
|
+
* from `seed`, or anything else that must be fresh per test. Hooks are held in a Set, so
|
|
28
|
+
* registering the same function twice runs it once. The library itself never depends on
|
|
29
|
+
* faker — it only hands you the seed.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* registerResetHook(({ seed }) => {
|
|
33
|
+
* faker.seed(seed);
|
|
34
|
+
* nextId = 0;
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
export function registerResetHook(hook) {
|
|
38
|
+
resetHooks.add(hook);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Opt a test file into the database. Call it once, at the top of the file.
|
|
42
|
+
*
|
|
43
|
+
* Wraps every test in its own Test Transaction: a `beforeEach` opens it and runs the
|
|
44
|
+
* Reset Hooks; an `afterEach` rolls it back, discarding every write — the Worker
|
|
45
|
+
* Database itself never changes, so each test starts from the same pristine clone.
|
|
46
|
+
*
|
|
47
|
+
* Vitest runs `afterEach` hooks in reverse registration order (`sequence.hooks:
|
|
48
|
+
* "stack"`, the default), so an `afterEach` your file registers *after* this call — or
|
|
49
|
+
* one inside a nested `describe` — still runs while the transaction is live. Cleanup
|
|
50
|
+
* registered with `onTestFinished` does *not*: the runner fires those after every
|
|
51
|
+
* `afterEach`, so they land past the rollback, where the database is out of reach.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* import { setupDatabase } from "@flefebvre/prisma-test-helper";
|
|
55
|
+
* import { db } from "../src/db/client.js";
|
|
56
|
+
*
|
|
57
|
+
* setupDatabase();
|
|
58
|
+
*
|
|
59
|
+
* test("persists an author", async () => {
|
|
60
|
+
* await db.author.create({ data: { name: "Ada" } });
|
|
61
|
+
* expect(await db.author.count()).toBe(1);
|
|
62
|
+
* });
|
|
63
|
+
*/
|
|
64
|
+
export function setupDatabase() {
|
|
65
|
+
databaseSetUp = true;
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
await testTransaction().start();
|
|
68
|
+
const testName = expect.getState().currentTestName ?? "";
|
|
69
|
+
const seed = hashName(testName);
|
|
70
|
+
// Sequential, not Promise.all: hooks reset shared per-test state, and the order they
|
|
71
|
+
// were registered in is the order a consumer can reason about.
|
|
72
|
+
for (const hook of resetHooks)
|
|
73
|
+
await hook({ testName, seed });
|
|
74
|
+
});
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
await testTransaction().rollback();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* FNV-1a, 32-bit — a small, fast, well-mixed string hash. Exported for its own unit
|
|
81
|
+
* test; consumers receive its result as `seed` rather than calling it.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* hashName("persists an author"); // → a stable uint32
|
|
85
|
+
*/
|
|
86
|
+
export function hashName(name) {
|
|
87
|
+
let hash = 2166136261;
|
|
88
|
+
for (let index = 0; index < name.length; index++) {
|
|
89
|
+
hash ^= name.charCodeAt(index);
|
|
90
|
+
hash = Math.imul(hash, 16777619);
|
|
91
|
+
}
|
|
92
|
+
return hash >>> 0;
|
|
93
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TestProject } from "vitest/node";
|
|
2
|
+
declare module "vitest" {
|
|
3
|
+
interface ProvidedContext {
|
|
4
|
+
/** Connection URI of this run's container, pointing at the Template Database. */
|
|
5
|
+
templateDatabaseUri: string;
|
|
6
|
+
/**
|
|
7
|
+
* Name of the Template Database the Worker Databases were cloned from. Workers
|
|
8
|
+
* rewrite the URI path from it (`<databaseName>_<pool id>`) and the Test Transaction
|
|
9
|
+
* guard derives the shape it admits from it.
|
|
10
|
+
*/
|
|
11
|
+
templateDatabaseName: string;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Options for {@link createGlobalSetup}. */
|
|
15
|
+
export interface CreateGlobalSetupOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Postgres image to run. Pin it to the same major as your production database, so a
|
|
18
|
+
* test never runs against a different Postgres than the one you deploy on.
|
|
19
|
+
* @default "postgres:17-alpine"
|
|
20
|
+
*/
|
|
21
|
+
image?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Name of the Template Database: migrated once per run, then cloned once per Vitest
|
|
24
|
+
* worker slot into the Worker Databases `<databaseName>_1..N`. It never hosts a test.
|
|
25
|
+
* @default "prisma_test"
|
|
26
|
+
*/
|
|
27
|
+
databaseName?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a Vitest `globalSetup` function: `export default createGlobalSetup()` from the
|
|
31
|
+
* file your `globalSetup` config points at.
|
|
32
|
+
*
|
|
33
|
+
* It runs once per test run, in the main process, before any worker is forked. It
|
|
34
|
+
* starts one throwaway Postgres on a random host port, migrates the Template Database
|
|
35
|
+
* with the consumer project's own `prisma migrate deploy`, and clones one Worker
|
|
36
|
+
* Database per worker slot: `<databaseName>_1..maxWorkers`. `VITEST_POOL_ID` is always
|
|
37
|
+
* within that range — the pool leases ids from a fixed `1..maxWorkers` map — so every
|
|
38
|
+
* fork finds its database already provisioned.
|
|
39
|
+
*
|
|
40
|
+
* The clones run here, in one psql invocation, while nothing else is connected to
|
|
41
|
+
* the template. `CREATE DATABASE … TEMPLATE` fails when the template has other
|
|
42
|
+
* connections (SQLSTATE 55006), which is why cloning from inside workers would
|
|
43
|
+
* need a lock; cloning before workers exist needs none.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createGlobalSetup(options?: CreateGlobalSetupOptions): (project: TestProject) => Promise<() => Promise<void>>;
|
|
46
|
+
/**
|
|
47
|
+
* How many Worker Databases to provision.
|
|
48
|
+
*
|
|
49
|
+
* `maxWorkers` is resolved lazily inside the pool, so at globalSetup time the config
|
|
50
|
+
* carries it only when set explicitly (config file or CLI). Without one, Vitest
|
|
51
|
+
* derives its worker count from `availableParallelism()` — minus one in run mode,
|
|
52
|
+
* halved in watch mode — so the undiminished value is an upper bound on every pool
|
|
53
|
+
* id in both modes, at the cost of spare clones: one in run mode, up to half the
|
|
54
|
+
* cores in watch mode. Vitest resolves `maxWorkers` with a truthy check — `0` means
|
|
55
|
+
* "unset", not "zero workers" — so this falls through on `0` the same way, or a
|
|
56
|
+
* `--maxWorkers=0` run would find no database provisioned. Exported for that test.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* workerCount(project); // 4 when maxWorkers is 4; availableParallelism() when unset or 0
|
|
60
|
+
*/
|
|
61
|
+
export declare function workerCount(project: TestProject): number;
|
|
62
|
+
/**
|
|
63
|
+
* The psql argv that clones every Worker Database (`<databaseName>_1..workerCount`)
|
|
64
|
+
* from the template in a single invocation. Each clone is its own `--command` flag:
|
|
65
|
+
* psql runs a multi-statement `--command` in an implicit transaction, and
|
|
66
|
+
* `CREATE DATABASE` refuses to run inside one. `ON_ERROR_STOP` makes the first
|
|
67
|
+
* failed clone abort the invocation with a non-zero exit, since psql otherwise
|
|
68
|
+
* continues past errors and exits 0. Exported so the argv can be unit-tested
|
|
69
|
+
* without a container.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* cloneCommand("prisma_test", "postgres", 2)
|
|
73
|
+
* // → ["psql", "--username", "postgres", "--dbname", "postgres",
|
|
74
|
+
* // "--set", "ON_ERROR_STOP=1",
|
|
75
|
+
* // "--command", 'CREATE DATABASE "prisma_test_1" TEMPLATE "prisma_test"',
|
|
76
|
+
* // "--command", 'CREATE DATABASE "prisma_test_2" TEMPLATE "prisma_test"']
|
|
77
|
+
*/
|
|
78
|
+
export declare function cloneCommand(databaseName: string, username: string, workerCount: number): string[];
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { PostgreSqlContainer } from "@testcontainers/postgresql";
|
|
6
|
+
import { getContainerRuntimeClient } from "testcontainers";
|
|
7
|
+
// initdb refuses a data directory it does not own, and a tmpfs mount is root-owned.
|
|
8
|
+
// PGDATA must therefore be a subdirectory the container creates itself.
|
|
9
|
+
const PGDATA_MOUNT = "/var/lib/postgresql/data";
|
|
10
|
+
const PGDATA = `${PGDATA_MOUNT}/pgdata`;
|
|
11
|
+
/**
|
|
12
|
+
* Build a Vitest `globalSetup` function: `export default createGlobalSetup()` from the
|
|
13
|
+
* file your `globalSetup` config points at.
|
|
14
|
+
*
|
|
15
|
+
* It runs once per test run, in the main process, before any worker is forked. It
|
|
16
|
+
* starts one throwaway Postgres on a random host port, migrates the Template Database
|
|
17
|
+
* with the consumer project's own `prisma migrate deploy`, and clones one Worker
|
|
18
|
+
* Database per worker slot: `<databaseName>_1..maxWorkers`. `VITEST_POOL_ID` is always
|
|
19
|
+
* within that range — the pool leases ids from a fixed `1..maxWorkers` map — so every
|
|
20
|
+
* fork finds its database already provisioned.
|
|
21
|
+
*
|
|
22
|
+
* The clones run here, in one psql invocation, while nothing else is connected to
|
|
23
|
+
* the template. `CREATE DATABASE … TEMPLATE` fails when the template has other
|
|
24
|
+
* connections (SQLSTATE 55006), which is why cloning from inside workers would
|
|
25
|
+
* need a lock; cloning before workers exist needs none.
|
|
26
|
+
*/
|
|
27
|
+
export function createGlobalSetup(options = {}) {
|
|
28
|
+
const { image = "postgres:17-alpine", databaseName = "prisma_test" } = options;
|
|
29
|
+
return async function setup(project) {
|
|
30
|
+
await assertDockerReachable();
|
|
31
|
+
const container = await new PostgreSqlContainer(image)
|
|
32
|
+
.withDatabase(databaseName)
|
|
33
|
+
.withEnvironment({ PGDATA })
|
|
34
|
+
.withTmpFs({ [PGDATA_MOUNT]: "rw,noexec,nosuid" })
|
|
35
|
+
.withCommand([
|
|
36
|
+
"postgres",
|
|
37
|
+
"-c",
|
|
38
|
+
"fsync=off",
|
|
39
|
+
"-c",
|
|
40
|
+
"synchronous_commit=off",
|
|
41
|
+
"-c",
|
|
42
|
+
"full_page_writes=off",
|
|
43
|
+
])
|
|
44
|
+
.start();
|
|
45
|
+
try {
|
|
46
|
+
migrate(container.getConnectionUri());
|
|
47
|
+
await cloneWorkerDatabases(container, databaseName, workerCount(project));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// A failed stop (e.g. the daemon died, failing migrate and stop alike) must not
|
|
51
|
+
// mask the diagnostic error being thrown; Ryuk reaps the container regardless.
|
|
52
|
+
await container.stop().catch(() => { });
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
project.provide("templateDatabaseUri", container.getConnectionUri());
|
|
56
|
+
project.provide("templateDatabaseName", databaseName);
|
|
57
|
+
// Ryuk is the backstop: it removes the whole session even when Vitest is killed
|
|
58
|
+
// and this teardown never runs.
|
|
59
|
+
return async function teardown() {
|
|
60
|
+
await container.stop();
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* How many Worker Databases to provision.
|
|
66
|
+
*
|
|
67
|
+
* `maxWorkers` is resolved lazily inside the pool, so at globalSetup time the config
|
|
68
|
+
* carries it only when set explicitly (config file or CLI). Without one, Vitest
|
|
69
|
+
* derives its worker count from `availableParallelism()` — minus one in run mode,
|
|
70
|
+
* halved in watch mode — so the undiminished value is an upper bound on every pool
|
|
71
|
+
* id in both modes, at the cost of spare clones: one in run mode, up to half the
|
|
72
|
+
* cores in watch mode. Vitest resolves `maxWorkers` with a truthy check — `0` means
|
|
73
|
+
* "unset", not "zero workers" — so this falls through on `0` the same way, or a
|
|
74
|
+
* `--maxWorkers=0` run would find no database provisioned. Exported for that test.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* workerCount(project); // 4 when maxWorkers is 4; availableParallelism() when unset or 0
|
|
78
|
+
*/
|
|
79
|
+
export function workerCount(project) {
|
|
80
|
+
return project.config.maxWorkers || project.vitest.config.maxWorkers || os.availableParallelism();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Fail with an actionable message when no container runtime answers.
|
|
84
|
+
*
|
|
85
|
+
* Testcontainers tries each connection strategy in turn and reports only that they all
|
|
86
|
+
* failed, naming sockets rather than the missing dependency. Probing here separates
|
|
87
|
+
* "Docker is not running" — the one failure a reader is expected to hit and fix — from
|
|
88
|
+
* every other startup failure, which keeps its own error rather than being relabelled.
|
|
89
|
+
*/
|
|
90
|
+
async function assertDockerReachable() {
|
|
91
|
+
try {
|
|
92
|
+
await getContainerRuntimeClient();
|
|
93
|
+
}
|
|
94
|
+
catch (cause) {
|
|
95
|
+
throw new Error("The test suite could not reach a container runtime. " +
|
|
96
|
+
"@flefebvre/prisma-test-helper starts a Postgres container for the run, so " +
|
|
97
|
+
"Docker must be running — start Docker and run the tests again.", { cause });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Apply all migrations to this run's Template Database.
|
|
102
|
+
*
|
|
103
|
+
* Runs the consumer project's own `prisma` CLI — resolved from the working directory
|
|
104
|
+
* and executed with the current Node, so no package manager is involved — relying on
|
|
105
|
+
* Prisma's own schema discovery (`prisma.config.ts`, `./prisma/schema.prisma`, …).
|
|
106
|
+
* `DATABASE_URL` is pointed at the container for the child process only. Output is
|
|
107
|
+
* captured rather than inherited: a clean run should print nothing.
|
|
108
|
+
*/
|
|
109
|
+
function migrate(databaseUrl) {
|
|
110
|
+
const prismaBin = resolvePrismaBin();
|
|
111
|
+
try {
|
|
112
|
+
execFileSync(process.execPath, [prismaBin, "migrate", "deploy"], {
|
|
113
|
+
stdio: "pipe",
|
|
114
|
+
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (cause) {
|
|
118
|
+
const output = cause && typeof cause === "object" && "stdout" in cause
|
|
119
|
+
? String(cause.stdout ?? "") +
|
|
120
|
+
String(cause.stderr ?? "")
|
|
121
|
+
: "";
|
|
122
|
+
throw new Error(`\`prisma migrate deploy\` failed:\n${output}`, { cause });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the consumer's `prisma` CLI entry point (its published bin, exported as
|
|
127
|
+
* `prisma/build/index.js`), starting from the project that is running Vitest.
|
|
128
|
+
*/
|
|
129
|
+
function resolvePrismaBin() {
|
|
130
|
+
const require = createRequire(path.join(process.cwd(), "package.json"));
|
|
131
|
+
try {
|
|
132
|
+
return require.resolve("prisma/build/index.js");
|
|
133
|
+
}
|
|
134
|
+
catch (cause) {
|
|
135
|
+
throw new Error("Could not resolve the `prisma` CLI from " +
|
|
136
|
+
process.cwd() +
|
|
137
|
+
". @flefebvre/prisma-test-helper migrates the Template Database with your " +
|
|
138
|
+
"project's own Prisma — install the `prisma` package (it is a peer " +
|
|
139
|
+
"dependency) and run the tests again.", { cause });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The psql argv that clones every Worker Database (`<databaseName>_1..workerCount`)
|
|
144
|
+
* from the template in a single invocation. Each clone is its own `--command` flag:
|
|
145
|
+
* psql runs a multi-statement `--command` in an implicit transaction, and
|
|
146
|
+
* `CREATE DATABASE` refuses to run inside one. `ON_ERROR_STOP` makes the first
|
|
147
|
+
* failed clone abort the invocation with a non-zero exit, since psql otherwise
|
|
148
|
+
* continues past errors and exits 0. Exported so the argv can be unit-tested
|
|
149
|
+
* without a container.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* cloneCommand("prisma_test", "postgres", 2)
|
|
153
|
+
* // → ["psql", "--username", "postgres", "--dbname", "postgres",
|
|
154
|
+
* // "--set", "ON_ERROR_STOP=1",
|
|
155
|
+
* // "--command", 'CREATE DATABASE "prisma_test_1" TEMPLATE "prisma_test"',
|
|
156
|
+
* // "--command", 'CREATE DATABASE "prisma_test_2" TEMPLATE "prisma_test"']
|
|
157
|
+
*/
|
|
158
|
+
export function cloneCommand(databaseName, username, workerCount) {
|
|
159
|
+
const argv = ["psql", "--username", username, "--dbname", "postgres", "--set", "ON_ERROR_STOP=1"];
|
|
160
|
+
for (let workerId = 1; workerId <= workerCount; workerId++) {
|
|
161
|
+
argv.push("--command", `CREATE DATABASE "${databaseName}_${workerId}" TEMPLATE "${databaseName}"`);
|
|
162
|
+
}
|
|
163
|
+
return argv;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Clone the migrated template into the Worker Databases, via `psql` inside the
|
|
167
|
+
* container — over the local socket, which the official image trusts, so no
|
|
168
|
+
* client library or credentials are involved. One exec covers every clone: the
|
|
169
|
+
* exec round-trip costs more than the clone itself.
|
|
170
|
+
*/
|
|
171
|
+
async function cloneWorkerDatabases(postgres, databaseName, workerCount) {
|
|
172
|
+
const { exitCode, output } = await postgres.exec(cloneCommand(databaseName, postgres.getUsername(), workerCount));
|
|
173
|
+
if (exitCode !== 0) {
|
|
174
|
+
throw new Error(`cloning the Worker Databases from ${databaseName} failed:\n${output}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { installTestTransaction } from "./transaction.js";
|
|
2
|
+
export type { MinimalClient, MinimalTransactionClient } from "./transaction.js";
|
|
3
|
+
export { isDatabaseSetUp, registerResetHook, setupDatabase } from "./database.js";
|
|
4
|
+
export type { ResetHook, ResetHookContext } from "./database.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Main entry: what a consumer's test setup file and their test files import. The engine
|
|
2
|
+
// internals (`TestTransaction`, `testTransaction`, `assertWorkerDatabase`, `hashName`)
|
|
3
|
+
// stay off the public surface — the harness's own wiring drives them.
|
|
4
|
+
export { installTestTransaction } from "./transaction.js";
|
|
5
|
+
export { isDatabaseSetUp, registerResetHook, setupDatabase } from "./database.js";
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** What {@link setupWorkerDatabase} hands back: everything the Client Seam needs. */
|
|
2
|
+
export interface WorkerDatabase {
|
|
3
|
+
/** Connection URL of this worker's Worker Database — also set as `DATABASE_URL`. */
|
|
4
|
+
databaseUrl: string;
|
|
5
|
+
/** The Template Database name it was cloned from, for the Test Transaction guard. */
|
|
6
|
+
databaseName: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Point this worker at its own Worker Database.
|
|
10
|
+
*
|
|
11
|
+
* Call it at the top of your Vitest setup file, above the `vi.mock` that installs the
|
|
12
|
+
* Client Seam. It reads what `createGlobalSetup` provided, rewrites the URI onto this
|
|
13
|
+
* worker's clone (`<databaseName>_<VITEST_POOL_ID>`), sets `process.env.DATABASE_URL`,
|
|
14
|
+
* and returns both halves — the URL and the template name — because
|
|
15
|
+
* `installTestTransaction` needs both.
|
|
16
|
+
*
|
|
17
|
+
* **Ordering matters.** The assignment below must happen before any module that reads
|
|
18
|
+
* `DATABASE_URL` at import time is loaded, and ES imports hoist above statements — so
|
|
19
|
+
* the setup file must keep *zero* static imports of your app, and reach for them with a
|
|
20
|
+
* dynamic `import()` (or a lazy `vi.mock` factory) instead. A single static app import
|
|
21
|
+
* would parse the environment against your dev `DATABASE_URL` and point the whole suite
|
|
22
|
+
* at your dev database.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* // test/setup.ts
|
|
26
|
+
* const { databaseUrl, databaseName } = setupWorkerDatabase();
|
|
27
|
+
*
|
|
28
|
+
* vi.mock("../src/db/client", async (importOriginal) => {
|
|
29
|
+
* const actual = await importOriginal<typeof import("../src/db/client")>();
|
|
30
|
+
* return { db: installTestTransaction(actual.db, databaseUrl, databaseName) };
|
|
31
|
+
* });
|
|
32
|
+
*/
|
|
33
|
+
export declare function setupWorkerDatabase(): WorkerDatabase;
|
|
34
|
+
/**
|
|
35
|
+
* Swap the database in a connection URI for this worker's clone, keeping everything
|
|
36
|
+
* else — credentials, host, port, query parameters — untouched. Exported so the rewrite
|
|
37
|
+
* can be unit-tested without a container.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* workerDatabaseUrl("postgresql://u:p@host:5432/prisma_test", "prisma_test", "1");
|
|
41
|
+
* // → "postgresql://u:p@host:5432/prisma_test_1"
|
|
42
|
+
*/
|
|
43
|
+
export declare function workerDatabaseUrl(templateUri: string, databaseName: string, poolId: string): string;
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { inject } from "vitest";
|
|
2
|
+
/**
|
|
3
|
+
* Point this worker at its own Worker Database.
|
|
4
|
+
*
|
|
5
|
+
* Call it at the top of your Vitest setup file, above the `vi.mock` that installs the
|
|
6
|
+
* Client Seam. It reads what `createGlobalSetup` provided, rewrites the URI onto this
|
|
7
|
+
* worker's clone (`<databaseName>_<VITEST_POOL_ID>`), sets `process.env.DATABASE_URL`,
|
|
8
|
+
* and returns both halves — the URL and the template name — because
|
|
9
|
+
* `installTestTransaction` needs both.
|
|
10
|
+
*
|
|
11
|
+
* **Ordering matters.** The assignment below must happen before any module that reads
|
|
12
|
+
* `DATABASE_URL` at import time is loaded, and ES imports hoist above statements — so
|
|
13
|
+
* the setup file must keep *zero* static imports of your app, and reach for them with a
|
|
14
|
+
* dynamic `import()` (or a lazy `vi.mock` factory) instead. A single static app import
|
|
15
|
+
* would parse the environment against your dev `DATABASE_URL` and point the whole suite
|
|
16
|
+
* at your dev database.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* // test/setup.ts
|
|
20
|
+
* const { databaseUrl, databaseName } = setupWorkerDatabase();
|
|
21
|
+
*
|
|
22
|
+
* vi.mock("../src/db/client", async (importOriginal) => {
|
|
23
|
+
* const actual = await importOriginal<typeof import("../src/db/client")>();
|
|
24
|
+
* return { db: installTestTransaction(actual.db, databaseUrl, databaseName) };
|
|
25
|
+
* });
|
|
26
|
+
*/
|
|
27
|
+
export function setupWorkerDatabase() {
|
|
28
|
+
const poolId = process.env.VITEST_POOL_ID;
|
|
29
|
+
if (!poolId) {
|
|
30
|
+
throw new Error("VITEST_POOL_ID is not set — @flefebvre/prisma-test-helper could not tell which " +
|
|
31
|
+
"Worker Database belongs to this worker. `setupWorkerDatabase()` must run inside " +
|
|
32
|
+
'a Vitest worker, from a file listed in `setupFiles`, with `pool: "forks"` set ' +
|
|
33
|
+
"in your Vitest config.");
|
|
34
|
+
}
|
|
35
|
+
// Typed as `string`, but `inject` is a plain lookup in the context the main process
|
|
36
|
+
// provided: an unregistered globalSetup yields `undefined` at runtime, not an error.
|
|
37
|
+
const templateUri = inject("templateDatabaseUri");
|
|
38
|
+
const databaseName = inject("templateDatabaseName");
|
|
39
|
+
if (!templateUri || !databaseName) {
|
|
40
|
+
throw new Error("No Template Database was provided to this run — @flefebvre/prisma-test-helper's " +
|
|
41
|
+
"global setup did not run. Add a global-setup file exporting " +
|
|
42
|
+
"`createGlobalSetup()` and register it in your Vitest config as " +
|
|
43
|
+
'`globalSetup: ["test/global-setup.ts"]`.');
|
|
44
|
+
}
|
|
45
|
+
const databaseUrl = workerDatabaseUrl(templateUri, databaseName, poolId);
|
|
46
|
+
process.env.DATABASE_URL = databaseUrl;
|
|
47
|
+
return { databaseUrl, databaseName };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Swap the database in a connection URI for this worker's clone, keeping everything
|
|
51
|
+
* else — credentials, host, port, query parameters — untouched. Exported so the rewrite
|
|
52
|
+
* can be unit-tested without a container.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* workerDatabaseUrl("postgresql://u:p@host:5432/prisma_test", "prisma_test", "1");
|
|
56
|
+
* // → "postgresql://u:p@host:5432/prisma_test_1"
|
|
57
|
+
*/
|
|
58
|
+
export function workerDatabaseUrl(templateUri, databaseName, poolId) {
|
|
59
|
+
const url = new URL(templateUri);
|
|
60
|
+
url.pathname = `/${databaseName}_${poolId}`;
|
|
61
|
+
return url.toString();
|
|
62
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transaction-side surface the engine drives directly: savepoints are raw SQL. Any
|
|
3
|
+
* interactive-transaction client exposing `$executeRawUnsafe` qualifies — a generated
|
|
4
|
+
* `Prisma.TransactionClient` does.
|
|
5
|
+
*/
|
|
6
|
+
export interface MinimalTransactionClient {
|
|
7
|
+
$executeRawUnsafe(query: string, ...values: unknown[]): Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The client-side surface the engine drives directly: opening the one interactive
|
|
11
|
+
* transaction it parks per test. Declared with method syntax so the check is bivariant —
|
|
12
|
+
* a generated `PrismaClient`, whose callback receives its own richer transaction client,
|
|
13
|
+
* is assignable.
|
|
14
|
+
*/
|
|
15
|
+
export interface MinimalClient {
|
|
16
|
+
$transaction(fn: (tx: MinimalTransactionClient) => Promise<unknown>, options?: {
|
|
17
|
+
maxWait?: number;
|
|
18
|
+
timeout?: number;
|
|
19
|
+
}): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Guard the entry to every Test Transaction. A throwaway container is not enough: if the
|
|
23
|
+
* `DATABASE_URL` wiring in the consumer's setup file ever fails, a client still points at
|
|
24
|
+
* the dev database and every test would run its transaction there. Only Worker Databases
|
|
25
|
+
* — named `<databaseName>_<pool id>` after the Template Database they were cloned from —
|
|
26
|
+
* may host one: even a rolled-back transaction advances sequences and takes locks, so the
|
|
27
|
+
* template and the dev database are refused. The name is matched literally (no regex
|
|
28
|
+
* built from config). Exported so it can be unit-tested without a real connection to a
|
|
29
|
+
* wrongly-named database.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assertWorkerDatabase(url: string, databaseName: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* Build the worker's engine around the real client and hand back its Routing Proxy —
|
|
34
|
+
* what every importer of the consumer's client module receives instead of the raw
|
|
35
|
+
* client, under the same type. Called once per worker, from the `vi.mock` block in the
|
|
36
|
+
* consumer's test setup file.
|
|
37
|
+
*
|
|
38
|
+
* `databaseName` is the Template Database name the Worker Database was cloned from
|
|
39
|
+
* (the `databaseName` given to `createGlobalSetup`, `"prisma_test"` by default); the
|
|
40
|
+
* guard derives the accepted `<databaseName>_<pool id>` shape from it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function installTestTransaction<Client extends MinimalClient>(prisma: Client, databaseUrl: string, databaseName: string): Client;
|
|
43
|
+
/** The engine `installTestTransaction` built for this worker. */
|
|
44
|
+
export declare function testTransaction(): TestTransaction<MinimalClient>;
|
|
45
|
+
/**
|
|
46
|
+
* One test's database transaction. `start()` opens an interactive transaction and parks
|
|
47
|
+
* it; `client` is a stand-in for the app's Prisma client that routes every query into
|
|
48
|
+
* the parked transaction *at call time* — so a delegate captured at import (a factory's
|
|
49
|
+
* `db.user`) still lands inside whichever transaction is live when the query actually
|
|
50
|
+
* runs; `rollback()` ends the transaction, discarding every write.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* const engine = new TestTransaction(prisma, databaseUrl, "prisma_test");
|
|
54
|
+
* await engine.start();
|
|
55
|
+
* await engine.client.user.create({ data: … }); // inside the transaction
|
|
56
|
+
* await engine.rollback(); // gone
|
|
57
|
+
*/
|
|
58
|
+
export declare class TestTransaction<Client extends MinimalClient> {
|
|
59
|
+
private readonly prisma;
|
|
60
|
+
private readonly databaseUrl;
|
|
61
|
+
private readonly databaseName;
|
|
62
|
+
private tx;
|
|
63
|
+
private release;
|
|
64
|
+
private running;
|
|
65
|
+
private savepointSeq;
|
|
66
|
+
private mutex;
|
|
67
|
+
private readonly inSavepoint;
|
|
68
|
+
constructor(prisma: Client, databaseUrl: string, databaseName: string);
|
|
69
|
+
/** Open the transaction and park it until `rollback()`. Refuses a non-worker database. */
|
|
70
|
+
start(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* End the transaction, discarding its writes. A no-op when none is live. State resets
|
|
73
|
+
* before the transaction's own error (if any) surfaces, so a transaction that died
|
|
74
|
+
* mid-test fails that test alone instead of wedging the engine.
|
|
75
|
+
*/
|
|
76
|
+
rollback(): Promise<void>;
|
|
77
|
+
private reset;
|
|
78
|
+
private proxy;
|
|
79
|
+
/**
|
|
80
|
+
* The Routing Proxy: a drop-in for the app's Prisma client. Model delegates and
|
|
81
|
+
* raw-query methods route into the live transaction at call time; everything else
|
|
82
|
+
* passes through to the underlying client. One stable object — consumers may capture
|
|
83
|
+
* it, or any of its delegates, once and forever.
|
|
84
|
+
*/
|
|
85
|
+
get client(): Client;
|
|
86
|
+
private requireTx;
|
|
87
|
+
/**
|
|
88
|
+
* Run `op` inside its own savepoint on the live transaction: released on success,
|
|
89
|
+
* rolled back — restoring a healthy transaction — when `op` throws. Scopes queue on
|
|
90
|
+
* the mutex; a scope opened from *inside* another (nested `$transaction`) joins the
|
|
91
|
+
* outer scope's turn, since Postgres savepoints nest natively.
|
|
92
|
+
*/
|
|
93
|
+
private withSavepoint;
|
|
94
|
+
private savepointScope;
|
|
95
|
+
/**
|
|
96
|
+
* A stand-in for one model delegate (`db.user`). Method calls resolve the live
|
|
97
|
+
* transaction when invoked, which is what makes eagerly captured delegates safe.
|
|
98
|
+
*/
|
|
99
|
+
private routeDelegate;
|
|
100
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
/**
|
|
3
|
+
* Guard the entry to every Test Transaction. A throwaway container is not enough: if the
|
|
4
|
+
* `DATABASE_URL` wiring in the consumer's setup file ever fails, a client still points at
|
|
5
|
+
* the dev database and every test would run its transaction there. Only Worker Databases
|
|
6
|
+
* — named `<databaseName>_<pool id>` after the Template Database they were cloned from —
|
|
7
|
+
* may host one: even a rolled-back transaction advances sequences and takes locks, so the
|
|
8
|
+
* template and the dev database are refused. The name is matched literally (no regex
|
|
9
|
+
* built from config). Exported so it can be unit-tested without a real connection to a
|
|
10
|
+
* wrongly-named database.
|
|
11
|
+
*/
|
|
12
|
+
export function assertWorkerDatabase(url, databaseName) {
|
|
13
|
+
const name = new URL(url).pathname.slice(1);
|
|
14
|
+
const prefix = `${databaseName}_`;
|
|
15
|
+
const workerId = name.startsWith(prefix) ? name.slice(prefix.length) : "";
|
|
16
|
+
if (!/^\d+$/.test(workerId)) {
|
|
17
|
+
throw new Error(`refusing to open a test transaction on ${name} — only Worker Databases ` +
|
|
18
|
+
`(${prefix}<pool id>) may host one`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// The worker's one engine, installed by the consumer's module mock (the Client Seam)
|
|
22
|
+
// when the first import of their client module resolves, and driven per-test by the
|
|
23
|
+
// harness: `start()` before each test, `rollback()` after.
|
|
24
|
+
let current = null;
|
|
25
|
+
/**
|
|
26
|
+
* Build the worker's engine around the real client and hand back its Routing Proxy —
|
|
27
|
+
* what every importer of the consumer's client module receives instead of the raw
|
|
28
|
+
* client, under the same type. Called once per worker, from the `vi.mock` block in the
|
|
29
|
+
* consumer's test setup file.
|
|
30
|
+
*
|
|
31
|
+
* `databaseName` is the Template Database name the Worker Database was cloned from
|
|
32
|
+
* (the `databaseName` given to `createGlobalSetup`, `"prisma_test"` by default); the
|
|
33
|
+
* guard derives the accepted `<databaseName>_<pool id>` shape from it.
|
|
34
|
+
*/
|
|
35
|
+
export function installTestTransaction(prisma, databaseUrl, databaseName) {
|
|
36
|
+
const engine = new TestTransaction(prisma, databaseUrl, databaseName);
|
|
37
|
+
current = engine;
|
|
38
|
+
return engine.client;
|
|
39
|
+
}
|
|
40
|
+
/** The engine `installTestTransaction` built for this worker. */
|
|
41
|
+
export function testTransaction() {
|
|
42
|
+
if (!current) {
|
|
43
|
+
throw new Error("no test transaction is installed — the app's Prisma client module was not " +
|
|
44
|
+
"imported through the test setup's module mock");
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
48
|
+
// Thrown into the parked transaction callback to end it; `start()` recognises it as the
|
|
49
|
+
// one expected way the transaction ends, so real errors still surface.
|
|
50
|
+
class Rollback extends Error {
|
|
51
|
+
}
|
|
52
|
+
// Raw-query methods route into the live transaction like model delegates do; the
|
|
53
|
+
// remaining `$`-methods (`$connect`, `$disconnect`, `$on`, `$extends`, …) are
|
|
54
|
+
// connection-level and pass through to the underlying client.
|
|
55
|
+
const RAW_METHODS = new Set(["$queryRaw", "$queryRawUnsafe", "$executeRaw", "$executeRawUnsafe"]);
|
|
56
|
+
/**
|
|
57
|
+
* One test's database transaction. `start()` opens an interactive transaction and parks
|
|
58
|
+
* it; `client` is a stand-in for the app's Prisma client that routes every query into
|
|
59
|
+
* the parked transaction *at call time* — so a delegate captured at import (a factory's
|
|
60
|
+
* `db.user`) still lands inside whichever transaction is live when the query actually
|
|
61
|
+
* runs; `rollback()` ends the transaction, discarding every write.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* const engine = new TestTransaction(prisma, databaseUrl, "prisma_test");
|
|
65
|
+
* await engine.start();
|
|
66
|
+
* await engine.client.user.create({ data: … }); // inside the transaction
|
|
67
|
+
* await engine.rollback(); // gone
|
|
68
|
+
*/
|
|
69
|
+
export class TestTransaction {
|
|
70
|
+
prisma;
|
|
71
|
+
databaseUrl;
|
|
72
|
+
databaseName;
|
|
73
|
+
tx = null;
|
|
74
|
+
release = null;
|
|
75
|
+
running = null;
|
|
76
|
+
savepointSeq = 0;
|
|
77
|
+
// Serializes savepoint scopes: they share one connection and Postgres savepoints are
|
|
78
|
+
// stack-like, so two interleaved scopes would corrupt each other's rollback.
|
|
79
|
+
mutex = Promise.resolve();
|
|
80
|
+
// Marks "inside a savepoint scope" across awaits, so a $transaction nested within
|
|
81
|
+
// another joins the outer scope's turn instead of deadlocking on the mutex.
|
|
82
|
+
inSavepoint = new AsyncLocalStorage();
|
|
83
|
+
constructor(prisma, databaseUrl, databaseName) {
|
|
84
|
+
this.prisma = prisma;
|
|
85
|
+
this.databaseUrl = databaseUrl;
|
|
86
|
+
this.databaseName = databaseName;
|
|
87
|
+
}
|
|
88
|
+
/** Open the transaction and park it until `rollback()`. Refuses a non-worker database. */
|
|
89
|
+
async start() {
|
|
90
|
+
assertWorkerDatabase(this.databaseUrl, this.databaseName);
|
|
91
|
+
if (this.running) {
|
|
92
|
+
throw new Error("a test transaction is already live — `start()` must be paired with a `rollback()`");
|
|
93
|
+
}
|
|
94
|
+
let ready;
|
|
95
|
+
const isReady = new Promise((resolve) => (ready = resolve));
|
|
96
|
+
this.running = this.prisma
|
|
97
|
+
.$transaction(async (tx) => {
|
|
98
|
+
this.tx = tx;
|
|
99
|
+
ready();
|
|
100
|
+
// Park until `rollback()` rejects with `Rollback`. The timeout only bounds a
|
|
101
|
+
// transaction whose `rollback()` never came — a leaked one — so it is far
|
|
102
|
+
// above any test's runtime.
|
|
103
|
+
await new Promise((_, reject) => {
|
|
104
|
+
this.release = () => reject(new Rollback());
|
|
105
|
+
});
|
|
106
|
+
}, { maxWait: 5_000, timeout: 3_600_000 })
|
|
107
|
+
.catch((error) => {
|
|
108
|
+
if (!(error instanceof Rollback))
|
|
109
|
+
throw error;
|
|
110
|
+
});
|
|
111
|
+
// `running` settles first only when the transaction failed to open (e.g. the pool
|
|
112
|
+
// handed out a dead connection); racing it turns that failure into a loud `start()`
|
|
113
|
+
// rejection instead of a hang on `isReady`. The reset keeps the failure to this one
|
|
114
|
+
// test — leaving state behind would make every later `start()` claim a transaction
|
|
115
|
+
// is live when none is.
|
|
116
|
+
try {
|
|
117
|
+
await Promise.race([isReady, this.running]);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
this.reset();
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* End the transaction, discarding its writes. A no-op when none is live. State resets
|
|
126
|
+
* before the transaction's own error (if any) surfaces, so a transaction that died
|
|
127
|
+
* mid-test fails that test alone instead of wedging the engine.
|
|
128
|
+
*/
|
|
129
|
+
async rollback() {
|
|
130
|
+
const running = this.running;
|
|
131
|
+
this.release?.();
|
|
132
|
+
this.reset();
|
|
133
|
+
if (running)
|
|
134
|
+
await running;
|
|
135
|
+
}
|
|
136
|
+
reset() {
|
|
137
|
+
this.tx = null;
|
|
138
|
+
this.release = null;
|
|
139
|
+
this.running = null;
|
|
140
|
+
this.savepointSeq = 0;
|
|
141
|
+
this.mutex = Promise.resolve();
|
|
142
|
+
}
|
|
143
|
+
proxy = null;
|
|
144
|
+
/**
|
|
145
|
+
* The Routing Proxy: a drop-in for the app's Prisma client. Model delegates and
|
|
146
|
+
* raw-query methods route into the live transaction at call time; everything else
|
|
147
|
+
* passes through to the underlying client. One stable object — consumers may capture
|
|
148
|
+
* it, or any of its delegates, once and forever.
|
|
149
|
+
*/
|
|
150
|
+
get client() {
|
|
151
|
+
return (this.proxy ??= new Proxy(this.prisma, {
|
|
152
|
+
get: (target, prop) => {
|
|
153
|
+
if (prop === "$transaction") {
|
|
154
|
+
return (arg) => {
|
|
155
|
+
this.requireTx();
|
|
156
|
+
if (typeof arg === "function") {
|
|
157
|
+
return this.withSavepoint(() => arg(this.tx));
|
|
158
|
+
}
|
|
159
|
+
// The batch form: its operations were built through this client, so awaiting
|
|
160
|
+
// them runs them on the live transaction; the savepoint makes the batch
|
|
161
|
+
// atomic, and sequential awaiting keeps the order.
|
|
162
|
+
return this.withSavepoint(async () => {
|
|
163
|
+
const results = [];
|
|
164
|
+
for (const operation of arg) {
|
|
165
|
+
results.push(await operation);
|
|
166
|
+
}
|
|
167
|
+
return results;
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (typeof prop === "string" && RAW_METHODS.has(prop)) {
|
|
172
|
+
return (...args) => {
|
|
173
|
+
const tx = this.requireTx();
|
|
174
|
+
return tx[prop](...args);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (typeof prop === "string" && !prop.startsWith("$")) {
|
|
178
|
+
const value = Reflect.get(target, prop);
|
|
179
|
+
if (value !== null && typeof value === "object") {
|
|
180
|
+
return this.routeDelegate(prop);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const value = Reflect.get(target, prop);
|
|
184
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
requireTx() {
|
|
189
|
+
if (!this.tx) {
|
|
190
|
+
throw new Error("the database was touched with no test transaction live — call `setupDatabase()` " +
|
|
191
|
+
"at the top of the test file, and build test data inside tests, not in `beforeAll`");
|
|
192
|
+
}
|
|
193
|
+
return this.tx;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Run `op` inside its own savepoint on the live transaction: released on success,
|
|
197
|
+
* rolled back — restoring a healthy transaction — when `op` throws. Scopes queue on
|
|
198
|
+
* the mutex; a scope opened from *inside* another (nested `$transaction`) joins the
|
|
199
|
+
* outer scope's turn, since Postgres savepoints nest natively.
|
|
200
|
+
*/
|
|
201
|
+
async withSavepoint(op) {
|
|
202
|
+
if (this.inSavepoint.getStore())
|
|
203
|
+
return this.savepointScope(op);
|
|
204
|
+
const run = this.mutex.then(() => this.inSavepoint.run(true, () => this.savepointScope(op)));
|
|
205
|
+
this.mutex = run.then(() => undefined, () => undefined);
|
|
206
|
+
return run;
|
|
207
|
+
}
|
|
208
|
+
async savepointScope(op) {
|
|
209
|
+
const tx = this.requireTx();
|
|
210
|
+
const name = `test_savepoint_${++this.savepointSeq}`;
|
|
211
|
+
await tx.$executeRawUnsafe(`SAVEPOINT ${name}`);
|
|
212
|
+
try {
|
|
213
|
+
const result = await op();
|
|
214
|
+
await tx.$executeRawUnsafe(`RELEASE SAVEPOINT ${name}`);
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
await tx.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${name}`);
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A stand-in for one model delegate (`db.user`). Method calls resolve the live
|
|
224
|
+
* transaction when invoked, which is what makes eagerly captured delegates safe.
|
|
225
|
+
*/
|
|
226
|
+
routeDelegate(model) {
|
|
227
|
+
const source = this.prisma;
|
|
228
|
+
return new Proxy(source[model], {
|
|
229
|
+
get: (target, method) => {
|
|
230
|
+
const value = Reflect.get(target, method);
|
|
231
|
+
if (typeof value !== "function")
|
|
232
|
+
return value;
|
|
233
|
+
return (...args) => {
|
|
234
|
+
const delegate = this.requireTx();
|
|
235
|
+
return delegate[model][method](...args);
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flefebvre/prisma-test-helper",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Throwaway-Postgres test harness for Prisma + Vitest: one container per run, one database per worker. Postgres-only.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Florian Lefebvre <florian.lefebvre.contact@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/flolefebvre/prisma-test-helper#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/flolefebvre/prisma-test-helper.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/flolefebvre/prisma-test-helper/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"prisma",
|
|
18
|
+
"vitest",
|
|
19
|
+
"postgres",
|
|
20
|
+
"testcontainers",
|
|
21
|
+
"testing",
|
|
22
|
+
"test-database",
|
|
23
|
+
"isolation"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./global-setup": {
|
|
32
|
+
"types": "./dist/global-setup.d.ts",
|
|
33
|
+
"default": "./dist/global-setup.js"
|
|
34
|
+
},
|
|
35
|
+
"./setup": {
|
|
36
|
+
"types": "./dist/setup.d.ts",
|
|
37
|
+
"default": "./dist/setup.js"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "pnpm run clean && tsc -p tsconfig.build.json",
|
|
46
|
+
"clean": "rm -rf dist",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"generate": "prisma generate",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:unit": "vitest run --project unit",
|
|
51
|
+
"test:integration": "vitest run --project integration",
|
|
52
|
+
"lint": "eslint --max-warnings 0",
|
|
53
|
+
"duplicates": "jscpd",
|
|
54
|
+
"format": "prettier -w .",
|
|
55
|
+
"format:check": "prettier -c .",
|
|
56
|
+
"gate": "pnpm run typecheck && pnpm run lint && pnpm run duplicates && pnpm run test && pnpm run build",
|
|
57
|
+
"prepublishOnly": "pnpm run clean && pnpm run build"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@testcontainers/postgresql": "^12.0.4",
|
|
61
|
+
"testcontainers": "^12.0.4"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"prisma": "^7.0.0",
|
|
65
|
+
"vitest": "^4.0.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@prisma/adapter-pg": "^7.8.0",
|
|
69
|
+
"@prisma/client": "^7.8.0",
|
|
70
|
+
"@types/node": "^20.19.43",
|
|
71
|
+
"eslint": "^10.6.0",
|
|
72
|
+
"jscpd": "^5.0.12",
|
|
73
|
+
"prettier": "^3.9.4",
|
|
74
|
+
"prisma": "^7.8.0",
|
|
75
|
+
"typescript": "^5.9.3",
|
|
76
|
+
"typescript-eslint": "^8.62.0",
|
|
77
|
+
"vitest": "^4.1.9"
|
|
78
|
+
},
|
|
79
|
+
"engines": {
|
|
80
|
+
"node": ">=20"
|
|
81
|
+
},
|
|
82
|
+
"publishConfig": {
|
|
83
|
+
"access": "public"
|
|
84
|
+
}
|
|
85
|
+
}
|