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
239 changes: 239 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -2151,3 +2151,242 @@ model TransactionReorg {
@@map("transaction_reorgs")
}


// ─── Feature Flag System (#476) ──────────────────────────────────────────────

enum FeatureFlagType {
boolean
string
number
json
}

enum FeatureFlagStatus {
draft
active
paused
archived
}

enum TargetingRuleType {
percentage
user_segment
environment
user_attribute
allowlist
}

enum RolloutScheduleStatus {
pending
active
paused
completed
cancelled
}

enum ExperimentStatus {
draft
running
paused
completed
aborted
}

model FeatureFlag {
id String @id @default(uuid())
tenantId String @map("tenant_id")
key String
name String
description String?
type FeatureFlagType @default(boolean)
defaultValue Json @map("default_value")
status FeatureFlagStatus @default(draft)
environment String @default("all")
ownerEmail String? @map("owner_email")
expiresAt DateTime? @map("expires_at")
metadata Json?
version Int @default(1)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
archivedAt DateTime? @map("archived_at")

rules FeatureFlagRule[]
schedules RolloutSchedule[]
experiments Experiment[]
exposures FlagExposure[]
stats FlagUsageStat[]
auditLogs FeatureFlagAuditLog[]

@@unique([tenantId, key])
@@index([tenantId, status])
@@index([environment])
@@index([status, archivedAt])
@@map("feature_flags")
}

model FeatureFlagRule {
id String @id @default(uuid())
flagId String @map("flag_id")
type TargetingRuleType
priority Int @default(0)
conditions Json
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)

@@index([flagId, priority])
@@index([type])
@@map("feature_flag_rules")
}

model UserSegment {
id String @id @default(uuid())
tenantId String @map("tenant_id")
name String
description String?
conditions Json
matchType String @default("all")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")

@@unique([tenantId, name])
@@index([tenantId])
@@map("user_segments")
}

model RolloutSchedule {
id String @id @default(uuid())
flagId String @map("flag_id")
startPercentage Int @map("start_percentage")
endPercentage Int @map("end_percentage")
currentPercentage Int @default(0) @map("current_percentage")
incrementPercent Int @map("increment_percent")
incrementInterval String
status RolloutScheduleStatus @default(pending)
startedAt DateTime? @map("started_at")
nextIncrementAt DateTime? @map("next_increment_at")
completedAt DateTime? @map("completed_at")
pausedReason String? @map("paused_reason")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)

@@index([flagId, status])
@@index([status, nextIncrementAt])
@@map("rollout_schedules")
}

model Experiment {
id String @id @default(uuid())
flagId String @map("flag_id")
name String
hypothesis String?
status ExperimentStatus @default(draft)
startedAt DateTime? @map("started_at")
endedAt DateTime? @map("ended_at")
primaryMetric String? @map("primary_metric")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
variants ExperimentVariant[]
assignments ExperimentAssignment[]

@@index([flagId, status])
@@index([status, startedAt])
@@map("experiments")
}

model ExperimentVariant {
id String @id @default(uuid())
experimentId String @map("experiment_id")
key String
name String
value Json
bucketWeight Int @default(50) @map("bucket_weight")
isControl Boolean @default(false) @map("is_control")
createdAt DateTime @default(now()) @map("created_at")

experiment Experiment @relation(fields: [experimentId], references: [id], onDelete: Cascade)
assignments ExperimentAssignment[]

@@unique([experimentId, key])
@@map("experiment_variants")
}

model ExperimentAssignment {
id String @id @default(uuid())
experimentId String @map("experiment_id")
variantId String @map("variant_id")
identifier String
assignedAt DateTime @default(now()) @map("assigned_at")
exposed Boolean @default(false)
firstExposedAt DateTime? @map("first_exposed_at")

experiment Experiment @relation(fields: [experimentId], references: [id], onDelete: Cascade)
variant ExperimentVariant @relation(fields: [variantId], references: [id], onDelete: Cascade)

@@unique([experimentId, identifier])
@@index([variantId])
@@index([identifier])
@@map("experiment_assignments")
}

model FlagExposure {
id String @id @default(uuid())
flagId String @map("flag_id")
identifier String
value Json
environment String
source String @default("server")
userAgent String? @map("user_agent")
metadata Json?
createdAt DateTime @default(now()) @map("created_at")

flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)

@@index([flagId, createdAt])
@@index([identifier, createdAt])
@@index([createdAt])
@@map("flag_exposures")
}

model FlagUsageStat {
id String @id @default(uuid())
flagId String @map("flag_id")
windowStart DateTime @map("window_start")
windowDuration String @map("window_duration")
evaluations Int @default(0)
trueCount Int @default(0) @map("true_count")
falseCount Int @default(0) @map("false_count")
uniqueIdentifiers Int @default(0) @map("unique_identifiers")

flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)

@@unique([flagId, windowStart, windowDuration])
@@index([flagId, windowStart])
@@map("flag_usage_stats")
}

model FeatureFlagAuditLog {
id String @id @default(uuid())
flagId String @map("flag_id")
actor String
action String
before Json?
after Json?
metadata Json?
createdAt DateTime @default(now()) @map("created_at")

flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)

@@index([flagId, createdAt])
@@index([actor, createdAt])
@@map("feature_flag_audit_logs")
}
18 changes: 18 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { cacheControlNoStore } from './middleware/cache-control.js';
import { httpLogger, correlationMiddleware } from './middleware/logger.js';
import { validateEnv, config as getConfig } from './config/env.js';
import { flagsRouter } from './routes/flags.js';
import { featureFlagsRouter } from './routes/featureFlags.js';
import { emailRouter } from './routes/email.js';
import { portfolioRouter } from './routes/portfolio.js';
import { backupRouter } from './routes/backup.js';
Expand Down Expand Up @@ -136,6 +137,7 @@ import piiRouter from './routes/pii.js';
import { piiRedactionMiddleware } from './middleware/pii-redaction.js';
import { reorgRouter } from './routes/reorg.js';
import { getReorgDetector } from './services/chain/reorg-detector.js';
import { featureFlagRegistry } from './services/featureFlagRegistry.js';

// Validate environment variables at startup
validateEnv();
Expand Down Expand Up @@ -281,6 +283,7 @@ apiV1Router.use('/sla', slaRouter);
apiV1Router.use('/onboarding', onboardingRouter);
apiV1Router.use('/legacy', legacyRouter);
apiV1Router.use('/flags', flagsRouter);
apiV1Router.use('/feature-flags', featureFlagsRouter);
apiV1Router.use('/rate-limit', rateLimitAnalyticsRouter);
apiV1Router.use('/zk-identity', zkIdentityRouter);
apiV1Router.use('/kyb', kybRouter);
Expand Down Expand Up @@ -513,6 +516,21 @@ if (featureFlags.evaluate('batch-operations')) {
console.log('[BatchProcessor] Started');
}

// Feature flag gradual-rollout scheduler + stale-flag detection (Issue #476)
// Runs every minute so percentage rollouts advance without manual triggers.
// Set FF_SCHEDULER_ENABLED=false to disable in tests.
if (process.env.FF_SCHEDULER_ENABLED !== 'false') {
setInterval(async () => {
try {
const advanced = await featureFlagRegistry.runScheduledRollouts(new Date());
if (advanced > 0) console.log(`[FeatureFlags] Advanced ${advanced} scheduled rollout(s)`);
} catch (err) {
console.error('[FeatureFlags] Scheduler tick failed:', err);
}
}, 60_000);
console.log('[FeatureFlags] Gradual-rollout scheduler started');
}

getRedisCache().connect().then(() => {
console.log('[RedisCache] Connection initialized');
}).catch(() => {
Expand Down
Loading