Skip to content
Open
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
19 changes: 9 additions & 10 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RewardsModule } from './rewards/rewards.module';
import blockchainConfig from './config/blockchain.config';
import sybilConfig from './config/sybil.config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DatabaseModule } from './database/database.module';
import { BlockchainModule } from './blockchain/blockchain.module';
import { DisputeModule } from './dispute/dispute.module';
import { IdentityModule } from './identity/identity.module';
Expand Down Expand Up @@ -257,16 +258,14 @@ async function createThrottlerStorage(
envFilePath: ['.env.local', '.env'],
}),
ScheduleModule.forRoot(),
TypeOrmModule.forRoot({
type: 'sqlite',
database: 'database.sqlite',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
// Allow automatic sync in development unless explicitly disabled
synchronize:
process.env.DATABASE_SYNCHRONIZE === 'true' ||
process.env.NODE_ENV !== 'production',
logging: process.env.DATABASE_LOGGING === 'true',
}),
// PostgreSQL Database Infrastructure (Issue #269)
// DatabaseModule provides:
// - PostgreSQL connectivity with connection pooling
// - Transaction management via TransactionRunner
// - Health reporting via DatabaseService
// - Repository base class for all domain repositories
// Falls back to SQLite when DATABASE_URL is not set (development).
DatabaseModule,
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand Down
74 changes: 63 additions & 11 deletions src/config/data-source.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,68 @@
import { DataSource } from 'typeorm';
import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv';

config();

export const dataSource = new DataSource({
type: 'sqlite',
database: 'database.sqlite',
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
// In development, allow automatic schema sync so missing tables are created.
// Disable in production by setting NODE_ENV=production.
synchronize: process.env.NODE_ENV !== 'production',
});
/**
* TypeORM DataSource configuration for TruthBounty V2.
*
* Supports both PostgreSQL (production/staging) and SQLite (development).
* The datasource type is driven by `DATABASE_URL` (PostgreSQL) or falls back
* to SQLite when no PostgreSQL URL is configured.
*
* ## Usage
*
* ```
* # PostgreSQL (production)
* DATABASE_URL=postgresql://user:pass@host:5432/truthbounty?sslmode=require
*
* # SQLite (local development — automatic fallback)
* # No env vars needed
* ```
*
* ## Migration commands
*
* ```bash
* npm run migration:generate # Generate a new migration
* npm run migration:run # Apply pending migrations
* npm run migration:revert # Rollback last migration
* ```
*/
function buildOptions(): DataSourceOptions {
const databaseUrl = process.env.DATABASE_URL;

export default dataSource;
if (databaseUrl) {
// PostgreSQL via DATABASE_URL
return {
type: 'postgres',
url: databaseUrl,
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
synchronize: false, // NEVER synchronize via CLI — use migrations
logging: process.env.DATABASE_LOGGING === 'true',
ssl:
process.env.DB_SSL === 'true'
? { rejectUnauthorized: false }
: false,
extra: {
max: parseInt(process.env.DB_POOL_MAX ?? '20', 10),
idleTimeoutMillis: parseInt(process.env.DB_POOL_IDLE_TIMEOUT ?? '30000', 10),
connectionTimeoutMillis: parseInt(process.env.DB_POOL_ACQUIRE_TIMEOUT ?? '60000', 10),
},
};
}

// Fallback to SQLite (development)
return {
type: 'sqlite',
database: process.env.SQLITE_PATH ?? 'database.sqlite',
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
synchronize: process.env.NODE_ENV !== 'production',
logging: process.env.DATABASE_LOGGING === 'true',
};
}

export const dataSource = new DataSource(buildOptions());

export default dataSource;
105 changes: 105 additions & 0 deletions src/database/base.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Repository, EntityManager, FindOptionsWhere, FindManyOptions } from 'typeorm';

/**
* BaseRepository — generic repository base class providing common CRUD
* operations and transaction-scoped repository access.
*
* All domain repositories (UserRepository, BountyRepository, etc.) should
* extend this class to inherit consistent data-access patterns.
*
* ## Usage
*
* ```ts
* @Injectable()
* class UserRepository extends BaseRepository<UserEntity> {
* constructor(
* @InjectDataSource() dataSource: DataSource,
* ) {
* super(dataSource, UserEntity);
* }
*
* async findByEmail(email: string): Promise<UserEntity | null> {
* return this.findOne({ where: { email } });
* }
* }
* ```
*
* ## Transaction support
*
* Use `withManager(manager)` to obtain a repository instance bound to
* a specific {@link EntityManager} (e.g., inside a {@link TransactionRunner}
* callback). All operations on the returned proxy are scoped to the
* transaction.
*
* ```ts
* await tx.run(async (manager) => {
* const repo = userRepo.withManager(manager);
* await repo.update(userId, { status: 'active' });
* });
* ```
*/
export class BaseRepository<T extends object> {
protected readonly repo: Repository<T>;

constructor(
private readonly dataSourceOrManager: { getRepository: (target: new () => T) => Repository<T> },
private readonly entityClass: new () => T,
) {
this.repo = dataSourceOrManager.getRepository(entityClass);
}

/**
* Returns a repository instance bound to the given {@link EntityManager}.
* Use this inside transactions to ensure all queries participate in the
* same atomic boundary.
*/
withManager(manager: EntityManager): this {
const Ctor = this.constructor as new (...args: any[]) => this;
return new Ctor(manager, this.entityClass);
}

// ── Read ──────────────────────────────────────────────────────────

async findAll(options?: FindManyOptions<T>): Promise<T[]> {
return this.repo.find(options);
}

async findById(id: string | number): Promise<T | null> {
return this.repo.findOneBy({ id } as unknown as FindOptionsWhere<T>);
}

async findOne(where: FindOptionsWhere<T>): Promise<T | null> {
return this.repo.findOneBy(where);
}

async findMany(where: FindOptionsWhere<T>): Promise<T[]> {
return this.repo.findBy(where);
}

async count(where?: FindOptionsWhere<T>): Promise<number> {
return this.repo.countBy(where ?? ({} as FindOptionsWhere<T>));
}

// ── Write ─────────────────────────────────────────────────────────

async create(entity: T): Promise<T> {
return this.repo.save(entity);
}

async createMany(entities: T[]): Promise<T[]> {
return this.repo.save(entities);
}

async update(
id: string | number,
partial: Partial<T>,
): Promise<T | null> {
await this.repo.update(id as any, partial as any);
return this.findById(id);
}

async delete(id: string | number): Promise<boolean> {
const result = await this.repo.delete(id as any);
return (result.affected ?? 0) > 0;
}
}
143 changes: 143 additions & 0 deletions src/database/database.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Module, Global, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule, InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { DatabaseService } from './database.service';
import { TransactionRunner } from './transaction.runner';

/**
* DatabaseModule — PostgreSQL infrastructure for TruthBounty V2.
*
* Replaces the inline SQLite configuration in AppModule with a proper,
* environment-driven PostgreSQL setup supporting:
*
* - PostgreSQL connectivity via `DATABASE_URL` or individual env vars
* - Connection pooling (max connections, idle timeout, acquire timeout)
* - SSL support for production
* - Migration execution and rollback
* - Repository injection pattern
* - Transaction management via {@link TransactionRunner}
* - Health reporting via {@link DatabaseService}
*
* ## Architecture
*
* This module is **global** — every backend service can inject repositories
* and the transaction runner without importing DatabaseModule explicitly.
* The TypeORM DataSource is the single source of database connectivity.
*
* ## Environment variables
*
* | Variable | Default | Description |
* |----------|---------|-------------|
* | `DATABASE_URL` | — | Full PostgreSQL connection string (takes precedence) |
* | `DB_HOST` | `localhost` | Database host |
* | `DB_PORT` | `5432` | Database port |
* | `DB_USERNAME` | `postgres` | Database user |
* | `DB_PASSWORD` | — | Database password |
* | `DB_DATABASE` | `truthbounty` | Database name |
* | `DB_SSL` | `false` | Enable SSL (set to `true` in production) |
* | `DB_POOL_MAX` | `20` | Maximum pool connections |
* | `DB_POOL_IDLE_TIMEOUT` | `30000` | Idle connection timeout (ms) |
* | `DB_POOL_ACQUIRE_TIMEOUT` | `60000` | Connection acquisition timeout (ms) |
* | `DATABASE_SYNCHRONIZE` | `false` | Auto-sync schema (NEVER enable in production) |
* | `DATABASE_LOGGING` | `false` | Enable query logging |
*/
@Global()
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const logger = new Logger('DatabaseModule');

const databaseUrl = configService.get<string>('DATABASE_URL');

if (databaseUrl) {
logger.log('Connecting to PostgreSQL via DATABASE_URL');
return {
type: 'postgres',
url: databaseUrl,
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
// NEVER synchronize in production — data loss risk
synchronize:
configService.get<string>('NODE_ENV') !== 'production' &&
configService.get<string>('DATABASE_SYNCHRONIZE') === 'true',
logging: configService.get<string>('DATABASE_LOGGING') === 'true',
ssl: configService.get<string>('DB_SSL') === 'true'
? { rejectUnauthorized: false }
: false,
extra: {
max: configService.get<number>('DB_POOL_MAX', 20),
idleTimeoutMillis: configService.get<number>('DB_POOL_IDLE_TIMEOUT', 30000),
connectionTimeoutMillis: configService.get<number>('DB_POOL_ACQUIRE_TIMEOUT', 60000),
},
};
}

// Fallback: individual connection parameters
const host = configService.get<string>('DB_HOST', 'localhost');
const port = configService.get<number>('DB_PORT', 5432);
const username = configService.get<string>('DB_USERNAME', 'postgres');
const password = configService.get<string>('DB_PASSWORD', '');
const database = configService.get<string>('DB_DATABASE', 'truthbounty');
const ssl = configService.get<string>('DB_SSL') === 'true';

logger.log(`Connecting to PostgreSQL at ${host}:${port}/${database}`);

return {
type: 'postgres',
host,
port,
username,
password,
database,
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
synchronize:
configService.get<string>('NODE_ENV') !== 'production' &&
configService.get<string>('DATABASE_SYNCHRONIZE') === 'true',
logging: configService.get<string>('DATABASE_LOGGING') === 'true',
ssl: ssl ? { rejectUnauthorized: false } : false,
extra: {
max: configService.get<number>('DB_POOL_MAX', 20),
idleTimeoutMillis: configService.get<number>('DB_POOL_IDLE_TIMEOUT', 30000),
connectionTimeoutMillis: configService.get<number>('DB_POOL_ACQUIRE_TIMEOUT', 60000),
},
};
},
}),
],
providers: [DatabaseService, TransactionRunner],
exports: [DatabaseService, TransactionRunner, TypeOrmModule],
})
export class DatabaseModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(DatabaseModule.name);

constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
) {}

async onModuleInit(): Promise<void> {
if (this.dataSource.isInitialized) {
this.logger.log('PostgreSQL connection pool established');
return;
}
try {
await this.dataSource.initialize();
this.logger.log('PostgreSQL connection pool initialized successfully');
} catch (error) {
this.logger.error('Failed to initialize PostgreSQL connection', error);
throw error;
}
}

async onModuleDestroy(): Promise<void> {
if (this.dataSource.isInitialized) {
await this.dataSource.destroy();
this.logger.log('PostgreSQL connection pool closed');
}
}
}
Loading