Prisma 7 + SQLite: nested writes, interactive transactions and unique errors: working versions (tested 2026-10-15)
Prisma Client 7 on SQLite (better-sqlite3 adapter): nested creates, an interactive $transaction that rolls back, and handling P2002 unique-constraint errors.
passed4 passed · 0 failed2026-10-15 13:32 UTC- Status
- passed
- Last passed
- 2026-10-15 13:32 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund && npx prisma db push && npx prisma generate npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("prisma-sqlite-transactions") (install), or see them on GitHub.
{
"name": "prisma-sqlite-transactions",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@prisma/client": "7.10.0",
"@prisma/adapter-better-sqlite3": "7.10.0"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2",
"prisma": "7.10.0"
}
}
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model Account {
id Int @id @default(autoincrement())
email String @unique
balance Int @default(0)
entries Entry[]
}
model Entry {
id Int @id @default(autoincrement())
amount Int
account Account @relation(fields: [accountId], references: [id])
accountId Int
}
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: { url: "file:./test.db" },
});
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient, Prisma } from "./generated/prisma/client.ts";
export const createClient = (url = "file:./test.db") => new PrismaClient({ adapter: new PrismaBetterSqlite3({ url }) });
type Db = ReturnType<typeof createClient>;
export class EmailTaken extends Error {}
export async function openAccount(db: Db, email: string, deposit: number) {
try {
return await db.account.create({ data: { email, balance: deposit, entries: { create: [{ amount: deposit }] } }, include: { entries: true } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") throw new EmailTaken(email);
throw e;
}
}
// Interactive transaction: any throw inside rolls back every write.
export function transfer(db: Db, from: string, to: string, amount: number) {
return db.$transaction(async (tx) => {
const src = await tx.account.update({ where: { email: from }, data: { balance: { decrement: amount }, entries: { create: { amount: -amount } } } });
if (src.balance < 0) throw new Error("insufficient funds");
await tx.account.update({ where: { email: to }, data: { balance: { increment: amount }, entries: { create: { amount } } } });
});
}
import { test, beforeEach, after } from "node:test";
import assert from "node:assert/strict";
import { createClient, openAccount, transfer, EmailTaken } from "../src/bank.ts";
const db = createClient();
beforeEach(async () => { await db.entry.deleteMany(); await db.account.deleteMany(); });
after(() => db.$disconnect());
const bal = async (email: string) => (await db.account.findUniqueOrThrow({ where: { email } })).balance;
test("nested create", async () => {
const a = await openAccount(db, "[email protected]", 100);
assert.equal(a.entries.length, 1);
});
test("duplicate email maps P2002 to a domain error", async () => {
await openAccount(db, "[email protected]", 1);
await assert.rejects(openAccount(db, "[email protected]", 1), EmailTaken);
});
test("transfer commits both sides", async () => {
await openAccount(db, "[email protected]", 100); await openAccount(db, "[email protected]", 0);
await transfer(db, "[email protected]", "[email protected]", 40);
assert.deepEqual([await bal("[email protected]"), await bal("[email protected]")], [60, 40]);
});
test("failed transfer rolls back", async () => {
await openAccount(db, "[email protected]", 10); await openAccount(db, "[email protected]", 0);
await assert.rejects(transfer(db, "[email protected]", "[email protected]", 50), /insufficient/);
assert.deepEqual([await bal("[email protected]"), await bal("[email protected]")], [10, 0]);
assert.equal(await db.entry.count(), 2);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Prisma 7 + SQLite via better-sqlite3 adapter
Prisma Client 7 with the better-sqlite3 driver adapter, schema push and relational queries on a local SQLite file.
Drizzle ORM + better-sqlite3 CRUD
Typed schema, migrations-free table creation and CRUD with Drizzle on an in-memory SQLite database.
Kysely + better-sqlite3 typed queries
Type-safe SQL with Kysely on SQLite, including a schema migration, joins and a transaction.
better-sqlite3 transactions with Zod-validated inputs
A small ledger on better-sqlite3 using db.transaction for atomic transfers, nested transactions as savepoints for all-or-nothing batches, and Zod 4 to validate inputs and rows, tested on in-memory SQLite.
All Prisma recipesAll SQLite recipesAll better-sqlite3 recipes