Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+93
View File
@@ -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 → 099: 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;
}