Skip to content

Commit b8cc013

Browse files
authored
feat: pivot config to Sanity singletons — 6 schemas + config.ts rewrite (#607)
Pivots config storage from Supabase tables to Sanity singleton documents. Creates 6 singleton schemas (pipelineConfig, remotionConfig, contentConfig, sponsorConfig, distributionConfig, gcsConfig) with initialValue defaults, validation rules, and icons. Rewrites lib/config.ts to use writeClient.fetch with same stale-while-revalidate caching API. Updates lib/types/config.ts to camelCase with Sanity _id/_type/_updatedAt fields. Removes Supabase migration SQL and invalidate endpoint.
1 parent 74288d8 commit b8cc013

File tree

11 files changed

+519
-487
lines changed

11 files changed

+519
-487
lines changed

app/api/dashboard/config/invalidate/route.ts

Lines changed: 0 additions & 61 deletions
This file was deleted.

lib/config.ts

Lines changed: 83 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,155 +1,113 @@
1-
import { createClient } from "@supabase/supabase-js";
1+
import { writeClient } from "@/lib/sanity-write-client";
22
import type { ConfigTable, ConfigTypeMap } from "@/lib/types/config";
33

44
/**
5-
* Supabase config module with stale-while-revalidate caching.
5+
* Sanity config module with in-memory caching.
66
*
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
7+
* Each config "table" maps to a Sanity singleton document type.
8+
* Uses writeClient.fetch for server-side reads.
129
*
13-
* Serverless reality: each Vercel instance has its own in-memory cache.
14-
* Worst case: TTL propagation delay across instances. Acceptable for config.
10+
* Caching: 5-minute TTL with stale-while-revalidate.
11+
* Sanity changes propagate on next cache miss.
1512
*/
1613

1714
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
1815

1916
interface CacheEntry<T> {
20-
data: T;
21-
fetchedAt: number;
22-
refreshing: boolean;
17+
data: T;
18+
fetchedAt: number;
19+
refreshing: boolean;
2320
}
2421

2522
const cache = new Map<string, CacheEntry<unknown>>();
2623

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-
}
24+
// Map config table names to Sanity document type names
25+
const TABLE_TO_TYPE: Record<ConfigTable, string> = {
26+
pipeline_config: "pipelineConfig",
27+
remotion_config: "remotionConfig",
28+
content_config: "contentConfig",
29+
sponsor_config: "sponsorConfig",
30+
distribution_config: "distributionConfig",
31+
gcs_config: "gcsConfig",
32+
};
4833

49-
/**
50-
* Fetch a singleton config row from Supabase and update the cache.
51-
*/
5234
async function refreshConfig<T extends ConfigTable>(
53-
table: T,
35+
table: T,
5436
): 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];
37+
const sanityType = TABLE_TO_TYPE[table];
38+
const data = await writeClient.fetch(
39+
`*[_type == $type][0]`,
40+
{ type: sanityType } as Record<string, unknown>,
41+
);
42+
43+
if (!data) {
44+
throw new Error(`Config not found for ${sanityType} — create the singleton document in Sanity Studio`);
45+
}
46+
47+
cache.set(table, {
48+
data,
49+
fetchedAt: Date.now(),
50+
refreshing: false,
51+
});
52+
53+
return data as ConfigTypeMap[T];
7354
}
7455

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-
*/
8656
export async function getConfig<T extends ConfigTable>(
87-
table: T,
88-
ttlMs = DEFAULT_TTL_MS,
57+
table: T,
58+
ttlMs = DEFAULT_TTL_MS,
8959
): 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-
}
60+
const cached = cache.get(table) as CacheEntry<ConfigTypeMap[T]> | undefined;
61+
const now = Date.now();
62+
63+
// Fresh cache — return immediately
64+
if (cached && now - cached.fetchedAt < ttlMs) {
65+
return cached.data;
66+
}
67+
68+
// Stale cache — return stale, refresh in background
69+
if (cached && !cached.refreshing) {
70+
cached.refreshing = true;
71+
refreshConfig(table).catch((err) => {
72+
console.error(`[config] Background refresh failed for ${table}:`, err);
73+
const entry = cache.get(table) as CacheEntry<unknown> | undefined;
74+
if (entry) entry.refreshing = false;
75+
});
76+
return cached.data;
77+
}
78+
79+
// No cache — must fetch synchronously
80+
return refreshConfig(table);
12381
}
12482

12583
/**
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);
84+
* Get a single config value with optional env var fallback.
85+
* Useful during migration period.
13186
*/
13287
export async function getConfigValue<
133-
T extends ConfigTable,
134-
K extends keyof ConfigTypeMap[T],
88+
T extends ConfigTable,
89+
K extends keyof ConfigTypeMap[T],
13590
>(
136-
table: T,
137-
key: K,
138-
fallback?: ConfigTypeMap[T][K],
91+
table: T,
92+
key: K,
93+
fallback?: ConfigTypeMap[T][K],
13994
): 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-
}
95+
try {
96+
const config = await getConfig(table);
97+
return config[key];
98+
} catch {
99+
if (fallback !== undefined) return fallback;
100+
throw new Error(`Config value ${String(key)} not found in ${table}`);
101+
}
102+
}
153103

154-
throw new Error(`No config value for ${String(table)}.${String(key)} and no fallback provided`);
104+
/**
105+
* Force-clear cached config. Called when config is known to have changed.
106+
*/
107+
export function invalidateConfig(table?: ConfigTable) {
108+
if (table) {
109+
cache.delete(table);
110+
} else {
111+
cache.clear();
112+
}
155113
}

0 commit comments

Comments
 (0)