Skip to content

Commit 89434c0

Browse files
Miriaddashboard
andcommitted
feat: add lib/config.ts with stale-while-revalidate caching + invalidate endpoint
- lib/config.ts: getConfig<T>(table, ttlMs?) with 5-min TTL cache - Stale-while-revalidate: returns cached data immediately, refreshes in background - getConfigValue() helper with env var fallback for migration period - invalidateConfig(table?) to force-refresh on dashboard save - POST /api/dashboard/config/invalidate with auth + table validation - lib/types/config.ts: typed interfaces for all 6 config tables - Uses SUPABASE_SERVICE_ROLE_KEY for server-side config reads (no RLS) Co-authored-by: dashboard <dashboard@miriad.systems>
1 parent 6e332aa commit 89434c0

File tree

3 files changed

+297
-0
lines changed

3 files changed

+297
-0
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { NextResponse } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { invalidateConfig } from "@/lib/config";
4+
import type { ConfigTable } from "@/lib/types/config";
5+
6+
export const dynamic = "force-dynamic";
7+
8+
const VALID_TABLES: ConfigTable[] = [
9+
"pipeline_config",
10+
"remotion_config",
11+
"content_config",
12+
"sponsor_config",
13+
"distribution_config",
14+
"gcs_config",
15+
];
16+
17+
export async function POST(request: Request) {
18+
// Fail-closed auth
19+
const hasSupabase =
20+
(process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL) &&
21+
(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY);
22+
23+
if (!hasSupabase) {
24+
return NextResponse.json({ error: "Auth not configured" }, { status: 503 });
25+
}
26+
27+
const supabase = await createClient();
28+
const {
29+
data: { user },
30+
} = await supabase.auth.getUser();
31+
32+
if (!user) {
33+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
34+
}
35+
36+
try {
37+
const body = await request.json().catch(() => ({}));
38+
const { table } = body as { table?: string };
39+
40+
// Validate table name if provided
41+
if (table && !VALID_TABLES.includes(table as ConfigTable)) {
42+
return NextResponse.json(
43+
{ error: `Invalid table: ${table}. Valid tables: ${VALID_TABLES.join(", ")}` },
44+
{ status: 400 },
45+
);
46+
}
47+
48+
invalidateConfig(table as ConfigTable | undefined);
49+
50+
return NextResponse.json({
51+
success: true,
52+
invalidated: table ?? "all",
53+
});
54+
} catch (error) {
55+
console.error("Failed to invalidate config:", error);
56+
return NextResponse.json(
57+
{ error: "Failed to invalidate config" },
58+
{ status: 500 },
59+
);
60+
}
61+
}

lib/config.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { createClient } from "@supabase/supabase-js";
2+
import type { ConfigTable, ConfigTypeMap } from "@/lib/types/config";
3+
4+
/**
5+
* Supabase config module with stale-while-revalidate caching.
6+
*
7+
* Caching behavior:
8+
* - Cold start: fetches from Supabase, caches result
9+
* - Warm (< TTL): returns cached data, zero DB queries
10+
* - Stale (> TTL): returns cached data immediately, refreshes in background
11+
* - Invalidate: called by dashboard "Save" button via /api/dashboard/config/invalidate
12+
*
13+
* Serverless reality: each Vercel instance has its own in-memory cache.
14+
* Worst case: TTL propagation delay across instances. Acceptable for config.
15+
*/
16+
17+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
18+
19+
interface CacheEntry<T> {
20+
data: T;
21+
fetchedAt: number;
22+
refreshing: boolean;
23+
}
24+
25+
const cache = new Map<string, CacheEntry<unknown>>();
26+
27+
/**
28+
* Create a Supabase client for config reads.
29+
* Uses service role key for server-side access (no RLS).
30+
*/
31+
function getSupabaseClient() {
32+
const url = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
33+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
34+
35+
if (!url || !key) {
36+
throw new Error(
37+
"Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY for config access",
38+
);
39+
}
40+
41+
return createClient(url, key, {
42+
auth: {
43+
autoRefreshToken: false,
44+
persistSession: false,
45+
},
46+
});
47+
}
48+
49+
/**
50+
* Fetch a singleton config row from Supabase and update the cache.
51+
*/
52+
async function refreshConfig<T extends ConfigTable>(
53+
table: T,
54+
): Promise<ConfigTypeMap[T]> {
55+
const supabase = getSupabaseClient();
56+
const { data, error } = await supabase
57+
.from(table)
58+
.select("*")
59+
.limit(1)
60+
.single();
61+
62+
if (error) {
63+
throw new Error(`Config fetch failed for ${table}: ${error.message}`);
64+
}
65+
66+
cache.set(table, {
67+
data,
68+
fetchedAt: Date.now(),
69+
refreshing: false,
70+
});
71+
72+
return data as ConfigTypeMap[T];
73+
}
74+
75+
/**
76+
* Get config for a table with stale-while-revalidate caching.
77+
*
78+
* @param table - The config table name
79+
* @param ttlMs - Cache TTL in milliseconds (default: 5 minutes)
80+
* @returns The config row data
81+
*
82+
* @example
83+
* const pipeline = await getConfig("pipeline_config");
84+
* console.log(pipeline.gemini_model); // "gemini-2.0-flash"
85+
*/
86+
export async function getConfig<T extends ConfigTable>(
87+
table: T,
88+
ttlMs = DEFAULT_TTL_MS,
89+
): Promise<ConfigTypeMap[T]> {
90+
const cached = cache.get(table) as CacheEntry<ConfigTypeMap[T]> | undefined;
91+
const now = Date.now();
92+
93+
// Fresh cache — return immediately
94+
if (cached && now - cached.fetchedAt < ttlMs) {
95+
return cached.data;
96+
}
97+
98+
// Stale cache — return stale data, refresh in background
99+
if (cached && !cached.refreshing) {
100+
cached.refreshing = true;
101+
refreshConfig(table).catch((err) => {
102+
console.error(`Background config refresh failed for ${table}:`, err);
103+
cached.refreshing = false;
104+
});
105+
return cached.data;
106+
}
107+
108+
// No cache — must fetch synchronously
109+
return refreshConfig(table);
110+
}
111+
112+
/**
113+
* Force-invalidate cached config. Called by the dashboard after saving settings.
114+
*
115+
* @param table - Specific table to invalidate, or undefined to clear all
116+
*/
117+
export function invalidateConfig(table?: ConfigTable) {
118+
if (table) {
119+
cache.delete(table);
120+
} else {
121+
cache.clear();
122+
}
123+
}
124+
125+
/**
126+
* Get a config value with an env var fallback.
127+
* Use during migration — once all config is in Supabase, remove fallbacks.
128+
*
129+
* @example
130+
* const model = await getConfigValue("pipeline_config", "gemini_model", process.env.GEMINI_MODEL);
131+
*/
132+
export async function getConfigValue<
133+
T extends ConfigTable,
134+
K extends keyof ConfigTypeMap[T],
135+
>(
136+
table: T,
137+
key: K,
138+
fallback?: ConfigTypeMap[T][K],
139+
): Promise<ConfigTypeMap[T][K]> {
140+
try {
141+
const config = await getConfig(table);
142+
const value = config[key];
143+
if (value !== undefined && value !== null) {
144+
return value;
145+
}
146+
} catch (err) {
147+
console.warn(`Config lookup failed for ${String(table)}.${String(key)}, using fallback:`, err);
148+
}
149+
150+
if (fallback !== undefined) {
151+
return fallback;
152+
}
153+
154+
throw new Error(`No config value for ${String(table)}.${String(key)} and no fallback provided`);
155+
}

lib/types/config.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Supabase config table types.
3+
* Each interface maps to a singleton row in the corresponding Supabase table.
4+
*/
5+
6+
export interface PipelineConfig {
7+
id: number;
8+
gemini_model: string;
9+
elevenlabs_voice_id: string;
10+
youtube_upload_visibility: string;
11+
youtube_channel_id: string;
12+
enable_notebooklm_research: boolean;
13+
quality_threshold: number;
14+
stuck_timeout_minutes: number;
15+
max_ideas_per_run: number;
16+
updated_at: string;
17+
}
18+
19+
export interface RemotionConfig {
20+
id: number;
21+
aws_region: string;
22+
function_name: string;
23+
serve_url: string;
24+
max_render_timeout_sec: number;
25+
memory_mb: number;
26+
disk_mb: number;
27+
updated_at: string;
28+
}
29+
30+
export interface ContentConfig {
31+
id: number;
32+
rss_feeds: { name: string; url: string }[];
33+
trend_sources_enabled: Record<string, boolean>;
34+
system_instruction: string;
35+
target_video_duration_sec: number;
36+
scene_count_min: number;
37+
scene_count_max: number;
38+
updated_at: string;
39+
}
40+
41+
export interface SponsorConfig {
42+
id: number;
43+
cooldown_days: number;
44+
rate_card_tiers: { name: string; description: string; price: number }[];
45+
outreach_email_template: string;
46+
max_outreach_per_run: number;
47+
updated_at: string;
48+
}
49+
50+
export interface DistributionConfig {
51+
id: number;
52+
notification_emails: string[];
53+
youtube_description_template: string;
54+
youtube_default_tags: string[];
55+
resend_from_email: string;
56+
updated_at: string;
57+
}
58+
59+
export interface GcsConfig {
60+
id: number;
61+
bucket_name: string;
62+
project_id: string;
63+
updated_at: string;
64+
}
65+
66+
export type ConfigTable =
67+
| "pipeline_config"
68+
| "remotion_config"
69+
| "content_config"
70+
| "sponsor_config"
71+
| "distribution_config"
72+
| "gcs_config";
73+
74+
export type ConfigTypeMap = {
75+
pipeline_config: PipelineConfig;
76+
remotion_config: RemotionConfig;
77+
content_config: ContentConfig;
78+
sponsor_config: SponsorConfig;
79+
distribution_config: DistributionConfig;
80+
gcs_config: GcsConfig;
81+
};

0 commit comments

Comments
 (0)