@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 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;
@@ -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
+ }
@@ -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";
@@ -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
+ }