feat(platform): add validated configuration and Prisma database foundation

This commit is contained in:
mihir 2026-09-08 20:33:58 +05:30
parent cce4ccc794
commit 39290c4944
20 changed files with 5982 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://mani:mani_local@localhost:5432/mani_candles
CORS_ORIGINS=http://localhost:3001

5
.prettierignore Normal file
View File

@ -0,0 +1,5 @@
pnpm-lock.yaml
src/generated
dist
coverage
node_modules

1
.prettierrc.json Normal file
View File

@ -0,0 +1 @@
{ "singleQuote": true, "trailingComma": "all" }

15
jest.config.cjs Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
preset: 'ts-jest',
moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' },
testEnvironment: 'node',
testMatch: ['**/*.spec.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/generated/**',
'!src/main.ts',
'!src/**/*.module.ts',
],
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
},
};

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "@mani-candles/backend",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@11.19.0",
"engines": {
"node": ">=24.15 <25"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"dev": "tsc -p tsconfig.build.json --watch",
"test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:validate": "prisma validate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"start:watch": "node --watch dist/main.js",
"db:status": "prisma migrate status"
},
"dependencies": {
"@nestjs/common": "^11.1.0",
"@nestjs/core": "^11.1.0",
"@nestjs/platform-express": "^11.1.0",
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"dotenv": "^17.0.0",
"helmet": "^8.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"zod": "^4.0.0"
},
"devDependencies": {
"@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^6.0.0",
"jest": "^30.0.0",
"prettier": "^3.0.0",
"prisma": "^7.0.0",
"supertest": "^7.0.0",
"ts-jest": "^29.4.0",
"typescript": "~5.9.0"
}
}

5635
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,6 @@
allowBuilds:
'@parcel/watcher': true
'@prisma/engines': true
esbuild: true
prisma: true
unrs-resolver: true

8
prisma.config.ts Normal file
View File

@ -0,0 +1,8 @@
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: process.env.DATABASE_URL },
});

View File

@ -0,0 +1,7 @@
CREATE TABLE "organizations" (
"id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);

View File

@ -0,0 +1 @@
provider = "postgresql"

19
prisma/schema.prisma Normal file
View File

@ -0,0 +1,19 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
// The owning business. Supplier/customer organizations belong to later modules.
model Organization {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(160)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@map("organizations")
}

View File

@ -0,0 +1,28 @@
import { spawnSync } from 'node:child_process';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required');
const commands = [
['prisma', 'migrate', 'deploy'],
['prisma', 'migrate', 'status'],
[
'prisma',
'migrate',
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'--exit-code',
],
];
for (const args of commands) {
const result = spawnSync(
process.execPath,
['node_modules/prisma/build/index.js', ...args.slice(1)],
{
stdio: 'inherit',
env: process.env,
},
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}

View File

@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { parseEnvironment } from './environment';
export const ENVIRONMENT = Symbol('ENVIRONMENT');
@Global()
@Module({
providers: [
{ provide: ENVIRONMENT, useFactory: () => parseEnvironment(process.env) },
],
exports: [ENVIRONMENT],
})
export class EnvironmentModule {}

42
src/config/environment.ts Normal file
View File

@ -0,0 +1,42 @@
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
CORS_ORIGINS: z
.string()
.default('')
.transform((value) =>
value
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
)
.pipe(
z.array(
z.url().refine((value) => {
if (!URL.canParse(value)) return false;
const url = new URL(value);
return (
['http:', 'https:'].includes(url.protocol) && url.origin === value
);
}),
),
),
});
export type Environment = z.infer<typeof schema>;
export function parseEnvironment(input: NodeJS.ProcessEnv): Environment {
const result = schema.safeParse(input);
if (!result.success) {
const fields = [
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
];
throw new Error(`Invalid environment configuration: ${fields.join(', ')}`);
}
return result.data;
}

View File

@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
@Module({ providers: [DatabaseService], exports: [DatabaseService] })
export class DatabaseModule {}

View File

@ -0,0 +1,37 @@
import {
Inject,
Injectable,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';
import { ENVIRONMENT } from '../config/environment.module';
import type { Environment } from '../config/environment';
@Injectable()
export class DatabaseService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor(@Inject(ENVIRONMENT) environment: Environment) {
super({
adapter: new PrismaPg({
connectionString: environment.DATABASE_URL,
connectionTimeoutMillis: 3000,
query_timeout: 3000,
max: 10,
}),
});
}
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
async ping(): Promise<void> {
await this.$queryRaw`SELECT 1`;
}
}

37
test/database.spec.ts Normal file
View File

@ -0,0 +1,37 @@
import 'reflect-metadata';
import { DatabaseService } from '../src/database/database.service';
import { parseEnvironment } from '../src/config/environment';
describe('database lifecycle', () => {
let database: DatabaseService;
beforeEach(() => {
database = new DatabaseService(
parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }),
);
});
it('connects during initialization', async () => {
const connect = jest.spyOn(database, '$connect').mockResolvedValue();
await database.onModuleInit();
expect(connect).toHaveBeenCalledTimes(1);
});
it('disconnects during shutdown', async () => {
const disconnect = jest.spyOn(database, '$disconnect').mockResolvedValue();
await database.onModuleDestroy();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('propagates connection failures', async () => {
jest
.spyOn(database, '$connect')
.mockRejectedValue(new Error('unavailable'));
await expect(database.onModuleInit()).rejects.toThrow('unavailable');
});
it('executes a database probe and propagates query failure', async () => {
const query = jest
.spyOn(database, '$queryRaw')
.mockResolvedValue([{ value: 1 }]);
await database.ping();
expect(query).toHaveBeenCalledTimes(1);
query.mockRejectedValue(new Error('timeout'));
await expect(database.ping()).rejects.toThrow('timeout');
});
});

46
test/environment.spec.ts Normal file
View File

@ -0,0 +1,46 @@
import { parseEnvironment } from '../src/config/environment';
const valid = { DATABASE_URL: 'postgresql://user:secret@localhost:5432/mani' };
describe('environment configuration', () => {
it('applies defaults and parses explicit configuration', () => {
expect(parseEnvironment(valid)).toMatchObject({
PORT: 3000,
CORS_ORIGINS: [],
});
expect(
parseEnvironment({
...valid,
PORT: '4000',
CORS_ORIGINS: ' https://shop.example.com ',
}),
).toMatchObject({ PORT: 4000, CORS_ORIGINS: ['https://shop.example.com'] });
});
it.each([
{ DATABASE_URL: undefined },
{ DATABASE_URL: 'https://example.com' },
{ PORT: '0' },
{ PORT: '65536' },
{ PORT: '1.5' },
{ PORT: 'invalid' },
{ NODE_ENV: 'staging' },
{ CORS_ORIGINS: '*' },
{ CORS_ORIGINS: 'https://shop.example.com/path' },
])('rejects invalid configuration %j', (override) => {
expect(() => parseEnvironment({ ...valid, ...override })).toThrow(
'Invalid environment',
);
});
it('does not leak credentials in validation errors', () => {
expect(() => parseEnvironment({ DATABASE_URL: 'secret-password' })).toThrow(
'DATABASE_URL',
);
try {
parseEnvironment({ DATABASE_URL: 'secret-password' });
} catch (error) {
expect(String(error)).not.toContain('secret-password');
}
});
});

6
tsconfig.build.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "rootDir": "src" },
"include": ["src/**/*.ts"],
"exclude": ["**/*.spec.ts"]
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"sourceMap": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "test/**/*.ts", "prisma.config.ts"]
}