Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ jobs:
fail-fast: false
matrix:
node-version:
- '20.19.0'
- '22'
- '22.13.0'
services:
postgres:
image: postgres:16
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -146,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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion prisma.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
});
20 changes: 10 additions & 10 deletions prisma/migrations/20250411175910_cleanup/migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 6 additions & 1 deletion scripts/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -21,7 +26,7 @@ function runPrisma(args: string[]) {

async function bootstrapIfNeeded() {
const pool = new Pool({
connectionString: config.databaseUrl,
connectionString: directDatabaseUrl,
});

try {
Expand Down
56 changes: 43 additions & 13 deletions server.ts
Original file line number Diff line number Diff line change
@@ -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<typeof app.listen> | 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(() => {
Expand All @@ -36,3 +64,5 @@ process.on('SIGINT', () => {
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});

await startServer();
14 changes: 9 additions & 5 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
});
});

Expand Down
17 changes: 17 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading