Plans the replacement of the raw mariadb pool and hand-written repository with Prisma Client, plus a no-data-loss Prisma Migrate baseline against the existing conversion_jobs table. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
Prisma ORM and DB migrations
Date: 2026-07-30
Goal
Replace the hand-rolled mariadb connection pool and raw SQL repository (src/db.js, src/jobs/jobRepository.js) with Prisma Client for data access, and Prisma Migrate for schema versioning. Today db/schema.sql is the sole source of truth and is applied by hand against local and o2switch production databases (see CLAUDE.md); there is no migration runner. This introduces one.
Scope
src/db.js,src/jobs/jobRepository.js: full rewrite onto Prisma Client.src/app.js,src/server.js,src/worker.js,src/cleanup.js: renamed variable/import (pool→prisma), no logic changes — function signatures taking the client as first argument are preserved.- New:
prisma/schema.prisma,prisma/migrations/,scripts/printDatabaseUrl.js. - Removed:
db/schema.sql, themariadbdependency. - Test files that currently talk to the raw pool:
test/db.test.js,test/jobs/jobRepository.test.js,test/cleanup.test.js,test/worker.test.js,test/api/jobs.test.js,test/api/jobStatus.test.js,test/api/endToEnd.test.js. - Out of scope: any change to the
conversion_jobscolumns themselves (this is a data-access-layer migration, not a schema change), frontend, converters.
Schema
prisma/schema.prisma reproduces db/schema.sql column-for-column, so the initial migration is a no-op against existing databases:
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum JobStatus {
pending
processing
done
failed
}
model ConversionJob {
id Int @id @default(autoincrement()) @db.UnsignedInt
uuid String @unique @db.Char(36)
status JobStatus @default(pending)
family String @db.VarChar(32)
sourceFormat String @map("source_format") @db.VarChar(16)
targetFormat String @map("target_format") @db.VarChar(16)
originalFilename String @map("original_filename") @db.VarChar(255)
inputPath String @map("input_path") @db.VarChar(255)
outputPath String? @map("output_path") @db.VarChar(255)
inputMimeType String @map("input_mime_type") @db.VarChar(128)
outputMimeType String? @map("output_mime_type") @db.VarChar(128)
inputSizeBytes Int @map("input_size_bytes") @db.UnsignedInt
outputSizeBytes Int? @map("output_size_bytes") @db.UnsignedInt
quality Int? @db.UnsignedSmallInt
conversionDurationSeconds Decimal? @map("conversion_duration_seconds") @db.Decimal(10, 3)
errorMessage String? @map("error_message") @db.VarChar(255)
errorLog String? @map("error_log") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
expiresAt DateTime @map("expires_at")
cleanedAt DateTime? @map("cleaned_at")
@@index([status])
@@index([expiresAt])
@@map("conversion_jobs")
}
Prisma's mysql provider is protocol-compatible with MariaDB for all types used here (ENUM, DECIMAL, CHAR, DATETIME, unsigned integers).
Migration baseline (no data loss)
Local and production databases already have conversion_jobs created by hand via db/schema.sql. To adopt Prisma Migrate without dropping or recreating that table:
- Generate the initial migration without applying it:
npx prisma migrate dev --name init --create-only(withDATABASE_URLset — see "Running Prisma CLI locally" below). This producesprisma/migrations/<timestamp>_init/migration.sql, hand-verified to matchdb/schema.sql. - On every environment that already has the table (local dev DB, o2switch production):
npx prisma migrate resolve --applied <timestamp>_initinstead of running the migration. This records the migration as applied in Prisma's_prisma_migrationstracking table without touchingconversion_jobsor its data. - Delete
db/schema.sql—prisma/schema.prismaandprisma/migrations/become the source of truth going forward.
Future schema changes: edit schema.prisma, run npx prisma migrate dev --name <description> locally (generates SQL, applies it to the local DB), commit the generated migration folder, and run npx prisma migrate deploy in production (applies pending migrations, no interactive prompts).
Running Prisma CLI locally
Prisma CLI commands (migrate dev, migrate deploy, generate) need DATABASE_URL in their own process environment, independent of the app. Per CLAUDE.md, .env holds production credentials and must never be loaded for local work, and there is no dotenv wiring that reads .env.local automatically — the existing convention is to pass local DB vars inline. This design keeps that convention: DATABASE_URL is never written into .env or .env.local; it is always computed on demand.
src/db.js exports buildDatabaseUrl(config):
export function buildDatabaseUrl(config) {
const { host, user, password, database } = config.db;
return `mysql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:3306/${database}?connection_limit=10`;
}
?connection_limit=10 preserves the current pool's connectionLimit: 10 behavior under Prisma's own internal connection pool.
scripts/printDatabaseUrl.js reuses it so the URL is computed in exactly one place:
import { loadConfig } from '../src/config.js';
import { buildDatabaseUrl } from '../src/db.js';
console.log(buildDatabaseUrl(loadConfig()));
Local usage, matching the inline-env-var pattern already documented in CLAUDE.md:
DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage \
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate dev
Production deploy: with the real DB_* variables loaded from .env, run the same DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate deploy before starting the app.
Data access layer
src/db.js: getPool/closePool become getPrismaClient/closePrismaClient. getPrismaClient(config) sets process.env.DATABASE_URL = buildDatabaseUrl(config) before constructing the singleton PrismaClient; closePrismaClient() calls $disconnect().
src/jobs/jobRepository.js is rewritten onto Prisma's model API. Function names and signatures ((prisma, ...) as first argument) are unchanged, so call sites in src/app.js and src/worker.js only need the pool → prisma rename, not a logic change. The manual toCamelJob mapping is deleted: Prisma returns objects already shaped with the model's camelCase field names, with @map handling the snake_case column translation internally.
getJobByUuid, findPendingJobs, and findExpiredJobs must not expose errorLog (only getJobErrorLog does, matching today's explicit SELECT column lists). This is preserved with a shared select object:
const jobSelect = {
id: true, uuid: true, status: true, family: true, sourceFormat: true, targetFormat: true,
originalFilename: true, inputPath: true, outputPath: true, inputMimeType: true, outputMimeType: true,
inputSizeBytes: true, outputSizeBytes: true, quality: true, conversionDurationSeconds: true,
errorMessage: true, createdAt: true, updatedAt: true, expiresAt: true, cleanedAt: true,
};
Function mapping:
| Function | Prisma call |
|---|---|
createJob(prisma, job) |
prisma.conversionJob.create({ data: { uuid, family, sourceFormat, targetFormat, originalFilename, inputPath, inputMimeType, inputSizeBytes, expiresAt, quality: job.quality ?? null } }) |
getJobByUuid(prisma, uuid) |
prisma.conversionJob.findUnique({ where: { uuid }, select: jobSelect }) |
getJobErrorLog(prisma, id) |
prisma.conversionJob.findUnique({ where: { id }, select: { errorLog: true } }) → ?.errorLog ?? null |
markProcessing(prisma, id) |
prisma.conversionJob.update({ where: { id }, data: { status: 'processing' } }) |
markDone(prisma, id, {...}) |
update with status: 'done', outputPath, outputMimeType, outputSizeBytes, conversionDurationSeconds |
markFailed(prisma, id, {...}) |
update with status: 'failed', errorMessage, errorLog |
findPendingJobs(prisma, limit) |
prisma.conversionJob.findMany({ where: { status: 'pending' }, orderBy: { createdAt: 'asc' }, take: limit, select: jobSelect }) |
findExpiredJobs(prisma) |
prisma.conversionJob.findMany({ where: { expiresAt: { lt: new Date() }, cleanedAt: null }, select: jobSelect }) |
markCleaned(prisma, id) |
prisma.conversionJob.update({ where: { id }, data: { cleanedAt: new Date() } }) |
Behavior change to watch for
findExpiredJobs and markCleaned currently compare/write using MariaDB's NOW(), computed on the database server. Under Prisma they use new Date(), computed on the application server. This is the one real behavior change introduced by this migration. CLAUDE.md already documents two pre-existing test failures tied to a clock/timezone mismatch around expiresAt comparisons (test/cleanup.test.js, test/jobs/jobRepository.test.js), not yet root-caused. After implementation, verify no new failures appear beyond those two — this change is not expected to fix or worsen them, but it changes where the clock read happens, so it must be checked rather than assumed neutral.
Package changes
package.jsondependencies: removemariadb, add@prisma/client.package.jsondevDependencies: addprisma.package.jsonscripts: add"postinstall": "prisma generate"so the generated client exists afternpm installon fresh clones, CI, and deploy.db/schema.sqldeleted.
Test updates
All test files importing getPool/closePool switch to getPrismaClient/closePrismaClient, and pool.query('DELETE FROM conversion_jobs') in beforeEach blocks becomes prisma.conversionJob.deleteMany().
Two special cases:
test/db.test.js: the raw-connectivity checkpool.query('SELECT 1 AS value')becomesprisma.$queryRaw`SELECT 1 AS value`. The singleton-instance assertion (getPoolcalled twice returns the same object) carries over unchanged togetPrismaClient.test/api/jobStatus.test.js("returns a generic 500 without leaking internal error details"): currently monkey-patchespool.queryto reject, since that endpoint's code path (getJobByUuid) went through a single.querymethod. Under Prisma there is no single method to patch — the test instead monkey-patchesprisma.conversionJob.findUnique(the specific model methodgetJobByUuidcalls), saving and restoring the original function around the assertion, same pattern as today.
Verification
- Run the test suite with the local env vars documented in
CLAUDE.mdand confirm the only failures are the two pre-existing, already-documented ones (or fewer) — no new regressions. - Run
npm run buildto confirm the frontend build step is unaffected (it has no dependency on the DB layer). - Manually run the baseline steps (
migrate dev --create-only+migrate resolve --applied) against the local dev DB and confirmconversion_jobsand its existing rows are untouched.