feat(platform): add validated configuration and Prisma database foundation
This commit is contained in:
parent
cce4ccc794
commit
39290c4944
|
|
@ -0,0 +1,4 @@
|
|||
NODE_ENV=development
|
||||
PORT=3000
|
||||
DATABASE_URL=postgresql://mani:mani_local@localhost:5432/mani_candles
|
||||
CORS_ORIGINS=http://localhost:3001
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
pnpm-lock.yaml
|
||||
src/generated
|
||||
dist
|
||||
coverage
|
||||
node_modules
|
||||
|
|
@ -0,0 +1 @@
|
|||
{ "singleQuote": true, "trailingComma": "all" }
|
||||
|
|
@ -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 },
|
||||
},
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
|||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@prisma/engines': true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
unrs-resolver: true
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -0,0 +1 @@
|
|||
provider = "postgresql"
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
@Module({ providers: [DatabaseService], exports: [DatabaseService] })
|
||||
export class DatabaseModule {}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "rootDir": "src" },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["**/*.spec.ts"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue