vitest-prisma 0.0.1 → 1.0.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 +250 -2
- package/dist/index.d.mts +116 -0
- package/dist/index.mjs +270 -0
- package/dist/index.mjs.map +1 -0
- package/docs/errors.md +118 -0
- package/docs/migration-from-vitest-environment-vprisma.md +85 -0
- package/package.json +90 -4
- package/skills/vitest-prisma/SKILL.md +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Aiji Uejima
|
|
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
CHANGED
|
@@ -1,4 +1,252 @@
|
|
|
1
|
+
[](https://github.com/aiji42/vitest-prisma/actions/workflows/ci.yml)
|
|
2
|
+
[](https://www.npmjs.com/package/vitest-prisma)
|
|
3
|
+
|
|
1
4
|
# vitest-prisma
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
6
|
+
Run every Vitest test in a Prisma transaction and roll it back automatically. Tests stay isolated
|
|
7
|
+
without truncating tables or re-seeding between tests.
|
|
8
|
+
|
|
9
|
+
- **Zero cleanup:** `beforeEach`, the test and `afterEach` share one transaction, which is rolled back afterwards.
|
|
10
|
+
- **Works with real application code:** swap your shared `PrismaClient` for the test client with `vi.mock`. Code that calls `$transaction` still gets real nested-transaction semantics through savepoints.
|
|
11
|
+
- **Any Prisma setup:** Prisma 6+, the `prisma-client` or `prisma-client-js` generator, any output path, any driver adapter, `$extends`.
|
|
12
|
+
- **Clear failures:** every error has a stable code and a [documented fix](./docs/errors.md).
|
|
13
|
+
|
|
14
|
+
> Formerly `vitest-environment-vprisma`. See the [migration guide](./docs/migration-from-vitest-environment-vprisma.md).
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Vitest **>= 4.1** (uses `aroundEach`)
|
|
19
|
+
- Prisma **>= 6**
|
|
20
|
+
- Node.js **>= 22.12**
|
|
21
|
+
- PostgreSQL, MySQL/MariaDB, SQLite or CockroachDB. SQL Server and MongoDB work with
|
|
22
|
+
[`savepoints: false`](#options).
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -D vitest-prisma
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**1. Create a setup file** that tells vitest-prisma how to build your client:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// vitest.setup.ts
|
|
34
|
+
import { createVPrisma } from "vitest-prisma";
|
|
35
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
36
|
+
import { PrismaClient } from "./src/generated/prisma/client";
|
|
37
|
+
|
|
38
|
+
export const vPrisma = createVPrisma(
|
|
39
|
+
() => new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }),
|
|
40
|
+
);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**2. Register it** in your Vitest config:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// vitest.config.ts
|
|
47
|
+
import { defineConfig } from "vitest/config";
|
|
48
|
+
|
|
49
|
+
export default defineConfig({
|
|
50
|
+
test: {
|
|
51
|
+
setupFiles: ["./vitest.setup.ts"],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**3. Use `vPrisma.client` in tests.** Everything is rolled back after each test:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { expect, test } from "vitest";
|
|
60
|
+
import { vPrisma } from "../vitest.setup";
|
|
61
|
+
|
|
62
|
+
const prisma = vPrisma.client;
|
|
63
|
+
|
|
64
|
+
test("creates a user", async () => {
|
|
65
|
+
await prisma.user.create({ data: { email: "alice@example.com" } });
|
|
66
|
+
expect(await prisma.user.count()).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("starts from a clean database", async () => {
|
|
70
|
+
expect(await prisma.user.count()).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The test database must already have your schema applied, for example with
|
|
75
|
+
`prisma migrate deploy` or `prisma db push` in Vitest `globalSetup` or in CI.
|
|
76
|
+
|
|
77
|
+
A complete, runnable project (SQLite, no services needed) is in [`examples/sqlite`](./examples/sqlite).
|
|
78
|
+
|
|
79
|
+
## Mocking a shared client
|
|
80
|
+
|
|
81
|
+
Most applications import one shared `PrismaClient`. Point that module at the test client in your
|
|
82
|
+
setup file:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// src/client.ts: the factory (NOT mocked)
|
|
86
|
+
export const createClient = () => new PrismaClient({ adapter: /* ... */ });
|
|
87
|
+
|
|
88
|
+
// src/db.ts: the shared instance used by application code (mocked in tests)
|
|
89
|
+
import { createClient } from "./client";
|
|
90
|
+
export const db = createClient();
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// vitest.setup.ts
|
|
95
|
+
import { vi } from "vitest";
|
|
96
|
+
import { createVPrisma } from "vitest-prisma";
|
|
97
|
+
import { createClient } from "./src/client";
|
|
98
|
+
|
|
99
|
+
export const vPrisma = createVPrisma(createClient);
|
|
100
|
+
|
|
101
|
+
vi.mock("./src/db", () => ({ db: vPrisma.client }));
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> [!IMPORTANT]
|
|
105
|
+
> The setup file must not import the module it mocks, directly or indirectly. Otherwise
|
|
106
|
+
> `vi.mock`'s factory runs before `vPrisma` is initialized. That is why the factory and the
|
|
107
|
+
> shared instance live in separate files.
|
|
108
|
+
|
|
109
|
+
## How it works
|
|
110
|
+
|
|
111
|
+
1. `createVPrisma()` registers an `aroundEach` hook for the test file. Called from a setup file,
|
|
112
|
+
that means for every test file.
|
|
113
|
+
2. Before each test, it opens an interactive transaction (`$transaction(async (tx) => ...)`) on
|
|
114
|
+
the client from your factory. `vPrisma.client` forwards to that transaction's client.
|
|
115
|
+
3. `beforeEach`, the test and `afterEach` run inside the transaction.
|
|
116
|
+
4. When the test finishes (pass, fail or timeout), the transaction is rolled back.
|
|
117
|
+
|
|
118
|
+
Inside the transaction, `vPrisma.client` behaves like a normal client:
|
|
119
|
+
|
|
120
|
+
- `$transaction(fn)` and `$transaction([...])` run inside a `SAVEPOINT`, so a failure rolls back
|
|
121
|
+
only that nested transaction, as it would in production.
|
|
122
|
+
- Each standalone query runs inside its own `SAVEPOINT`, so a failing query (for example, a
|
|
123
|
+
unique-constraint violation you assert with `rejects`) does not abort the rest of the test.
|
|
124
|
+
Without this, PostgreSQL fails every later query with "current transaction is aborted".
|
|
125
|
+
- `$connect()` / `$disconnect()` are no-ops.
|
|
126
|
+
|
|
127
|
+
Savepoints go through Prisma's native nested transactions when the transaction client supports
|
|
128
|
+
them (recent Prisma 7+). Otherwise vitest-prisma issues `SAVEPOINT` statements itself.
|
|
129
|
+
|
|
130
|
+
It works with every Vitest pool (`forks`, `threads`) and with `isolate: false`.
|
|
131
|
+
|
|
132
|
+
The client is created lazily on first use in each test file and disconnected after the file finishes.
|
|
133
|
+
|
|
134
|
+
## API
|
|
135
|
+
|
|
136
|
+
### `createVPrisma(factory, options?)`
|
|
137
|
+
|
|
138
|
+
Call it from a `setupFiles` module and export the result.
|
|
139
|
+
|
|
140
|
+
| Parameter | Type | Description |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `factory` | `() => PrismaClient` | Builds the client. Called at most once per test file. Configure adapters, `log` and `$extends` here. |
|
|
143
|
+
| `options` | `VPrismaOptions` | Defaults for all test files (see below). |
|
|
144
|
+
|
|
145
|
+
Returns a `VPrisma` handle:
|
|
146
|
+
|
|
147
|
+
| Member | Description |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `client` | Client bound to the current test's transaction. Usable in `test`, `beforeEach`, `afterEach` and in code they call. Throws [`E_OUTSIDE_TEST`](./docs/errors.md#e_outside_test) elsewhere. It is a stable reference you can store or pass to `vi.mock`. |
|
|
150
|
+
| `originalClient` | The underlying client, outside any transaction. **Not rolled back.** For `beforeAll` / `afterAll`. |
|
|
151
|
+
| `configure(options)` | Override `rollback` / `savepoints` for the current test file. Call it once at the top level of the file; calling it inside a test throws [`E_CONFIGURE_IN_TEST`](./docs/errors.md#e_configure_in_test). |
|
|
152
|
+
|
|
153
|
+
### Options
|
|
154
|
+
|
|
155
|
+
| Option | Default | Description |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `rollback` | `true` | `false` commits each test's transaction instead. Useful for inspecting data while debugging. |
|
|
158
|
+
| `savepoints` | `true` | Use `SAVEPOINT`s for nested `$transaction` calls and standalone queries. Set `false` for SQL Server / MongoDB, or to [trade safety for speed](#speed-vs-savepoints). |
|
|
159
|
+
| `transactionOptions.timeout` | `86_400_000` | Max lifetime (ms) of a test transaction. Prisma's 5s default is too short for tests, so in practice Vitest's `testTimeout` is the limit. |
|
|
160
|
+
| `transactionOptions.maxWait` | `5_000` | Max time (ms) to wait for a connection. When set, the `aroundEach` hook timeout becomes `maxWait + 10s`, so it can exceed Vitest's `hookTimeout`. |
|
|
161
|
+
| `transactionOptions.isolationLevel` | DB default | Isolation level of the test transaction. |
|
|
162
|
+
|
|
163
|
+
## Recipes
|
|
164
|
+
|
|
165
|
+
### Reference data for a whole file
|
|
166
|
+
|
|
167
|
+
`beforeAll` runs outside any test transaction. Use `originalClient` there and clean up yourself:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
beforeAll(async () => {
|
|
171
|
+
await vPrisma.originalClient.plan.create({ data: { id: "pro", name: "Pro" } });
|
|
172
|
+
});
|
|
173
|
+
afterAll(async () => {
|
|
174
|
+
await vPrisma.originalClient.plan.delete({ where: { id: "pro" } });
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
For per-test data, use `beforeEach` with `vPrisma.client`. It is rolled back automatically.
|
|
179
|
+
|
|
180
|
+
### See the data a test wrote
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
vPrisma.configure({ rollback: false }); // this file only
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Speed vs. savepoints
|
|
187
|
+
|
|
188
|
+
With `savepoints: true` (the default), each standalone query costs three round trips
|
|
189
|
+
(`SAVEPOINT`, the query, `RELEASE`). On a local PostgreSQL, 1,000 queries took about 550 ms with
|
|
190
|
+
savepoints versus 300 ms without. Over a network the gap grows with latency.
|
|
191
|
+
|
|
192
|
+
`savepoints: false` gives v1-like speed. The cost is that on PostgreSQL a failing query (for
|
|
193
|
+
example, a unique-constraint violation you assert with `rejects`) aborts the rest of that test,
|
|
194
|
+
and a failing nested `$transaction` no longer rolls back on its own. You can turn savepoints off
|
|
195
|
+
only in DB-heavy files that never expect query errors:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
vPrisma.configure({ savepoints: false });
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Log SQL
|
|
202
|
+
|
|
203
|
+
Configure logging in the factory: `new PrismaClient({ adapter, log: ["query"] })`.
|
|
204
|
+
|
|
205
|
+
### Only some tests use the database
|
|
206
|
+
|
|
207
|
+
Every test in a file covered by the setup file opens a transaction. To keep pure unit tests free
|
|
208
|
+
of database access, put them in a separate [Vitest project](https://vitest.dev/guide/projects)
|
|
209
|
+
that does not include the setup file.
|
|
210
|
+
|
|
211
|
+
### Parallel test files and unique values
|
|
212
|
+
|
|
213
|
+
Test files run in parallel, each in its own transaction. If two files insert the same unique
|
|
214
|
+
value (for example the same email), the second insert waits for the first transaction's lock, and
|
|
215
|
+
MySQL may abort one with a deadlock error. Use values unique to each file or test (for example,
|
|
216
|
+
include a random suffix), or run files sequentially with `fileParallelism: false`.
|
|
217
|
+
|
|
218
|
+
### SQLite
|
|
219
|
+
|
|
220
|
+
SQLite allows one writer at a time. Set `fileParallelism: false` or give each worker its own
|
|
221
|
+
database file.
|
|
222
|
+
|
|
223
|
+
## Limitations
|
|
224
|
+
|
|
225
|
+
- Tests in the same file cannot run concurrently ([`E_CONCURRENT_TEST`](./docs/errors.md#e_concurrent_test)).
|
|
226
|
+
Separate files run in parallel.
|
|
227
|
+
- `$extends`, `$on` and `$use` cannot be called on `vPrisma.client`. Apply them in the factory.
|
|
228
|
+
- Prisma's fluent relation API (`prisma.user.findUnique(...).posts()`) works, but the chained
|
|
229
|
+
query does not get its own savepoint.
|
|
230
|
+
- Code that opens its own `PrismaClient` instead of using the mocked shared one is not isolated.
|
|
231
|
+
|
|
232
|
+
## For AI coding agents
|
|
233
|
+
|
|
234
|
+
This package is built to be used by coding agents as well as people:
|
|
235
|
+
|
|
236
|
+
- **Docs are shipped in the package.** `node_modules/vitest-prisma/README.md` and
|
|
237
|
+
`node_modules/vitest-prisma/docs/` cover setup, API, migration and every error, with no network needed.
|
|
238
|
+
- **Agent Skill:** `node_modules/vitest-prisma/skills/vitest-prisma/SKILL.md` describes how to set
|
|
239
|
+
up, write tests and debug failures. Copy it into `.claude/skills/` (or your agent's skills
|
|
240
|
+
directory) to use it.
|
|
241
|
+
- **Errors are actionable.** Messages look like `[vitest-prisma:E_CODE] what happened + how to fix`
|
|
242
|
+
and link to [docs/errors.md](./docs/errors.md).
|
|
243
|
+
- [`llms.txt`](./llms.txt) indexes the documentation.
|
|
244
|
+
|
|
245
|
+
## Acknowledgments
|
|
246
|
+
|
|
247
|
+
v1 was based on [`@quramy/jest-prisma`](https://github.com/Quramy/jest-prisma). Many thanks to
|
|
248
|
+
[@Quramy](https://github.com/Quramy) and its contributors.
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The minimal shape vitest-prisma needs from a Prisma client.
|
|
3
|
+
* Any `PrismaClient` (Prisma 6+, any generator, any driver adapter, `$extends`-ed or not) satisfies it.
|
|
4
|
+
*/
|
|
5
|
+
interface PrismaClientLike {
|
|
6
|
+
$transaction(...args: any[]): Promise<any>;
|
|
7
|
+
$disconnect(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/** Options accepted by the outer interactive transaction (`PrismaClient.$transaction(fn, options)`). */
|
|
10
|
+
interface TransactionOptions {
|
|
11
|
+
/** Max time (ms) to wait for a connection from the pool. @default 5_000 */
|
|
12
|
+
maxWait?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Max time (ms) the per-test transaction may stay open.
|
|
15
|
+
* Prisma's own default (5s) is too short for tests, so vitest-prisma raises it.
|
|
16
|
+
* @default 86_400_000 (24h, i.e. effectively bounded by Vitest's testTimeout)
|
|
17
|
+
*/
|
|
18
|
+
timeout?: number;
|
|
19
|
+
/** Isolation level of the per-test transaction. Uses the database default when omitted. */
|
|
20
|
+
isolationLevel?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Behavior that can be overridden per test file with {@link VPrisma.configure}. */
|
|
23
|
+
interface FileOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Roll back the transaction after each test.
|
|
26
|
+
* Set to `false` to commit instead (useful for inspecting data while debugging).
|
|
27
|
+
* @default true
|
|
28
|
+
*/
|
|
29
|
+
rollback?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Use SAVEPOINTs to make the per-test transaction behave like a normal (autocommit) connection:
|
|
32
|
+
* - `$transaction` called by the code under test becomes a real nested transaction: if it fails,
|
|
33
|
+
* only its own writes are rolled back.
|
|
34
|
+
* - Each standalone query runs in its own SAVEPOINT, so a failing query (e.g. a unique-constraint
|
|
35
|
+
* violation you assert with `rejects`) does not abort the rest of the test on PostgreSQL.
|
|
36
|
+
*
|
|
37
|
+
* Works on PostgreSQL, MySQL/MariaDB, SQLite and CockroachDB. Set to `false` for databases
|
|
38
|
+
* without standard SAVEPOINT syntax (SQL Server, MongoDB); nested transactions then run
|
|
39
|
+
* directly in the test transaction.
|
|
40
|
+
* @default true
|
|
41
|
+
*/
|
|
42
|
+
savepoints?: boolean;
|
|
43
|
+
}
|
|
44
|
+
/** Options for {@link createVPrisma}. */
|
|
45
|
+
interface VPrismaOptions extends FileOptions {
|
|
46
|
+
/** Options for the per-test interactive transaction. */
|
|
47
|
+
transactionOptions?: TransactionOptions;
|
|
48
|
+
}
|
|
49
|
+
/** The handle returned by {@link createVPrisma}. */
|
|
50
|
+
interface VPrisma<Client extends PrismaClientLike> {
|
|
51
|
+
/**
|
|
52
|
+
* A client bound to the current test's transaction. Everything written through it is rolled
|
|
53
|
+
* back when the test ends. Usable in `test`, `beforeEach`, `afterEach` and in code they call.
|
|
54
|
+
* Accessing it in `beforeAll`/`afterAll` or at module top level throws `E_OUTSIDE_TEST`.
|
|
55
|
+
*
|
|
56
|
+
* It is a stable reference: pass it to `vi.mock` factories or keep it in a module-level constant.
|
|
57
|
+
*/
|
|
58
|
+
readonly client: Client;
|
|
59
|
+
/**
|
|
60
|
+
* The underlying client, outside any transaction. Writes through it are NOT rolled back.
|
|
61
|
+
* Use it only in `beforeAll`/`afterAll` (e.g. seeding reference data, then cleaning it up).
|
|
62
|
+
*/
|
|
63
|
+
readonly originalClient: Client;
|
|
64
|
+
/**
|
|
65
|
+
* Override {@link FileOptions} for the current test file. Call it at the top level of a test
|
|
66
|
+
* file; it is reset after the file finishes.
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* vPrisma.configure({ rollback: false });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
configure(options: FileOptions): void;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Stable identifiers for every error thrown by vitest-prisma.
|
|
76
|
+
* Each code has a matching section in `docs/errors.md` (also shipped in the npm package).
|
|
77
|
+
*/
|
|
78
|
+
type VPrismaErrorCode = "E_OUTSIDE_TEST" | "E_CONCURRENT_TEST" | "E_UNSUPPORTED_ON_TEST_CLIENT" | "E_INVALID_CLIENT" | "E_SAVEPOINT_FAILED" | "E_CONFIGURE_IN_TEST" | "E_INVALID_TRANSACTION_ARGUMENT";
|
|
79
|
+
/** Error thrown by vitest-prisma. Check `code` to branch on the cause. */
|
|
80
|
+
export declare class VPrismaError extends Error {
|
|
81
|
+
override name: string;
|
|
82
|
+
readonly code: VPrismaErrorCode;
|
|
83
|
+
readonly docs: string;
|
|
84
|
+
constructor(code: VPrismaErrorCode, message: string, options?: ErrorOptions);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Wrap every test of the current test file in a Prisma transaction that is rolled back when the
|
|
88
|
+
* test finishes. Call it from a Vitest `setupFiles` module and export the result.
|
|
89
|
+
*
|
|
90
|
+
* @param factory Creates the PrismaClient to use. Called lazily, once per test file; the client is
|
|
91
|
+
* disconnected after the file finishes. Configure adapters, logging and `$extends` here.
|
|
92
|
+
* @param options Defaults for every test file. See {@link VPrismaOptions}.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* // vitest.setup.ts (listed in `test.setupFiles`)
|
|
97
|
+
* import { createVPrisma } from "vitest-prisma";
|
|
98
|
+
* import { PrismaPg } from "@prisma/adapter-pg";
|
|
99
|
+
* import { PrismaClient } from "./src/generated/prisma/client";
|
|
100
|
+
*
|
|
101
|
+
* export const vPrisma = createVPrisma(
|
|
102
|
+
* () => new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }),
|
|
103
|
+
* );
|
|
104
|
+
*
|
|
105
|
+
* // example.test.ts
|
|
106
|
+
* import { vPrisma } from "../vitest.setup";
|
|
107
|
+
*
|
|
108
|
+
* test("creates a user", async () => {
|
|
109
|
+
* await vPrisma.client.user.create({ data: { email: "a@example.com" } });
|
|
110
|
+
* expect(await vPrisma.client.user.count()).toBe(1); // rolled back afterwards
|
|
111
|
+
* });
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export declare function createVPrisma<Client extends PrismaClientLike>(factory: () => Client, options?: VPrismaOptions): VPrisma<Client>;
|
|
115
|
+
export type { FileOptions, PrismaClientLike, TransactionOptions, VPrisma, VPrismaErrorCode, VPrismaOptions };
|
|
116
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { afterAll, aroundEach, expect } from "vitest";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
const DOCS_URL = "https://github.com/aiji42/vitest-prisma/blob/main/docs/errors.md";
|
|
5
|
+
/** Error thrown by vitest-prisma. Check `code` to branch on the cause. */
|
|
6
|
+
var VPrismaError = class extends Error {
|
|
7
|
+
name = "VPrismaError";
|
|
8
|
+
code;
|
|
9
|
+
docs;
|
|
10
|
+
constructor(code, message, options) {
|
|
11
|
+
const docs = `${DOCS_URL}#${code.toLowerCase()}`;
|
|
12
|
+
super(`[vitest-prisma:${code}] ${message}\nSee: ${docs}`, options);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.docs = docs;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
// src/test-client.ts
|
|
18
|
+
const UNSUPPORTED = /* @__PURE__ */ new Set([
|
|
19
|
+
"$on",
|
|
20
|
+
"$use",
|
|
21
|
+
"$extends"
|
|
22
|
+
]);
|
|
23
|
+
const RAW_QUERIES = /* @__PURE__ */ new Set([
|
|
24
|
+
"$queryRaw",
|
|
25
|
+
"$queryRawUnsafe",
|
|
26
|
+
"$executeRaw",
|
|
27
|
+
"$executeRawUnsafe",
|
|
28
|
+
"$queryRawTyped",
|
|
29
|
+
"$runCommandRaw"
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Wrap Prisma's interactive-transaction client so that it behaves like a full PrismaClient running
|
|
33
|
+
* in autocommit mode:
|
|
34
|
+
* - `$transaction` becomes a nested transaction (a SAVEPOINT that is rolled back on failure).
|
|
35
|
+
* - Each query issued outside such a nested transaction runs in its own SAVEPOINT, so a failing
|
|
36
|
+
* statement does not abort the whole test transaction (PostgreSQL "current transaction is aborted").
|
|
37
|
+
* - Connection management is a no-op.
|
|
38
|
+
*
|
|
39
|
+
* With `savepoints: false`, nested transactions and queries run directly in the test transaction.
|
|
40
|
+
*/
|
|
41
|
+
function createTestClient(tx, savepoints) {
|
|
42
|
+
const insideNested = new AsyncLocalStorage();
|
|
43
|
+
let queue = Promise.resolve();
|
|
44
|
+
let savepointSeq = 0;
|
|
45
|
+
const serialize = (fn) => {
|
|
46
|
+
if (insideNested.getStore()) return fn();
|
|
47
|
+
const run = queue.then(fn);
|
|
48
|
+
queue = run.catch(() => {});
|
|
49
|
+
return run;
|
|
50
|
+
};
|
|
51
|
+
const nativeNested = typeof tx.$transaction === "function" ? tx.$transaction.bind(tx) : void 0;
|
|
52
|
+
const withSavepoint = async (fn) => {
|
|
53
|
+
if (!savepoints) return fn();
|
|
54
|
+
if (nativeNested) return nativeNested(async () => await fn());
|
|
55
|
+
const name = `vprisma_sp_${++savepointSeq}`;
|
|
56
|
+
await execSavepoint(tx, `SAVEPOINT ${name}`);
|
|
57
|
+
let result;
|
|
58
|
+
try {
|
|
59
|
+
result = await fn();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
await execSavepoint(tx, `ROLLBACK TO SAVEPOINT ${name}`);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
await execSavepoint(tx, `RELEASE SAVEPOINT ${name}`);
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
const $transaction = (arg) => {
|
|
68
|
+
let body;
|
|
69
|
+
if (typeof arg === "function") body = async () => arg(proxy);
|
|
70
|
+
else if (Array.isArray(arg)) body = async () => {
|
|
71
|
+
const results = [];
|
|
72
|
+
for (const query of arg) results.push(await query);
|
|
73
|
+
return results;
|
|
74
|
+
};
|
|
75
|
+
else throw new VPrismaError("E_INVALID_TRANSACTION_ARGUMENT", `vPrisma.client.\$transaction() expects a callback or an array of Prisma queries but received ${arg === null ? "null" : typeof arg}.`);
|
|
76
|
+
return serialize(() => withSavepoint(() => insideNested.run(true, body)));
|
|
77
|
+
};
|
|
78
|
+
const wrapQuery = (query) => {
|
|
79
|
+
if (!savepoints || !isPrismaPromise(query)) return query;
|
|
80
|
+
let result;
|
|
81
|
+
const run = () => {
|
|
82
|
+
result ??= insideNested.getStore() ? Promise.resolve(query) : serialize(() => withSavepoint(() => query));
|
|
83
|
+
return result;
|
|
84
|
+
};
|
|
85
|
+
return new Proxy(query, { get(target, prop) {
|
|
86
|
+
if (prop === "then") return (ok, ng) => run().then(ok, ng);
|
|
87
|
+
if (prop === "catch") return (ng) => run().catch(ng);
|
|
88
|
+
if (prop === "finally") return (fn) => run().finally(fn);
|
|
89
|
+
const value = Reflect.get(target, prop, target);
|
|
90
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
91
|
+
} });
|
|
92
|
+
};
|
|
93
|
+
const wrapFunction = (fn, thisArg) => (...args) => wrapQuery(fn.apply(thisArg, args));
|
|
94
|
+
const delegates = /* @__PURE__ */ new Map();
|
|
95
|
+
const wrapDelegate = (prop, delegate) => {
|
|
96
|
+
let wrapped = delegates.get(prop);
|
|
97
|
+
if (!wrapped) {
|
|
98
|
+
wrapped = new Proxy(delegate, { get(target, key) {
|
|
99
|
+
const value = Reflect.get(target, key, target);
|
|
100
|
+
return typeof value === "function" ? wrapFunction(value, target) : value;
|
|
101
|
+
} });
|
|
102
|
+
delegates.set(prop, wrapped);
|
|
103
|
+
}
|
|
104
|
+
return wrapped;
|
|
105
|
+
};
|
|
106
|
+
const proxy = new Proxy(tx, { get(target, prop) {
|
|
107
|
+
if (prop === "$transaction") return $transaction;
|
|
108
|
+
if (prop === "$connect" || prop === "$disconnect") return async () => {};
|
|
109
|
+
if (UNSUPPORTED.has(prop)) throw new VPrismaError("E_UNSUPPORTED_ON_TEST_CLIENT", `\`${String(prop)}\` is not available on vPrisma.client because it is bound to a transaction. Apply \`${String(prop)}\` to the client inside the factory passed to createVPrisma() instead.`);
|
|
110
|
+
const value = Reflect.get(target, prop, target);
|
|
111
|
+
if (RAW_QUERIES.has(prop) && typeof value === "function") return wrapFunction(value, target);
|
|
112
|
+
if (typeof prop === "string" && !prop.startsWith("$") && isObject(value)) return wrapDelegate(prop, value);
|
|
113
|
+
return value;
|
|
114
|
+
} });
|
|
115
|
+
return proxy;
|
|
116
|
+
}
|
|
117
|
+
const isObject = (value) => (typeof value === "object" || typeof value === "function") && value !== null;
|
|
118
|
+
const isPrismaPromise = (value) => isObject(value) && value[Symbol.toStringTag] === "PrismaPromise";
|
|
119
|
+
async function execSavepoint(tx, sql) {
|
|
120
|
+
try {
|
|
121
|
+
await tx.$executeRawUnsafe(sql);
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
throw new VPrismaError("E_SAVEPOINT_FAILED", `Failed to run \`${sql}\`: ${cause instanceof Error ? cause.message.trim().split("\n").at(-1) : String(cause)}. If your database does not support standard SAVEPOINT syntax (e.g. SQL Server, MongoDB), use createVPrisma(factory, { savepoints: false }).`, { cause });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// src/index.ts
|
|
127
|
+
const DEFAULT_TIMEOUT = 864e5;
|
|
128
|
+
const DEFAULT_MAX_WAIT = 5e3;
|
|
129
|
+
const PROBED_PROPS = /* @__PURE__ */ new Set([
|
|
130
|
+
"then",
|
|
131
|
+
"__esModule",
|
|
132
|
+
"toJSON",
|
|
133
|
+
"asymmetricMatch",
|
|
134
|
+
"$$typeof",
|
|
135
|
+
"nodeType",
|
|
136
|
+
"inspect",
|
|
137
|
+
"_isMockFunction"
|
|
138
|
+
]);
|
|
139
|
+
var RollbackSignal = class extends Error {};
|
|
140
|
+
const STATE_KEY = Symbol.for("vitest-prisma.state");
|
|
141
|
+
const globals = globalThis;
|
|
142
|
+
const workerState = globals[STATE_KEY] ?? {
|
|
143
|
+
slots: [],
|
|
144
|
+
next: 0,
|
|
145
|
+
file: void 0
|
|
146
|
+
};
|
|
147
|
+
globals[STATE_KEY] = workerState;
|
|
148
|
+
/**
|
|
149
|
+
* Wrap every test of the current test file in a Prisma transaction that is rolled back when the
|
|
150
|
+
* test finishes. Call it from a Vitest `setupFiles` module and export the result.
|
|
151
|
+
*
|
|
152
|
+
* @param factory Creates the PrismaClient to use. Called lazily, once per test file; the client is
|
|
153
|
+
* disconnected after the file finishes. Configure adapters, logging and `$extends` here.
|
|
154
|
+
* @param options Defaults for every test file. See {@link VPrismaOptions}.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```ts
|
|
158
|
+
* // vitest.setup.ts (listed in `test.setupFiles`)
|
|
159
|
+
* import { createVPrisma } from "vitest-prisma";
|
|
160
|
+
* import { PrismaPg } from "@prisma/adapter-pg";
|
|
161
|
+
* import { PrismaClient } from "./src/generated/prisma/client";
|
|
162
|
+
*
|
|
163
|
+
* export const vPrisma = createVPrisma(
|
|
164
|
+
* () => new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }),
|
|
165
|
+
* );
|
|
166
|
+
*
|
|
167
|
+
* // example.test.ts
|
|
168
|
+
* import { vPrisma } from "../vitest.setup";
|
|
169
|
+
*
|
|
170
|
+
* test("creates a user", async () => {
|
|
171
|
+
* await vPrisma.client.user.create({ data: { email: "a@example.com" } });
|
|
172
|
+
* expect(await vPrisma.client.user.count()).toBe(1); // rolled back afterwards
|
|
173
|
+
* });
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
function createVPrisma(factory, options = {}) {
|
|
177
|
+
const { transactionOptions, ...defaultFileOptions } = options;
|
|
178
|
+
const slot = takeSlot();
|
|
179
|
+
const getOriginal = () => {
|
|
180
|
+
if (slot.original) return slot.original;
|
|
181
|
+
const client = factory();
|
|
182
|
+
if (typeof client?.$transaction !== "function") throw new VPrismaError("E_INVALID_CLIENT", "The factory passed to createVPrisma() must return a PrismaClient instance.");
|
|
183
|
+
slot.original = client;
|
|
184
|
+
return client;
|
|
185
|
+
};
|
|
186
|
+
const hookTimeout = transactionOptions?.maxWait === void 0 ? void 0 : transactionOptions.maxWait + 1e4;
|
|
187
|
+
aroundEach(async (runTest) => {
|
|
188
|
+
if (slot.running) throw new VPrismaError("E_CONCURRENT_TEST", "Tests in the same file cannot run concurrently with vitest-prisma because they would share one transaction. Remove `.concurrent` / `sequence.concurrent` for files that use vPrisma.");
|
|
189
|
+
slot.running = true;
|
|
190
|
+
const { rollback = true, savepoints = true } = {
|
|
191
|
+
...defaultFileOptions,
|
|
192
|
+
...slot.fileOptions
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
await getOriginal().$transaction(async (tx) => {
|
|
196
|
+
slot.current = createTestClient(tx, savepoints);
|
|
197
|
+
try {
|
|
198
|
+
await runTest();
|
|
199
|
+
} finally {
|
|
200
|
+
slot.current = void 0;
|
|
201
|
+
}
|
|
202
|
+
if (rollback) throw new RollbackSignal();
|
|
203
|
+
}, {
|
|
204
|
+
maxWait: DEFAULT_MAX_WAIT,
|
|
205
|
+
timeout: DEFAULT_TIMEOUT,
|
|
206
|
+
...transactionOptions
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (!(error instanceof RollbackSignal)) throw error;
|
|
210
|
+
} finally {
|
|
211
|
+
slot.running = false;
|
|
212
|
+
}
|
|
213
|
+
}, hookTimeout);
|
|
214
|
+
afterAll(async () => {
|
|
215
|
+
workerState.next = 0;
|
|
216
|
+
workerState.file = void 0;
|
|
217
|
+
slot.fileOptions = {};
|
|
218
|
+
const client = slot.original;
|
|
219
|
+
slot.original = void 0;
|
|
220
|
+
await client?.$disconnect();
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
client: new Proxy({}, { get(_, prop) {
|
|
224
|
+
if (slot.current) return Reflect.get(slot.current, prop);
|
|
225
|
+
if (prop === "$connect" || prop === "$disconnect") return async () => {};
|
|
226
|
+
if (typeof prop === "symbol" || PROBED_PROPS.has(prop)) return void 0;
|
|
227
|
+
if (prop in Object.prototype) return Reflect.get(Object.prototype, prop);
|
|
228
|
+
throw new VPrismaError("E_OUTSIDE_TEST", `vPrisma.client.${prop} was accessed outside a test. vPrisma.client is only usable inside \`test\`, \`beforeEach\`, \`afterEach\` and code called from them. Use vPrisma.originalClient in \`beforeAll\`/\`afterAll\` (writes are not rolled back), and avoid destructuring vPrisma.client at module top level.`);
|
|
229
|
+
} }),
|
|
230
|
+
get originalClient() {
|
|
231
|
+
return getOriginal();
|
|
232
|
+
},
|
|
233
|
+
configure(options) {
|
|
234
|
+
if (slot.running) throw new VPrismaError("E_CONFIGURE_IN_TEST", "vPrisma.configure() was called while a test was running. It applies to the whole test file, so call it once at the top level of the file.");
|
|
235
|
+
slot.fileOptions = {
|
|
236
|
+
...slot.fileOptions,
|
|
237
|
+
...options
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Pick the slot for the next createVPrisma() call. Calls are numbered from 0 within each test
|
|
244
|
+
* file; the numbering restarts when the file changes, even if the previous file never reached its
|
|
245
|
+
* afterAll (collection error, skipped file).
|
|
246
|
+
*/
|
|
247
|
+
function takeSlot() {
|
|
248
|
+
const file = expect.getState().testPath;
|
|
249
|
+
if (file !== workerState.file) {
|
|
250
|
+
workerState.file = file;
|
|
251
|
+
workerState.next = 0;
|
|
252
|
+
for (const slot of workerState.slots) {
|
|
253
|
+
slot.fileOptions = {};
|
|
254
|
+
slot.original?.$disconnect().catch(() => {});
|
|
255
|
+
slot.original = void 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const index = workerState.next++;
|
|
259
|
+
const slot = workerState.slots[index] ?? {
|
|
260
|
+
current: void 0,
|
|
261
|
+
running: false,
|
|
262
|
+
original: void 0,
|
|
263
|
+
fileOptions: {}
|
|
264
|
+
};
|
|
265
|
+
workerState.slots[index] = slot;
|
|
266
|
+
return slot;
|
|
267
|
+
}
|
|
268
|
+
export { VPrismaError, createVPrisma };
|
|
269
|
+
|
|
270
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/errors.ts","../src/test-client.ts","../src/index.ts"],"sourcesContent":["const DOCS_URL = \"https://github.com/aiji42/vitest-prisma/blob/main/docs/errors.md\";\n\n/**\n * Stable identifiers for every error thrown by vitest-prisma.\n * Each code has a matching section in `docs/errors.md` (also shipped in the npm package).\n */\nexport type VPrismaErrorCode =\n | \"E_OUTSIDE_TEST\"\n | \"E_CONCURRENT_TEST\"\n | \"E_UNSUPPORTED_ON_TEST_CLIENT\"\n | \"E_INVALID_CLIENT\"\n | \"E_SAVEPOINT_FAILED\"\n | \"E_CONFIGURE_IN_TEST\"\n | \"E_INVALID_TRANSACTION_ARGUMENT\";\n\n/** Error thrown by vitest-prisma. Check `code` to branch on the cause. */\nexport class VPrismaError extends Error {\n override name = \"VPrismaError\";\n readonly code: VPrismaErrorCode;\n readonly docs: string;\n\n constructor(code: VPrismaErrorCode, message: string, options?: ErrorOptions) {\n const docs = `${DOCS_URL}#${code.toLowerCase()}`;\n super(`[vitest-prisma:${code}] ${message}\\nSee: ${docs}`, options);\n this.code = code;\n this.docs = docs;\n }\n}\n","import { AsyncLocalStorage } from \"node:async_hooks\";\nimport { VPrismaError } from \"./errors.ts\";\n\ntype TxClient = {\n $executeRawUnsafe(query: string): PromiseLike<unknown>;\n /** Native nested transactions (savepoints through the driver adapter), in recent Prisma 7+. */\n $transaction?: (fn: () => Promise<unknown>) => Promise<unknown>;\n};\n\nconst UNSUPPORTED = new Set<PropertyKey>([\"$on\", \"$use\", \"$extends\"]);\nconst RAW_QUERIES = new Set<PropertyKey>([\n \"$queryRaw\",\n \"$queryRawUnsafe\",\n \"$executeRaw\",\n \"$executeRawUnsafe\",\n \"$queryRawTyped\",\n \"$runCommandRaw\",\n]);\n\n/**\n * Wrap Prisma's interactive-transaction client so that it behaves like a full PrismaClient running\n * in autocommit mode:\n * - `$transaction` becomes a nested transaction (a SAVEPOINT that is rolled back on failure).\n * - Each query issued outside such a nested transaction runs in its own SAVEPOINT, so a failing\n * statement does not abort the whole test transaction (PostgreSQL \"current transaction is aborted\").\n * - Connection management is a no-op.\n *\n * With `savepoints: false`, nested transactions and queries run directly in the test transaction.\n */\nexport function createTestClient<Client>(tx: TxClient, savepoints: boolean): Client {\n // Set while running inside a nested transaction (or batch), whose own SAVEPOINT already applies.\n const insideNested = new AsyncLocalStorage<true>();\n // All statements share one connection, so top-level units of work must run one at a time.\n let queue: Promise<unknown> = Promise.resolve();\n let savepointSeq = 0;\n\n const serialize = <T>(fn: () => Promise<T>): Promise<T> => {\n if (insideNested.getStore()) return fn();\n const run = queue.then(fn);\n queue = run.catch(() => {});\n return run;\n };\n\n // Prefer Prisma's own nested transactions: the driver adapter issues SAVEPOINT with the right\n // protocol (MySQL rejects SAVEPOINT as a prepared statement, which $executeRawUnsafe uses).\n const nativeNested = typeof tx.$transaction === \"function\" ? tx.$transaction.bind(tx) : undefined;\n\n const withSavepoint = async <T>(fn: () => PromiseLike<T>): Promise<T> => {\n if (!savepoints) return fn();\n if (nativeNested) return nativeNested(async () => await fn()) as Promise<T>;\n const name = `vprisma_sp_${++savepointSeq}`;\n await execSavepoint(tx, `SAVEPOINT ${name}`);\n let result: T;\n try {\n result = await fn();\n } catch (error) {\n await execSavepoint(tx, `ROLLBACK TO SAVEPOINT ${name}`);\n throw error;\n }\n await execSavepoint(tx, `RELEASE SAVEPOINT ${name}`);\n return result;\n };\n\n const $transaction = (arg: unknown): Promise<unknown> => {\n let body: () => Promise<unknown>;\n if (typeof arg === \"function\") {\n body = async () => arg(proxy);\n } else if (Array.isArray(arg)) {\n body = async () => {\n const results: unknown[] = [];\n for (const query of arg) results.push(await query);\n return results;\n };\n } else {\n throw new VPrismaError(\n \"E_INVALID_TRANSACTION_ARGUMENT\",\n \"vPrisma.client.$transaction() expects a callback or an array of Prisma queries \" +\n `but received ${arg === null ? \"null\" : typeof arg}.`,\n );\n }\n return serialize(() => withSavepoint(() => insideNested.run(true, body)));\n };\n\n // Defer a lazy PrismaPromise until it is awaited, then run it in its own SAVEPOINT.\n const wrapQuery = (query: unknown): unknown => {\n // Only lazy PrismaPromises: an eager promise (e.g. from a $extends method) has already started.\n if (!savepoints || !isPrismaPromise(query)) return query;\n let result: Promise<unknown> | undefined;\n const run = () => {\n // Inside a nested transaction a failing query fails that transaction, as it would for real.\n result ??= insideNested.getStore()\n ? Promise.resolve(query)\n : serialize(() => withSavepoint(() => query));\n return result;\n };\n return new Proxy(query, {\n get(target, prop) {\n if (prop === \"then\") return (ok?: Fn, ng?: Fn) => run().then(ok, ng);\n if (prop === \"catch\") return (ng?: Fn) => run().catch(ng);\n if (prop === \"finally\") return (fn?: () => void) => run().finally(fn);\n const value = Reflect.get(target, prop, target);\n return typeof value === \"function\" ? value.bind(target) : value;\n },\n });\n };\n\n const wrapFunction =\n (fn: Fn, thisArg: unknown): Fn =>\n (...args) =>\n wrapQuery(fn.apply(thisArg, args));\n\n const delegates = new Map<PropertyKey, unknown>();\n const wrapDelegate = (prop: PropertyKey, delegate: object): unknown => {\n let wrapped = delegates.get(prop);\n if (!wrapped) {\n wrapped = new Proxy(delegate, {\n get(target, key) {\n const value = Reflect.get(target, key, target);\n return typeof value === \"function\" ? wrapFunction(value, target) : value;\n },\n });\n delegates.set(prop, wrapped);\n }\n return wrapped;\n };\n\n const proxy = new Proxy(tx, {\n get(target, prop) {\n if (prop === \"$transaction\") return $transaction;\n if (prop === \"$connect\" || prop === \"$disconnect\") return async () => {};\n if (UNSUPPORTED.has(prop)) {\n throw new VPrismaError(\n \"E_UNSUPPORTED_ON_TEST_CLIENT\",\n `\\`${String(prop)}\\` is not available on vPrisma.client because it is bound to a transaction. ` +\n `Apply \\`${String(prop)}\\` to the client inside the factory passed to createVPrisma() instead.`,\n );\n }\n const value = Reflect.get(target, prop, target);\n if (RAW_QUERIES.has(prop) && typeof value === \"function\") return wrapFunction(value, target);\n if (typeof prop === \"string\" && !prop.startsWith(\"$\") && isObject(value)) {\n return wrapDelegate(prop, value);\n }\n return value;\n },\n }) as Client;\n return proxy;\n}\n\n// biome-ignore lint/suspicious/noExplicitAny: generic passthrough\ntype Fn = (...args: any[]) => any;\n\nconst isObject = (value: unknown): value is object =>\n (typeof value === \"object\" || typeof value === \"function\") && value !== null;\n\nconst isPrismaPromise = (value: unknown): value is PromiseLike<unknown> =>\n isObject(value) &&\n (value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === \"PrismaPromise\";\n\nasync function execSavepoint(tx: TxClient, sql: string): Promise<void> {\n try {\n await tx.$executeRawUnsafe(sql);\n } catch (cause) {\n const reason = cause instanceof Error ? cause.message.trim().split(\"\\n\").at(-1) : String(cause);\n throw new VPrismaError(\n \"E_SAVEPOINT_FAILED\",\n `Failed to run \\`${sql}\\`: ${reason}. ` +\n \"If your database does not support standard SAVEPOINT syntax (e.g. SQL Server, MongoDB), \" +\n \"use createVPrisma(factory, { savepoints: false }).\",\n { cause },\n );\n }\n}\n","import { afterAll, aroundEach, expect } from \"vitest\";\nimport { VPrismaError } from \"./errors.ts\";\nimport { createTestClient } from \"./test-client.ts\";\nimport type { FileOptions, PrismaClientLike, VPrisma, VPrismaOptions } from \"./types.ts\";\n\nexport { VPrismaError, type VPrismaErrorCode } from \"./errors.ts\";\nexport type {\n FileOptions,\n PrismaClientLike,\n TransactionOptions,\n VPrisma,\n VPrismaOptions,\n} from \"./types.ts\";\n\nconst DEFAULT_TIMEOUT = 86_400_000;\n// Below Vitest's default hookTimeout (10s), so Prisma's pool-timeout error surfaces instead of a\n// hook timeout.\nconst DEFAULT_MAX_WAIT = 5_000;\n\n// Properties that tooling (vi.mock, util.inspect, expect, await) probes on arbitrary objects.\n// They must not throw when accessed outside a test. Object.prototype members are allowed too.\nconst PROBED_PROPS = new Set<PropertyKey>([\n \"then\",\n \"__esModule\",\n \"toJSON\",\n \"asymmetricMatch\",\n \"$$typeof\",\n \"nodeType\",\n \"inspect\",\n \"_isMockFunction\",\n]);\n\nclass RollbackSignal extends Error {}\n\n/**\n * Per-worker state shared by every createVPrisma() call. With `isolate: false`, the setup file is\n * re-run for each test file while application modules (and the vPrisma.client they captured in\n * vi.mock) stay cached. Instances are matched to slots by call order within a file, so a stale\n * reference still reaches the current file's transaction.\n */\ninterface Slot {\n /** Client bound to the running test's transaction. */\n current: unknown;\n running: boolean;\n original: PrismaClientLike | undefined;\n fileOptions: FileOptions;\n}\ninterface WorkerState {\n slots: Slot[];\n next: number;\n /** Test file whose setup is (or was last) running in this worker. */\n file: string | undefined;\n}\nconst STATE_KEY = Symbol.for(\"vitest-prisma.state\");\nconst globals = globalThis as Record<symbol, WorkerState | undefined>;\nconst workerState: WorkerState = globals[STATE_KEY] ?? {\n slots: [],\n next: 0,\n file: undefined,\n};\nglobals[STATE_KEY] = workerState;\n\n/**\n * Wrap every test of the current test file in a Prisma transaction that is rolled back when the\n * test finishes. Call it from a Vitest `setupFiles` module and export the result.\n *\n * @param factory Creates the PrismaClient to use. Called lazily, once per test file; the client is\n * disconnected after the file finishes. Configure adapters, logging and `$extends` here.\n * @param options Defaults for every test file. See {@link VPrismaOptions}.\n *\n * @example\n * ```ts\n * // vitest.setup.ts (listed in `test.setupFiles`)\n * import { createVPrisma } from \"vitest-prisma\";\n * import { PrismaPg } from \"@prisma/adapter-pg\";\n * import { PrismaClient } from \"./src/generated/prisma/client\";\n *\n * export const vPrisma = createVPrisma(\n * () => new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) }),\n * );\n *\n * // example.test.ts\n * import { vPrisma } from \"../vitest.setup\";\n *\n * test(\"creates a user\", async () => {\n * await vPrisma.client.user.create({ data: { email: \"a@example.com\" } });\n * expect(await vPrisma.client.user.count()).toBe(1); // rolled back afterwards\n * });\n * ```\n */\nexport function createVPrisma<Client extends PrismaClientLike>(\n factory: () => Client,\n options: VPrismaOptions = {},\n): VPrisma<Client> {\n const { transactionOptions, ...defaultFileOptions } = options;\n const slot = takeSlot();\n\n const getOriginal = (): Client => {\n if (slot.original) return slot.original as Client;\n const client = factory();\n if (typeof client?.$transaction !== \"function\") {\n throw new VPrismaError(\n \"E_INVALID_CLIENT\",\n \"The factory passed to createVPrisma() must return a PrismaClient instance.\",\n );\n }\n slot.original = client;\n return client;\n };\n\n // The connection wait happens in aroundEach's setup phase, which Vitest bounds by hookTimeout.\n const hookTimeout =\n transactionOptions?.maxWait === undefined ? undefined : transactionOptions.maxWait + 10_000;\n\n aroundEach(async (runTest) => {\n if (slot.running) {\n throw new VPrismaError(\n \"E_CONCURRENT_TEST\",\n \"Tests in the same file cannot run concurrently with vitest-prisma because they would share \" +\n \"one transaction. Remove `.concurrent` / `sequence.concurrent` for files that use vPrisma.\",\n );\n }\n slot.running = true;\n const { rollback = true, savepoints = true } = {\n ...defaultFileOptions,\n ...slot.fileOptions,\n };\n try {\n await getOriginal().$transaction(\n async (tx: Parameters<typeof createTestClient>[0]) => {\n slot.current = createTestClient<Client>(tx, savepoints);\n try {\n await runTest();\n } finally {\n slot.current = undefined;\n }\n if (rollback) throw new RollbackSignal();\n },\n {\n maxWait: DEFAULT_MAX_WAIT,\n timeout: DEFAULT_TIMEOUT,\n ...transactionOptions,\n },\n );\n } catch (error) {\n if (!(error instanceof RollbackSignal)) throw error;\n } finally {\n slot.running = false;\n }\n }, hookTimeout);\n\n afterAll(async () => {\n workerState.next = 0;\n workerState.file = undefined;\n slot.fileOptions = {};\n const client = slot.original;\n slot.original = undefined;\n await client?.$disconnect();\n });\n\n const client = new Proxy({} as Client, {\n get(_, prop) {\n if (slot.current) return Reflect.get(slot.current, prop);\n // e.g. `afterAll(() => db.$disconnect())` on a mocked shared client.\n if (prop === \"$connect\" || prop === \"$disconnect\") return async () => {};\n if (typeof prop === \"symbol\" || PROBED_PROPS.has(prop)) return undefined;\n if (prop in Object.prototype) return Reflect.get(Object.prototype, prop);\n throw new VPrismaError(\n \"E_OUTSIDE_TEST\",\n `vPrisma.client.${prop} was accessed outside a test. vPrisma.client is only usable inside ` +\n \"`test`, `beforeEach`, `afterEach` and code called from them. Use vPrisma.originalClient \" +\n \"in `beforeAll`/`afterAll` (writes are not rolled back), and avoid destructuring \" +\n \"vPrisma.client at module top level.\",\n );\n },\n });\n\n return {\n client,\n get originalClient() {\n return getOriginal();\n },\n configure(options) {\n if (slot.running) {\n throw new VPrismaError(\n \"E_CONFIGURE_IN_TEST\",\n \"vPrisma.configure() was called while a test was running. It applies to the whole test \" +\n \"file, so call it once at the top level of the file.\",\n );\n }\n slot.fileOptions = { ...slot.fileOptions, ...options };\n },\n };\n}\n\n/**\n * Pick the slot for the next createVPrisma() call. Calls are numbered from 0 within each test\n * file; the numbering restarts when the file changes, even if the previous file never reached its\n * afterAll (collection error, skipped file).\n */\nfunction takeSlot(): Slot {\n const file = expect.getState().testPath;\n if (file !== workerState.file) {\n workerState.file = file;\n workerState.next = 0;\n // Clean up after a file whose afterAll did not run.\n for (const slot of workerState.slots) {\n slot.fileOptions = {};\n void slot.original?.$disconnect().catch(() => {});\n slot.original = undefined;\n }\n }\n const index = workerState.next++;\n const slot = workerState.slots[index] ?? {\n current: undefined,\n running: false,\n original: undefined,\n fileOptions: {},\n };\n workerState.slots[index] = slot;\n return slot;\n}\n"],"mappings":"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"}
|
package/docs/errors.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Error reference
|
|
2
|
+
|
|
3
|
+
Every error thrown by vitest-prisma is a `VPrismaError` whose message starts with
|
|
4
|
+
`[vitest-prisma:<CODE>]` and ends with a link to the matching section below. Branch on
|
|
5
|
+
`error.code` in code; search this file for the code when diagnosing a failure.
|
|
6
|
+
|
|
7
|
+
## E_OUTSIDE_TEST
|
|
8
|
+
|
|
9
|
+
**Cause:** `vPrisma.client` was accessed when no test transaction is open:
|
|
10
|
+
|
|
11
|
+
- in `beforeAll` / `afterAll`,
|
|
12
|
+
- at module top level (for example `const { user } = vPrisma.client`),
|
|
13
|
+
- from code that keeps running after its test has finished (an un-awaited promise, a timer).
|
|
14
|
+
|
|
15
|
+
**Fix:**
|
|
16
|
+
|
|
17
|
+
- Move the code into `test`, `beforeEach` or `afterEach`. These all run inside the test transaction.
|
|
18
|
+
- For per-file setup that must happen once, use `vPrisma.originalClient` in `beforeAll` and clean up
|
|
19
|
+
in `afterAll`. These writes are **not** rolled back.
|
|
20
|
+
- Keep a reference to the client (`const prisma = vPrisma.client`), not to its properties.
|
|
21
|
+
- Make sure every query in the test is awaited.
|
|
22
|
+
|
|
23
|
+
## E_CONCURRENT_TEST
|
|
24
|
+
|
|
25
|
+
**Cause:** two tests in the same file ran at the same time (`test.concurrent`, `describe.concurrent`
|
|
26
|
+
or `sequence.concurrent: true`). All tests of a file share one client, and each test needs its own
|
|
27
|
+
transaction, so they cannot overlap.
|
|
28
|
+
|
|
29
|
+
**Fix:** remove `.concurrent` from files that use vPrisma. Different test files still run in
|
|
30
|
+
parallel, each in its own worker and transaction. To speed up one large file, split it into several files.
|
|
31
|
+
|
|
32
|
+
## E_UNSUPPORTED_ON_TEST_CLIENT
|
|
33
|
+
|
|
34
|
+
**Cause:** `$extends`, `$on` or `$use` was called on `vPrisma.client`. The test client wraps a
|
|
35
|
+
transaction, and Prisma does not support these methods on transaction clients.
|
|
36
|
+
|
|
37
|
+
**Fix:** apply them inside the factory you pass to `createVPrisma`:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
export const vPrisma = createVPrisma(() =>
|
|
41
|
+
new PrismaClient({ adapter, log: ["query"] }).$extends(myExtension),
|
|
42
|
+
);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If application code calls `$extends` on a shared client at import time, export the extended client
|
|
46
|
+
from your module and mock that export instead.
|
|
47
|
+
|
|
48
|
+
## E_INVALID_CLIENT
|
|
49
|
+
|
|
50
|
+
**Cause:** the factory passed to `createVPrisma` did not return an object with a `$transaction`
|
|
51
|
+
method.
|
|
52
|
+
|
|
53
|
+
**Fix:** return the client instance itself: `createVPrisma(() => new PrismaClient(...))`. Do not
|
|
54
|
+
return a promise, a module or a model delegate.
|
|
55
|
+
|
|
56
|
+
## E_SAVEPOINT_FAILED
|
|
57
|
+
|
|
58
|
+
**Cause:** a `SAVEPOINT` / `RELEASE SAVEPOINT` / `ROLLBACK TO SAVEPOINT` statement failed.
|
|
59
|
+
vitest-prisma uses savepoints for nested `$transaction` calls and to isolate each standalone query.
|
|
60
|
+
The original database error is attached as `error.cause`.
|
|
61
|
+
|
|
62
|
+
When the transaction client supports nested `$transaction` natively (recent Prisma 7+),
|
|
63
|
+
vitest-prisma uses that instead and this error does not occur.
|
|
64
|
+
|
|
65
|
+
**Fix:**
|
|
66
|
+
|
|
67
|
+
- On MySQL with a Prisma 7 release whose transaction client lacks native nested transactions, the
|
|
68
|
+
cause reads "This command is not supported in the prepared statement protocol". Upgrade Prisma.
|
|
69
|
+
If you can't, use `savepoints: false`.
|
|
70
|
+
- On SQL Server or MongoDB, which have no standard `SAVEPOINT` syntax, disable savepoints:
|
|
71
|
+
`createVPrisma(factory, { savepoints: false })`. A failed nested transaction then cannot be
|
|
72
|
+
rolled back on its own.
|
|
73
|
+
- Otherwise, inspect `error.cause`. A common cause is the test transaction having already been
|
|
74
|
+
closed by Prisma's transaction `timeout`. Raise it with
|
|
75
|
+
`createVPrisma(factory, { transactionOptions: { timeout } })`.
|
|
76
|
+
|
|
77
|
+
## E_CONFIGURE_IN_TEST
|
|
78
|
+
|
|
79
|
+
**Cause:** `vPrisma.configure()` was called while a test was running. Its options apply to the
|
|
80
|
+
whole test file and are read when each test starts, so a call inside a test would silently change
|
|
81
|
+
every later test in the file.
|
|
82
|
+
|
|
83
|
+
**Fix:** call `vPrisma.configure()` once, at the top level of the test file. Calling it inside a
|
|
84
|
+
`describe` callback also affects the whole file. To use different options, put those tests in
|
|
85
|
+
another file.
|
|
86
|
+
|
|
87
|
+
## E_INVALID_TRANSACTION_ARGUMENT
|
|
88
|
+
|
|
89
|
+
**Cause:** `vPrisma.client.$transaction()` received something other than a callback or an array of
|
|
90
|
+
Prisma queries.
|
|
91
|
+
|
|
92
|
+
**Fix:** pass `async (tx) => { ... }` or `[prisma.a.create(...), prisma.b.update(...)]`. Query
|
|
93
|
+
arrays must hold un-awaited Prisma queries.
|
|
94
|
+
|
|
95
|
+
## Not a vitest-prisma error, but common
|
|
96
|
+
|
|
97
|
+
### `Cannot access 'vPrisma' before initialization` (from `vi.mock`)
|
|
98
|
+
|
|
99
|
+
The setup file imported the module it mocks (directly or through another import), so the
|
|
100
|
+
mock factory ran before `vPrisma` existed. Import the client *factory* from a module that is
|
|
101
|
+
not mocked, and mock only the module that holds the shared instance. See
|
|
102
|
+
[Mocking a shared client](../README.md#mocking-a-shared-client).
|
|
103
|
+
|
|
104
|
+
### `Transaction already closed` / `Transaction API error: Transaction ... timed out`
|
|
105
|
+
|
|
106
|
+
The test ran longer than `transactionOptions.timeout`, which defaults to 24 hours. You may have
|
|
107
|
+
lowered it, or the test leaked a query past its own end. Await every query, or raise the timeout.
|
|
108
|
+
|
|
109
|
+
### `Transaction failed due to a write conflict or a deadlock` / lock wait timeouts
|
|
110
|
+
|
|
111
|
+
Two test files running in parallel inserted the same unique value. Each holds its row lock
|
|
112
|
+
until its transaction rolls back, so they block each other. Make test data unique per file or per
|
|
113
|
+
test, or set `fileParallelism: false`.
|
|
114
|
+
|
|
115
|
+
### SQLite: `database is locked` / `SQLITE_BUSY`
|
|
116
|
+
|
|
117
|
+
SQLite allows only one writer at a time, and every test holds a write transaction. Set
|
|
118
|
+
`fileParallelism: false` in your Vitest config, or give each worker its own database file.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Migrating from vitest-environment-vprisma
|
|
2
|
+
|
|
3
|
+
`vitest-prisma` is the successor of `vitest-environment-vprisma` v1. It is a rewrite: it no longer
|
|
4
|
+
ships a Vitest environment and no longer depends on `@quramy/jest-prisma-core`. The per-test
|
|
5
|
+
transaction is driven by Vitest's `aroundEach` hook.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
| | vitest-environment-vprisma v1 | vitest-prisma |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Vitest | 0.34 – 1.x | **>= 4.1** |
|
|
12
|
+
| Prisma | 2.29 – 5.x | **>= 6** (any generator, any driver adapter) |
|
|
13
|
+
| Node.js | any | **>= 22.12** |
|
|
14
|
+
| Module format | ESM + CJS | ESM only |
|
|
15
|
+
|
|
16
|
+
## Steps
|
|
17
|
+
|
|
18
|
+
1. Swap the package:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm remove vitest-environment-vprisma
|
|
22
|
+
npm install -D vitest-prisma
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
2. Create a setup file that builds the client yourself. This replaces `environment: "vprisma"`,
|
|
26
|
+
the `vitest-environment-vprisma/setup` setup file and the `databaseUrl` option:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// vitest.setup.ts
|
|
30
|
+
import { createVPrisma } from "vitest-prisma";
|
|
31
|
+
import { createClient } from "./src/client";
|
|
32
|
+
|
|
33
|
+
export const vPrisma = createVPrisma(createClient);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. Update `vitest.config.ts`:
|
|
37
|
+
|
|
38
|
+
```diff
|
|
39
|
+
export default defineConfig({
|
|
40
|
+
test: {
|
|
41
|
+
- globals: true,
|
|
42
|
+
- environment: "vprisma",
|
|
43
|
+
- setupFiles: ["vitest-environment-vprisma/setup", "./vitest.setup.ts"],
|
|
44
|
+
- environmentOptions: { vprisma: { baseEnv: "jsdom", disableRollback: false } },
|
|
45
|
+
+ environment: "jsdom", // only if you used baseEnv; otherwise omit
|
|
46
|
+
+ setupFiles: ["./vitest.setup.ts"],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`globals: true` is no longer required.
|
|
52
|
+
|
|
53
|
+
4. Replace the global `vPrisma` with an import, and remove `"vitest-environment-vprisma"` from
|
|
54
|
+
`compilerOptions.types` in `tsconfig.json`:
|
|
55
|
+
|
|
56
|
+
```diff
|
|
57
|
+
+import { vPrisma } from "../vitest.setup";
|
|
58
|
+
const prisma = vPrisma.client;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
5. Map the old options:
|
|
62
|
+
|
|
63
|
+
| v1 option | vitest-prisma |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `baseEnv` | Use Vitest's own `environment` option (or `// @vitest-environment jsdom`). |
|
|
66
|
+
| `disableRollback: true` | `createVPrisma(factory, { rollback: false })`, or `vPrisma.configure({ rollback: false })` in one file |
|
|
67
|
+
| `databaseUrl` | Pass the URL to your adapter / client in the factory. |
|
|
68
|
+
| `verboseQuery: true` | Enable Prisma's own logging in the factory: `new PrismaClient({ adapter, log: ["query"] })` |
|
|
69
|
+
| `// @vitest-environment-options {...}` | `vPrisma.configure({...})` at the top of the test file |
|
|
70
|
+
| `enableExperimentalRollbackInTransaction` | Always on (`savepoints: true` by default). |
|
|
71
|
+
|
|
72
|
+
## Behavior changes
|
|
73
|
+
|
|
74
|
+
- **Top-level `beforeEach` / `afterEach` now run inside the test transaction.** The v1 workaround
|
|
75
|
+
of moving `beforeEach` into a `describe` block is no longer needed.
|
|
76
|
+
- **A failing query no longer breaks the rest of the test on PostgreSQL.** Each standalone query
|
|
77
|
+
runs in its own savepoint.
|
|
78
|
+
- **Nested `$transaction` calls roll back on their own when they fail.** v1 needed an experimental
|
|
79
|
+
flag for this.
|
|
80
|
+
- **Standalone queries are slower.** Each one runs in a savepoint, which takes about 1.8× as long
|
|
81
|
+
on a local PostgreSQL. Use `savepoints: false` (globally or per file) for v1-like speed. See
|
|
82
|
+
[Speed vs. savepoints](../README.md#speed-vs-savepoints).
|
|
83
|
+
- **Concurrent tests in one file now fail fast** with `E_CONCURRENT_TEST`. In v1 they silently
|
|
84
|
+
shared a transaction.
|
|
85
|
+
- **The client is disconnected after each test file.**
|
package/package.json
CHANGED
|
@@ -1,11 +1,97 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vitest-prisma",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Isolate every Vitest test in a Prisma transaction and roll it back automatically.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vitest",
|
|
7
|
+
"prisma",
|
|
8
|
+
"testing",
|
|
9
|
+
"transaction",
|
|
10
|
+
"rollback",
|
|
11
|
+
"database"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./dist/index.mjs",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"docs",
|
|
22
|
+
"skills"
|
|
23
|
+
],
|
|
5
24
|
"repository": {
|
|
6
25
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/aiji42/vitest-
|
|
26
|
+
"url": "git+https://github.com/aiji42/vitest-prisma.git"
|
|
8
27
|
},
|
|
28
|
+
"homepage": "https://github.com/aiji42/vitest-prisma#readme",
|
|
29
|
+
"bugs": "https://github.com/aiji42/vitest-prisma/issues",
|
|
9
30
|
"author": "aiji42 <aiji42@gmail.com> (https://twitter.com/aiji42_dev)",
|
|
10
|
-
"license": "MIT"
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"packageManager": "pnpm@12.15.0",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.12.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"dev": "tsdown --watch",
|
|
39
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json",
|
|
40
|
+
"lint": "biome check .",
|
|
41
|
+
"lint:fix": "biome check --write .",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"check:package": "publint && attw --pack . --profile esm-only",
|
|
45
|
+
"prepack": "pnpm build"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"vitest": ">=4.1.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
52
|
+
"@biomejs/biome": "^2.6.3",
|
|
53
|
+
"@semantic-release/exec": "^7.1.0",
|
|
54
|
+
"@types/node": "^22.20.5",
|
|
55
|
+
"publint": "^0.3.25",
|
|
56
|
+
"semantic-release": "^25.0.9",
|
|
57
|
+
"tsdown": "^0.23.2",
|
|
58
|
+
"typescript": "^7.0.2",
|
|
59
|
+
"vitest": "^5.0.5"
|
|
60
|
+
},
|
|
61
|
+
"release": {
|
|
62
|
+
"tagFormat": "vitest-prisma@${version}",
|
|
63
|
+
"branches": [
|
|
64
|
+
"main",
|
|
65
|
+
{
|
|
66
|
+
"name": "beta",
|
|
67
|
+
"prerelease": true
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"plugins": [
|
|
71
|
+
"@semantic-release/commit-analyzer",
|
|
72
|
+
"@semantic-release/release-notes-generator",
|
|
73
|
+
[
|
|
74
|
+
"@semantic-release/npm",
|
|
75
|
+
{
|
|
76
|
+
"npmPublish": false
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
"@semantic-release/exec",
|
|
81
|
+
{
|
|
82
|
+
"publishCmd": "npm --version && npm stage publish --access public --tag ${nextRelease.channel || 'latest'} --loglevel verbose"
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
"@semantic-release/github",
|
|
87
|
+
{
|
|
88
|
+
"successCommentCondition": "<% if (issue.number >= 10) { %>true<% } %>"
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
"publishConfig": {
|
|
94
|
+
"access": "public",
|
|
95
|
+
"provenance": true
|
|
96
|
+
}
|
|
11
97
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vitest-prisma
|
|
3
|
+
description: Set up and debug database tests that use vitest-prisma (each Vitest test runs in a Prisma transaction that is rolled back). Use when adding vitest-prisma to a project, writing tests that touch the database through Prisma, migrating from vitest-environment-vprisma, or when a test fails with a [vitest-prisma:E_*] error.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# vitest-prisma
|
|
7
|
+
|
|
8
|
+
vitest-prisma wraps `beforeEach` + test + `afterEach` in one Prisma interactive transaction and
|
|
9
|
+
rolls it back. Full docs: `node_modules/vitest-prisma/README.md`, error reference:
|
|
10
|
+
`node_modules/vitest-prisma/docs/errors.md`.
|
|
11
|
+
|
|
12
|
+
## Set up
|
|
13
|
+
|
|
14
|
+
1. Check requirements: `vitest >= 4.1`, Prisma `>= 6`, Node `>= 22.12`. Install with `-D vitest-prisma`.
|
|
15
|
+
2. Find how the project builds its `PrismaClient` (adapter, output path such as
|
|
16
|
+
`src/generated/prisma/client`, `$extends`). If one module both creates and exports the shared
|
|
17
|
+
instance, split it into a factory module (`createClient()`) and an instance module
|
|
18
|
+
(`export const db = createClient()`). The setup file must import only the factory.
|
|
19
|
+
3. Create the setup file and register it in `test.setupFiles`:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// vitest.setup.ts
|
|
23
|
+
import { vi } from "vitest";
|
|
24
|
+
import { createVPrisma } from "vitest-prisma";
|
|
25
|
+
import { createClient } from "./src/client";
|
|
26
|
+
|
|
27
|
+
export const vPrisma = createVPrisma(createClient);
|
|
28
|
+
vi.mock("./src/db", () => ({ db: vPrisma.client })); // path of the shared-instance module
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
4. Make sure the test database has the schema (`prisma migrate deploy` or `prisma db push` in
|
|
32
|
+
`globalSetup` or CI). vitest-prisma does not migrate.
|
|
33
|
+
5. SQLite: set `fileParallelism: false`.
|
|
34
|
+
6. Verify: write one test that creates a row and one that expects the table to be empty, then run them.
|
|
35
|
+
|
|
36
|
+
## Write tests
|
|
37
|
+
|
|
38
|
+
- Use `vPrisma.client` (imported from the setup file) or the application's mocked client inside
|
|
39
|
+
`test` / `beforeEach` / `afterEach`. Do not clean up; everything is rolled back.
|
|
40
|
+
- Never use `vPrisma.client` in `beforeAll` / `afterAll` or at module top level. Use
|
|
41
|
+
`vPrisma.originalClient` there, and clean up manually because those writes are not rolled back.
|
|
42
|
+
- Do not use `test.concurrent` / `describe.concurrent` in files that use the database.
|
|
43
|
+
- Asserting a DB error is fine: `await expect(createDuplicate()).rejects.toThrow()`, then keep querying.
|
|
44
|
+
- Slow DB-heavy file that never expects query errors: `vPrisma.configure({ savepoints: false })`
|
|
45
|
+
at the top of that file (about 1.8× faster per query).
|
|
46
|
+
- Debug the data a test leaves: add `vPrisma.configure({ rollback: false })` temporarily.
|
|
47
|
+
|
|
48
|
+
## Diagnose failures
|
|
49
|
+
|
|
50
|
+
Match the code in `[vitest-prisma:E_*]` and apply the fix from `docs/errors.md`:
|
|
51
|
+
|
|
52
|
+
| Code | Usual fix |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `E_OUTSIDE_TEST` | Move the access into `test`/`beforeEach`, use `originalClient` in `beforeAll`, or await a leaked promise. |
|
|
55
|
+
| `E_CONCURRENT_TEST` | Remove `.concurrent` from the file. |
|
|
56
|
+
| `E_UNSUPPORTED_ON_TEST_CLIENT` | Move `$extends`/`$on`/`$use` into the factory. |
|
|
57
|
+
| `E_INVALID_CLIENT` | Make the factory return the client instance itself. |
|
|
58
|
+
| `E_SAVEPOINT_FAILED` | SQL Server/MongoDB: `savepoints: false`. Otherwise read `error.cause`. |
|
|
59
|
+
| `E_CONFIGURE_IN_TEST` | Move `vPrisma.configure()` to the top level of the file. |
|
|
60
|
+
| `E_INVALID_TRANSACTION_ARGUMENT` | Pass a callback or an array of un-awaited queries. |
|
|
61
|
+
|
|
62
|
+
Other symptoms:
|
|
63
|
+
|
|
64
|
+
- `Cannot access 'vPrisma' before initialization`: the setup file imports the mocked module. Split
|
|
65
|
+
factory and instance (Set up, step 2).
|
|
66
|
+
- Data leaks between tests: the code under test creates its own `PrismaClient` instead of using the
|
|
67
|
+
mocked shared module. Mock that module too, or refactor it to use the shared client.
|
|
68
|
+
- `SQLITE_BUSY` / `database is locked`: set `fileParallelism: false`.
|
|
69
|
+
- Deadlocks or lock waits between test files: parallel files insert the same unique values. Make
|
|
70
|
+
test data unique per file or test (for example, a random suffix).
|