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
47 changes: 35 additions & 12 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ class ThrottlerMemoryStorage {
private readonly logger = new Logger('ThrottlerMemoryStorage');

constructor() {
this.logger.log('Using in-memory storage for rate limiting (development mode)');
this.logger.log(
'Using in-memory storage for rate limiting (development mode)',
);
}

async increment(
Expand All @@ -58,7 +60,12 @@ class ThrottlerMemoryStorage {
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<{ totalHits: number; timeToExpire: number; isBlocked: boolean; timeToBlockExpire: number }> {
): Promise<{
totalHits: number;
timeToExpire: number;
isBlocked: boolean;
timeToBlockExpire: number;
}> {
const now = Date.now();
const record = this.storage.get(key);

Expand Down Expand Up @@ -113,7 +120,9 @@ class ThrottlerMemoryStorage {
totalHits: record.totalHits,
timeToExpire: Math.max(record.expiresAt - now, 0),
isBlocked: record.isBlocked,
timeToBlockExpire: record.isBlocked ? Math.max(record.blockExpiresAt - now, 0) : 0,
timeToBlockExpire: record.isBlocked
? Math.max(record.blockExpiresAt - now, 0)
: 0,
};
}
}
Expand All @@ -134,7 +143,12 @@ class ThrottlerRedisStorage {
limit: number,
blockDuration: number,
throttlerName: string,
): Promise<{ totalHits: number; timeToExpire: number; isBlocked: boolean; timeToBlockExpire: number }> {
): Promise<{
totalHits: number;
timeToExpire: number;
isBlocked: boolean;
timeToBlockExpire: number;
}> {
const blockKey = `${key}:blocked`;
const [blocked, blockTimeToExpire] = await Promise.all([
this.redis.exists(blockKey),
Expand All @@ -144,7 +158,9 @@ class ThrottlerRedisStorage {
if (blocked) {
const timeToExpire = await this.redis.pttl(key);
return {
totalHits: await this.redis.get(key).then((value: string | null) => Number(value) || limit + 1),
totalHits: await this.redis
.get(key)
.then((value: string | null) => Number(value) || limit + 1),
timeToExpire: timeToExpire > 0 ? timeToExpire : ttl,
isBlocked: true,
timeToBlockExpire: blockTimeToExpire > 0 ? blockTimeToExpire : 0,
Expand Down Expand Up @@ -185,13 +201,18 @@ class ThrottlerRedisStorage {
}

// Factory to create appropriate storage based on environment
async function createThrottlerStorage(configService: ConfigService): Promise<any> {
async function createThrottlerStorage(
configService: ConfigService,
): Promise<any> {
const useRedis = configService.get<string>('REDIS_HOST');

if (useRedis) {
try {
const Redis = (await import('ioredis')).default;
const redisHost = configService.get<string>('throttler.redis.host', 'localhost');
const redisHost = configService.get<string>(
'throttler.redis.host',
'localhost',
);
const redisPort = configService.get<number>('throttler.redis.port', 6379);

const redis = new Redis({
Expand All @@ -212,15 +233,16 @@ async function createThrottlerStorage(configService: ConfigService): Promise<any
return new ThrottlerRedisStorage(redis);
} catch (error) {
const logger = new Logger('ThrottlerModule');
logger.warn(`Redis connection failed, falling back to memory storage: ${error}`);
logger.warn(
`Redis connection failed, falling back to memory storage: ${error}`,
);
return new ThrottlerMemoryStorage();
}
}

return new ThrottlerMemoryStorage();
}


@Module({
imports: [
ConfigModule.forRoot({
Expand All @@ -234,7 +256,9 @@ async function createThrottlerStorage(configService: ConfigService): Promise<any
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',
synchronize:
process.env.DATABASE_SYNCHRONIZE === 'true' ||
process.env.NODE_ENV !== 'production',
logging: process.env.DATABASE_LOGGING === 'true',
}),
ThrottlerModule.forRootAsync({
Expand Down Expand Up @@ -309,5 +333,4 @@ async function createThrottlerStorage(configService: ConfigService): Promise<any
},
],
})
export class AppModule { }

export class AppModule {}
148 changes: 148 additions & 0 deletions src/feature-flags/configuration.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RedisService } from '../redis/redis.service';
import { ConfigurationValue } from './entities/configuration-value.entity';
import { ConfigurationHistoryEntry } from './feature-flags.types';

const CACHE_TTL_SECONDS = 60;

@Injectable()
export class ConfigurationService {
private readonly logger = new Logger(ConfigurationService.name);

constructor(
@InjectRepository(ConfigurationValue)
private readonly configRepo: Repository<ConfigurationValue>,
private readonly redisService: RedisService,
) {}

async get<T = unknown>(key: string, environment?: string): Promise<T | null> {
const env = environment ?? this.getDefaultEnvironment();
const cached = await this.redisService.get(this.cacheKey(key, env));
if (cached) {
try {
return JSON.parse(cached) as T;
} catch {
// fall through to DB
}
}

const record = await this.configRepo.findOne({
where: { key, environment: env },
});
if (!record) return null;

await this.redisService.set(
this.cacheKey(key, env),
JSON.stringify(record.value),
CACHE_TTL_SECONDS,
);
return record.value as T;
}

async getRequired<T = unknown>(
key: string,
environment?: string,
): Promise<T> {
const value = await this.get<T>(key, environment);
if (value === null) {
throw new NotFoundException(`Configuration ${key} not found`);
}
return value;
}

async set<T = unknown>(
key: string,
value: T,
environment?: string,
createdBy?: string,
changeReason?: string,
): Promise<ConfigurationValue> {
const env = environment ?? this.getDefaultEnvironment();
const existing = await this.configRepo.findOne({
where: { key, environment: env },
});

if (existing) {
const nextVersion = existing.version + 1;
await this.configRepo.update(existing.id, {
value: value as unknown,
version: nextVersion,
createdBy,
changeReason,
});
await this.invalidateCache(key, env);
return this.configRepo.findOneOrFail({ where: { id: existing.id } });
}

const record = this.configRepo.create({
key,
value: value as unknown,
environment: env,
version: 1,
createdBy,
changeReason,
});
const saved = await this.configRepo.save(record);
await this.invalidateCache(key, env);
return saved;
}

async findAll(environment?: string): Promise<ConfigurationValue[]> {
const env = environment ?? this.getDefaultEnvironment();
return this.configRepo.find({
where: { environment: env },
order: { key: 'ASC' },
});
}

async findOne(id: string): Promise<ConfigurationValue> {
const record = await this.configRepo.findOne({ where: { id } });
if (!record) throw new NotFoundException(`Configuration ${id} not found`);
return record;
}

async delete(id: string): Promise<void> {
const record = await this.findOne(id);
await this.configRepo.delete(id);
await this.invalidateCache(record.key, record.environment);
}

async getHistory(
key: string,
environment?: string,
limit = 50,
): Promise<ConfigurationHistoryEntry[]> {
const env = environment ?? this.getDefaultEnvironment();
const records = await this.configRepo.find({
where: { key, environment: env },
order: { createdAt: 'DESC' },
take: limit,
});
return records.map((r) => ({
id: r.id,
key: r.key,
value: r.value,
version: r.version,
createdBy: r.createdBy,
changeReason: r.changeReason,
createdAt: r.createdAt,
}));
}

private async invalidateCache(
key: string,
environment: string,
): Promise<void> {
await this.redisService.del(this.cacheKey(key, environment));
}

private cacheKey(key: string, environment: string): string {
return `config:${environment}:${key}`;
}

private getDefaultEnvironment(): string {
return process.env.NODE_ENV ?? 'development';
}
}
39 changes: 39 additions & 0 deletions src/feature-flags/entities/configuration-value.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';

@Entity('configuration_values')
@Index(['key', 'environment'], { unique: true })
export class ConfigurationValue {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
key: string;

@Column({ type: 'json' })
value: unknown;

@Column({ default: 'development' })
environment: string;

@Column({ default: 1 })
version: number;

@Column({ nullable: true })
createdBy: string;

@Column({ type: 'text', nullable: true })
changeReason: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
59 changes: 59 additions & 0 deletions src/feature-flags/entities/feature-flag.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';

export type FeatureFlagType =
| 'boolean'
| 'percentage'
| 'user'
| 'role'
| 'environment'
| 'time';

@Entity('feature_flags')
@Index(['key', 'environment'], { unique: true })
export class FeatureFlag {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column()
key: string;

@Column({ type: 'varchar', default: 'boolean' })
type: FeatureFlagType;

@Column({ default: false })
enabled: boolean;

@Column({ type: 'float', default: 0 })
rolloutPercentage: number;

@Column({ type: 'json', nullable: true })
rules: Record<string, unknown>;

@Column({ default: 'development' })
environment: string;

@Column({ type: 'text', nullable: true })
description: string;

@Column({ nullable: true })
expiresAt: Date;

@Column({ default: 1 })
version: number;

@Column({ nullable: true })
createdBy: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
Loading
Loading