Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export interface FlagRow {
|
||||
key: string;
|
||||
enabled: boolean;
|
||||
rolloutPercent: number;
|
||||
}
|
||||
|
||||
/** Kända flaggor – centralt register så att döda flaggor syns i kodgranskning. */
|
||||
export const KNOWN_FLAGS = {
|
||||
COMMUNITY_PUBLISHING: "community_publishing",
|
||||
WEEK_PLAN_AI: "week_plan_ai",
|
||||
PLATE_PHOTO_ANALYSIS: "plate_photo_analysis",
|
||||
RECEIPT_SCANNING: "receipt_scanning",
|
||||
PANTRY_FORECAST: "pantry_forecast",
|
||||
HEALTH_INTEGRATION: "health_integration",
|
||||
WEATHER_CONTEXT: "weather_context",
|
||||
CREATOR_RANKINGS: "creator_rankings",
|
||||
FOOD_MEMORIES: "food_memories",
|
||||
VOICE_INPUT: "voice_input",
|
||||
AI_RERANK: "ai_rerank",
|
||||
} as const;
|
||||
export type KnownFlagKey = (typeof KNOWN_FLAGS)[keyof typeof KNOWN_FLAGS];
|
||||
|
||||
export interface FeatureFlagServiceOptions {
|
||||
/** Laddar alla flaggor från databasen (injiceras för att undvika DB-beroende här). */
|
||||
loadAll: () => Promise<FlagRow[]>;
|
||||
/** Cache-TTL i ms (default 30 s). */
|
||||
ttlMs?: number;
|
||||
/** Miljövariabler för override: APP_FLAG_<KEY>=true/false. */
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flaggtjänst med tre lager:
|
||||
* 1. env-override (drift/incident: stäng av utan deploy),
|
||||
* 2. databasvärde med rollout-procent (gradvis utrullning per användare),
|
||||
* 3. default false (fail closed för ny funktionalitet).
|
||||
*/
|
||||
export class FeatureFlagService {
|
||||
private cache = new Map<string, FlagRow>();
|
||||
private cachedAt = 0;
|
||||
private readonly ttlMs: number;
|
||||
private readonly env: Record<string, string | undefined>;
|
||||
private readonly loadAll: () => Promise<FlagRow[]>;
|
||||
|
||||
constructor(options: FeatureFlagServiceOptions) {
|
||||
this.loadAll = options.loadAll;
|
||||
this.ttlMs = options.ttlMs ?? 30_000;
|
||||
this.env = options.env ?? process.env;
|
||||
}
|
||||
|
||||
async isEnabled(key: string, userId?: string): Promise<boolean> {
|
||||
const envOverride = this.env[`APP_FLAG_${key.toUpperCase()}`];
|
||||
if (envOverride === "true") return true;
|
||||
if (envOverride === "false") return false;
|
||||
|
||||
await this.refreshIfStale();
|
||||
const row = this.cache.get(key);
|
||||
if (!row || !row.enabled) return false;
|
||||
if (row.rolloutPercent >= 100) return true;
|
||||
if (row.rolloutPercent <= 0) return false;
|
||||
if (!userId) return false;
|
||||
return bucketFor(key, userId) < row.rolloutPercent;
|
||||
}
|
||||
|
||||
async all(): Promise<FlagRow[]> {
|
||||
await this.refreshIfStale();
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedAt = 0;
|
||||
}
|
||||
|
||||
private async refreshIfStale(): Promise<void> {
|
||||
if (Date.now() - this.cachedAt < this.ttlMs) return;
|
||||
try {
|
||||
const rows = await this.loadAll();
|
||||
this.cache = new Map(rows.map((r) => [r.key, r]));
|
||||
this.cachedAt = Date.now();
|
||||
} catch {
|
||||
// Behåll gammal cache vid DB-fel – flaggor får aldrig fälla en request.
|
||||
this.cachedAt = Date.now() - this.ttlMs + 5_000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stabil hash → 0–99: samma användare hamnar alltid i samma bucket per flagga. */
|
||||
export function bucketFor(flagKey: string, userId: string): number {
|
||||
const hash = createHash("sha256").update(`${flagKey}:${userId}`).digest();
|
||||
return ((hash[0]! << 8) | hash[1]!) % 100;
|
||||
}
|
||||
Reference in New Issue
Block a user