From c6c0028135e7e28d7b27b793155aa839c72355df Mon Sep 17 00:00:00 2001 From: maotora Date: Thu, 19 Mar 2026 16:48:05 +0300 Subject: [PATCH 1/3] fix: use Prisma Accelerate in production --- .env.example | 1 + README.md | 7 ++- package.json | 1 + pnpm-lock.yaml | 13 ++++++ prisma.config.ts | 5 ++- scripts/migrate.ts | 7 ++- server.ts | 56 ++++++++++++++++++------ src/app.ts | 14 +++--- src/config.ts | 17 ++++++++ src/db/prisma.ts | 79 +++++++++++++++++++++++++++++++--- src/middleware/errorHandler.ts | 22 +++++++++- tests/locations-api.test.ts | 10 +++++ 12 files changed, 204 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 92c795e..5fe6381 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/locations_api" +DIRECT_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/locations_api" PORT="8080" PAGE_SIZE="10" diff --git a/README.md b/README.md index a666012..220bbb9 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,11 @@ Compatibility-first REST API for Tanzania location data backed by PostgreSQL and cp .env.example .env ``` -3. Start PostgreSQL and update `DATABASE_URL` if needed. +3. Start PostgreSQL and update your connection strings if needed. + + - Local and test environments use a direct PostgreSQL `DATABASE_URL`. + - Production uses a Prisma Accelerate `DATABASE_URL`. + - If you run `pnpm db:migrate` against an Accelerate-backed environment, also provide `DIRECT_DATABASE_URL` so the migration bootstrap can talk to Postgres directly. 4. Apply the checked-in schema and seed deterministic fixture data. @@ -66,6 +70,7 @@ pnpm openapi:json - On a fresh database it bootstraps the historical `init` migration, marks that baseline as applied, and then deploys later migrations - On an existing database that already has the older Prisma migration history, it only applies the new additive migrations - Prefer `pnpm db:migrate` over calling `prisma migrate deploy` directly +- `DATABASE_URL` may point at Prisma Accelerate in production, but `pnpm db:migrate` still requires a direct Postgres URL in `DIRECT_DATABASE_URL` ## Testing diff --git a/package.json b/package.json index f78259c..2eb519b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.5.0", "@prisma/client": "^7.5.0", + "@prisma/extension-accelerate": "^3.0.1", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ba510e..97fd3e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@prisma/client': specifier: ^7.5.0 version: 7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/extension-accelerate': + specifier: ^3.0.1 + version: 3.0.1(@prisma/client@7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)) cors: specifier: ^2.8.6 version: 2.8.6 @@ -703,6 +706,12 @@ packages: '@prisma/engines@7.5.0': resolution: {integrity: sha512-ondGRhzoaVpRWvFaQ5wH5zS1BIbhzbKqczKjCn6j3L0Zfe/LInjcEg8+xtB49AuZBX30qyx1ZtGoootUohz2pw==} + '@prisma/extension-accelerate@3.0.1': + resolution: {integrity: sha512-xc+kn4AjjTzS9jsdD1JWCebB09y0Aj+C8GjjG7oUm81PF9psvmJOw5rxpl7tOEBz/8hmuNX996XL28ys/OLxVA==} + engines: {node: '>=22'} + peerDependencies: + '@prisma/client': '>=4.16.1' + '@prisma/fetch-engine@7.5.0': resolution: {integrity: sha512-kZCl2FV54qnyrVdnII8MI6qvt7HfU6Cbiz8dZ8PXz4f4lbSw45jEB9/gEMK2SGdiNhBKyk/Wv95uthoLhGMLYA==} @@ -3515,6 +3524,10 @@ snapshots: '@prisma/fetch-engine': 7.5.0 '@prisma/get-platform': 7.5.0 + '@prisma/extension-accelerate@3.0.1(@prisma/client@7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))': + dependencies: + '@prisma/client': 7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + '@prisma/fetch-engine@7.5.0': dependencies: '@prisma/debug': 7.5.0 diff --git a/prisma.config.ts b/prisma.config.ts index e5e83d7..91c27ae 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ seed: 'tsx prisma/seed.ts', }, datasource: { - url: process.env.DATABASE_URL ?? 'postgresql://postgres:postgres@localhost:5432/locations_api', + url: + process.env.DIRECT_DATABASE_URL ?? + process.env.DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/locations_api', }, }); diff --git a/scripts/migrate.ts b/scripts/migrate.ts index a5a25c7..ce5db50 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -3,6 +3,11 @@ import { Pool } from 'pg'; import config from '../src/config.js'; const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const directDatabaseUrl = config.directDatabaseUrl; + +if (!directDatabaseUrl) { + throw new Error('db:migrate requires DIRECT_DATABASE_URL when DATABASE_URL uses Prisma Accelerate.'); +} function runPrisma(args: string[]) { const result = spawnSync( @@ -21,7 +26,7 @@ function runPrisma(args: string[]) { async function bootstrapIfNeeded() { const pool = new Pool({ - connectionString: config.databaseUrl, + connectionString: directDatabaseUrl, }); try { diff --git a/server.ts b/server.ts index 3c6e6e3..59f585b 100644 --- a/server.ts +++ b/server.ts @@ -1,22 +1,50 @@ import app from './src/app.js'; import config from './src/config.js'; -import { disconnectPrisma } from './src/db/prisma.js'; - -const server = app.listen(config.port, () => { - console.log( - JSON.stringify({ - environment: config.nodeEnv, - message: 'Server started', - openApiUrl: `http://localhost:${config.port}/openapi.json`, - port: config.port, - swaggerUrl: `http://localhost:${config.port}/api-docs`, - }), - ); -}); +import { checkDatabaseConnection, disconnectPrisma } from './src/db/prisma.js'; + +let server: ReturnType | undefined; + +async function startServer() { + const database = await checkDatabaseConnection(); + + if (!database.ok) { + console.error( + JSON.stringify({ + error: database.error, + message: 'Database readiness check failed. Refusing to start server.', + }), + ); + process.exit(1); + } + + server = app.listen(config.port, () => { + console.log( + JSON.stringify({ + environment: config.nodeEnv, + message: 'Server started', + openApiUrl: `http://localhost:${config.port}/openapi.json`, + port: config.port, + swaggerUrl: `http://localhost:${config.port}/api-docs`, + }), + ); + }); +} async function shutdown(signal: NodeJS.Signals) { console.log(JSON.stringify({ message: 'Graceful shutdown requested', signal })); + if (!server) { + void disconnectPrisma() + .then(() => { + process.exit(0); + }) + .catch((error: unknown) => { + console.error(JSON.stringify({ error, message: 'Failed to disconnect Prisma cleanly' })); + process.exit(1); + }); + return; + } + server.close(() => { void disconnectPrisma() .then(() => { @@ -36,3 +64,5 @@ process.on('SIGINT', () => { process.on('SIGTERM', () => { void shutdown('SIGTERM'); }); + +await startServer(); diff --git a/src/app.ts b/src/app.ts index e61cf92..1f64960 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,7 @@ import helmet from 'helmet'; import morgan from 'morgan'; import type { Request, Response } from 'express'; import config from './config.js'; +import { checkDatabaseConnection } from './db/prisma.js'; import { setupSwagger } from './docs/swagger.js'; import { errorHandler } from './middleware/errorHandler.js'; import { @@ -36,12 +37,15 @@ app.use(morgan(logFormatter)); app.use(express.json()); app.use(express.urlencoded({ extended: true })); -app.get('/health', (_: Request, res: Response) => { - res.status(200).json({ - status: 'UP', - timestamp: new Date().toISOString(), +app.get('/health', async (_: Request, res: Response) => { + const database = await checkDatabaseConnection({ logErrors: false }); + + res.status(database.ok ? 200 : 503).json({ + database: database.ok ? 'UP' : 'DOWN', environment: config.nodeEnv, - version: process.env.npm_package_version || '1.0.0' + status: database.ok ? 'UP' : 'DEGRADED', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0', }); }); diff --git a/src/config.ts b/src/config.ts index 3d4abe1..7166270 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,20 +3,37 @@ import { z } from 'zod'; dotenv.config(); +function isAccelerateUrl(url: string) { + return url.startsWith('prisma://') || url.startsWith('prisma+postgres://'); +} + const envSchema = z.object({ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'), + DIRECT_DATABASE_URL: z.string().min(1, 'DIRECT_DATABASE_URL cannot be empty').optional(), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PAGE_SIZE: z.coerce.number().int().positive().max(100).default(10), PORT: z.coerce.number().int().positive().default(8080), }); const env = envSchema.parse(process.env); +const usesAccelerate = isAccelerateUrl(env.DATABASE_URL); +const directDatabaseUrl = env.DIRECT_DATABASE_URL ?? (usesAccelerate ? undefined : env.DATABASE_URL); + +if (env.NODE_ENV === 'production' && !usesAccelerate) { + throw new Error('Production requires DATABASE_URL to be a Prisma Accelerate URL.'); +} + +if (env.NODE_ENV !== 'production' && !directDatabaseUrl) { + throw new Error('Non-production requires a direct PostgreSQL URL via DIRECT_DATABASE_URL or DATABASE_URL.'); +} const config = { databaseUrl: env.DATABASE_URL, + directDatabaseUrl, nodeEnv: env.NODE_ENV, pageSize: env.PAGE_SIZE, port: env.PORT, + usesAccelerate, }; export default config; diff --git a/src/db/prisma.ts b/src/db/prisma.ts index 5ca1c0f..f9a1a16 100644 --- a/src/db/prisma.ts +++ b/src/db/prisma.ts @@ -1,4 +1,5 @@ import { PrismaPg } from '@prisma/adapter-pg'; +import { withAccelerate } from '@prisma/extension-accelerate'; import { Pool } from 'pg'; import { PrismaClient } from '../generated/prisma/client.js'; import config from '../config.js'; @@ -11,12 +12,48 @@ const globalForPrisma = globalThis as typeof globalThis & { let pool = globalForPrisma.pgPool; let prismaClient = globalForPrisma.prismaClient; +function databaseHost() { + try { + return new URL(config.usesAccelerate ? config.databaseUrl : (config.directDatabaseUrl ?? config.databaseUrl)).hostname; + } catch { + return 'unknown'; + } +} + +function serializeError(error: unknown) { + if (error instanceof Error) { + const errorWithCode = error as Error & { code?: string }; + + return { + code: errorWithCode.code, + message: error.message, + name: error.name, + stack: error.stack, + }; + } + + return { + message: String(error), + name: 'UnknownError', + }; +} + function createPool() { + if (!config.directDatabaseUrl) { + throw new Error('DIRECT_DATABASE_URL is required for direct PostgreSQL connections.'); + } + return new Pool({ - connectionString: config.databaseUrl, + connectionString: config.directDatabaseUrl, }); } +function createAcceleratedPrismaClient() { + return new PrismaClient({ + accelerateUrl: config.databaseUrl, + }).$extends(withAccelerate()) as unknown as PrismaClient; +} + function createPrismaClient(nextPool: Pool) { return new PrismaClient({ adapter: new PrismaPg(nextPool as unknown as ConstructorParameters[0]), @@ -31,12 +68,17 @@ function cacheInstances() { } function ensurePrismaClient(): PrismaClient { - if (!pool) { - pool = createPool(); - } - if (!prismaClient) { - prismaClient = createPrismaClient(pool); + if (config.usesAccelerate) { + prismaClient = createAcceleratedPrismaClient(); + } else { + if (!pool) { + pool = createPool(); + } + + prismaClient = createPrismaClient(pool); + } + cacheInstances(); } @@ -56,6 +98,31 @@ if (pool && prismaClient && config.nodeEnv !== 'production') { cacheInstances(); } +export async function checkDatabaseConnection(options: { logErrors?: boolean } = {}) { + const { logErrors = true } = options; + + try { + await ensurePrismaClient().$queryRawUnsafe('SELECT 1'); + return { ok: true } as const; + } catch (error) { + if (logErrors) { + console.error( + JSON.stringify({ + databaseHost: databaseHost(), + error: serializeError(error), + level: 'error', + message: 'Database connectivity check failed', + }), + ); + } + + return { + error: serializeError(error), + ok: false, + } as const; + } +} + export async function disconnectPrisma() { if (prismaClient) { await prismaClient.$disconnect(); diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index abc878d..a287aec 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,4 +1,4 @@ -import type { Request, Response } from 'express'; +import type { NextFunction, Request, Response } from 'express'; import { ZodError } from 'zod'; import type { ErrorResponse } from '../types.js'; @@ -18,6 +18,7 @@ export const errorHandler = ( err: Error | ApiError | ZodError, req: Request, res: Response, + _: NextFunction, ) => { console.error( JSON.stringify({ @@ -31,6 +32,16 @@ export const errorHandler = ( let statusCode = 500; let message = 'Something went wrong'; + const errorWithCode = err as Error & { code?: string }; + const databaseUnavailableCodes = new Set(['P1000', 'P1001', 'P1002', 'P1017']); + const databaseUnavailablePatterns = [ + /Unable to connect to the Accelerate API/i, + /Connection terminated due to connection timeout/i, + /connect ECONN/i, + /ECONNREFUSED/i, + /ENOTFOUND/i, + /timeout/i, + ]; if (err instanceof ApiError) { statusCode = err.statusCode; @@ -47,6 +58,15 @@ export const errorHandler = ( message = 'Requested resource not found'; } + if ( + errorWithCode.name === 'PrismaClientInitializationError' || + databaseUnavailableCodes.has(errorWithCode.code ?? '') || + databaseUnavailablePatterns.some((pattern) => pattern.test(err.message)) + ) { + statusCode = 503; + message = 'Database unavailable'; + } + if (err instanceof SyntaxError || err instanceof TypeError) { statusCode = 400; message = 'Invalid request data'; diff --git a/tests/locations-api.test.ts b/tests/locations-api.test.ts index 36554d7..77a0cc6 100644 --- a/tests/locations-api.test.ts +++ b/tests/locations-api.test.ts @@ -81,6 +81,16 @@ describe.each(['/v1', '/api'])('Tanzania Locations API (%s)', (basePath) => { }); describe('Shared API behavior', () => { + it('reports database readiness on the health endpoint', async () => { + const res = await request(app).get('/health'); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ + database: 'UP', + status: 'UP', + }); + }); + it('keeps the /api alias active', async () => { const res = await request(app).get('/api/countries'); From 9f24e30fa45d56c673c9295647e958e96caa8403 Mon Sep 17 00:00:00 2001 From: maotora Date: Thu, 19 Mar 2026 16:54:40 +0300 Subject: [PATCH 2/3] chore: align Node support with Accelerate --- .github/workflows/ci.yml | 3 +-- README.md | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16fc097..db7ad94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,7 @@ jobs: fail-fast: false matrix: node-version: - - '20.19.0' - - '22' + - '22.13.0' services: postgres: image: postgres:16 diff --git a/README.md b/README.md index 220bbb9..9c63225 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Compatibility-first REST API for Tanzania location data backed by PostgreSQL and ## Requirements -- Node.js `>=20.19.0` +- Node.js `22.13.0+` - pnpm `10.7.0+` - PostgreSQL `16+` recommended @@ -151,7 +151,7 @@ Additional filters: ## Dependency Automation - `.github/dependabot.yml` opens weekly update PRs for npm packages and GitHub Actions -- `.github/workflows/ci.yml` validates every PR against Postgres on Node `20.19.0` and `22` +- `.github/workflows/ci.yml` validates every PR against Postgres on Node `22.13.0` ## License diff --git a/package.json b/package.json index 2eb519b..7d208d7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "./dist/server.js", "engines": { - "node": ">=20.19.0" + "node": "^22.13.0 || >=24.0.0" }, "scripts": { "dev": "tsx watch server.ts", From 2b39218eb79813609c71670bf7ad2c4df6f9032f Mon Sep 17 00:00:00 2001 From: maotora Date: Thu, 19 Mar 2026 17:01:02 +0300 Subject: [PATCH 3/3] fix: unblock CI lint and migration flow --- .../20250411175910_cleanup/migration.sql | 20 +++++++++---------- src/middleware/errorHandler.ts | 4 +++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/prisma/migrations/20250411175910_cleanup/migration.sql b/prisma/migrations/20250411175910_cleanup/migration.sql index 7841308..377c166 100644 --- a/prisma/migrations/20250411175910_cleanup/migration.sql +++ b/prisma/migrations/20250411175910_cleanup/migration.sql @@ -14,19 +14,19 @@ */ -- AlterTable -ALTER TABLE "districts" DROP COLUMN "properties_count", -DROP COLUMN "view_count", -DROP COLUMN "watcher_count"; +ALTER TABLE "districts" DROP COLUMN IF EXISTS "properties_count", +DROP COLUMN IF EXISTS "view_count", +DROP COLUMN IF EXISTS "watcher_count"; -- AlterTable -ALTER TABLE "places" DROP COLUMN "properties_count", -DROP COLUMN "view_count"; +ALTER TABLE "places" DROP COLUMN IF EXISTS "properties_count", +DROP COLUMN IF EXISTS "view_count"; -- AlterTable -ALTER TABLE "regions" DROP COLUMN "properties_count", -DROP COLUMN "view_count", -DROP COLUMN "watcher_count"; +ALTER TABLE "regions" DROP COLUMN IF EXISTS "properties_count", +DROP COLUMN IF EXISTS "view_count", +DROP COLUMN IF EXISTS "watcher_count"; -- AlterTable -ALTER TABLE "wards" DROP COLUMN "properties_count", -DROP COLUMN "view_count"; +ALTER TABLE "wards" DROP COLUMN IF EXISTS "properties_count", +DROP COLUMN IF EXISTS "view_count"; diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index a287aec..3e1d727 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -18,8 +18,10 @@ export const errorHandler = ( err: Error | ApiError | ZodError, req: Request, res: Response, - _: NextFunction, + next: NextFunction, ) => { + void next; + console.error( JSON.stringify({ level: 'error',