import type { Group, Route } from "../types"; import { log } from "../lib/log"; export interface ConfigStore { loadRoutes(): Promise; saveRoutes(routes: Route[]): Promise; loadGroups(): Promise; saveGroups(groups: Group[]): Promise; invalidateCache(): void; } interface D1GroupRow { id: string; name: string; data: string; version: number; } interface D1RouteRow { id: string; group_id: string; name: string; enabled: number; filters: string; targets: string; stop: number; fallback: number; discord_role_ids: string | null; ast: string | null; } const CACHE_TTL = 300_000; const KV_ROUTES_KEY = "config:routes"; const KV_GROUPS_KEY = "config:groups"; const KV_CACHE_TTL = 3600; export function d1ConfigStore(db: D1Database, kv: KVNamespace): ConfigStore { let routesCache: { routes: Route[]; expiresAt: number } | null = null; let groupsCache: { groups: Group[]; expiresAt: number } | null = null; async function loadRoutesFromD1(): Promise { const stmt = db.prepare( "SELECT id, group_id, name, enabled, filters, targets, stop, fallback, discord_role_ids, ast FROM d1_routes ORDER BY id", ); if (typeof stmt.all !== "function") return []; const { results } = await stmt.all(); if (!results || results.length === 0) return []; return results.map((r) => ({ id: r.id, groupId: r.group_id, name: r.name, enabled: r.enabled === 1, filters: JSON.parse(r.filters), targets: JSON.parse(r.targets), stop: r.stop === 1, fallback: r.fallback === 1, discordRoleIds: r.discord_role_ids ? JSON.parse(r.discord_role_ids) : undefined, ast: r.ast ? JSON.parse(r.ast) : undefined, })); } async function loadGroupsFromD1(): Promise { const stmt = db.prepare("SELECT id, name, data, version FROM d1_groups ORDER BY id"); if (typeof stmt.all !== "function") return []; const { results } = await stmt.all(); if (!results || results.length === 0) return []; return results.map((r) => JSON.parse(r.data) as Group); } async function loadRoutesFromKV(): Promise { try { const raw = await kv.get(KV_ROUTES_KEY, "json"); if (raw) return raw; } catch (err) { log.warn({ err }, "Failed to load routes from KV"); } return []; } async function loadGroupsFromKV(): Promise { try { const raw = await kv.get(KV_GROUPS_KEY, "json"); if (raw) return raw; } catch (err) { log.warn({ err }, "Failed to load groups from KV"); } return []; } function routeStatements(routes: Route[], existingRouteKeys?: Set): D1PreparedStatement[] { const now = Date.now(); const statements: D1PreparedStatement[] = []; // If we have existing routes, delete those not in the new set if (existingRouteKeys) { const newKeys = new Set(routes.map((r) => `${r.id}:${r.groupId ?? ""}`)); for (const key of existingRouteKeys) { if (!newKeys.has(key)) { const [id, groupId] = key.split(":"); statements.push( db.prepare("DELETE FROM d1_routes WHERE id = ? AND group_id = ?").bind(id, groupId) ); } } } else { // Backward compatibility: delete all routes if no existing set provided statements.push(db.prepare("DELETE FROM d1_routes")); } // Upsert all routes for (const r of routes) { statements.push( db .prepare( `INSERT INTO d1_routes (id, group_id, name, enabled, filters, targets, stop, fallback, discord_role_ids, ast, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) ON CONFLICT(id, group_id) DO UPDATE SET name = excluded.name, enabled = excluded.enabled, filters = excluded.filters, targets = excluded.targets, stop = excluded.stop, fallback = excluded.fallback, discord_role_ids = excluded.discord_role_ids, ast = excluded.ast, updated_at = excluded.updated_at`, ) .bind( r.id, r.groupId ?? "", r.name, r.enabled ? 1 : 0, JSON.stringify(r.filters), JSON.stringify(r.targets), r.stop ? 1 : 0, r.fallback ? 1 : 0, r.discordRoleIds ? JSON.stringify(r.discordRoleIds) : null, r.ast ? JSON.stringify(r.ast) : null, now, now, ), ); } return statements; } function groupStatements(groups: Group[], existingGroupIds: string[]): D1PreparedStatement[] { const now = Date.now(); const newGroupIds = new Set(groups.map((g) => g.id)); const toDelete = existingGroupIds.filter((id) => !newGroupIds.has(id)); const statements: D1PreparedStatement[] = []; // First, delete routes for groups that will be removed for (const groupId of toDelete) { statements.push(db.prepare("DELETE FROM d1_routes WHERE group_id = ?").bind(groupId)); } // Then delete the groups themselves for (const groupId of toDelete) { statements.push(db.prepare("DELETE FROM d1_groups WHERE id = ?").bind(groupId)); } // Finally, upsert all groups (INSERT OR REPLACE) for (const g of groups) { statements.push( db .prepare( `INSERT INTO d1_groups (id, name, data, version, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, data = excluded.data, updated_at = excluded.updated_at`, ) .bind(g.id, g.name, JSON.stringify(g), now, now), ); } return statements; } async function seedRoutesToD1(routes: Route[]): Promise { if (routes.length === 0) return; await db.batch(routeStatements(routes)); } async function seedGroupsToD1(groups: Group[]): Promise { if (groups.length === 0) return; // When seeding, there are no existing groups to delete await db.batch(groupStatements(groups, [])); } async function syncRoutesToKV(routes: Route[], ttl: number): Promise { try { await kv.put(KV_ROUTES_KEY, JSON.stringify(routes), { expirationTtl: ttl }); } catch (err) { log.warn({ err }, "Failed to sync routes to KV cache"); } } async function syncGroupsToKV(groups: Group[], ttl: number): Promise { try { await kv.put(KV_GROUPS_KEY, JSON.stringify(groups), { expirationTtl: ttl }); } catch (err) { log.warn({ err }, "Failed to sync groups to KV cache"); } } return { async loadRoutes(): Promise { if (routesCache && Date.now() < routesCache.expiresAt) { return routesCache.routes; } let routes: Route[] = []; try { routes = await loadRoutesFromD1(); if (routes.length > 0) { syncRoutesToKV(routes, KV_CACHE_TTL).catch(() => undefined); } else { routes = await loadRoutesFromKV(); if (routes.length > 0) { seedRoutesToD1(routes).catch((err) => log.warn({ err }, "Failed to seed routes from KV to D1"), ); } } } catch (err) { log.warn({ err }, "D1 routes unavailable, falling back to KV"); routes = await loadRoutesFromKV(); } routesCache = { routes, expiresAt: Date.now() + CACHE_TTL }; return routes; }, async saveRoutes(routes: Route[]): Promise { try { await db.batch(routeStatements(routes)); await syncRoutesToKV(routes, KV_CACHE_TTL); } catch (err) { log.warn({ err }, "D1 routes unavailable, falling back to KV"); await syncRoutesToKV(routes, 0); } routesCache = null; }, async loadGroups(): Promise { if (groupsCache && Date.now() < groupsCache.expiresAt) { return groupsCache.groups; } let groups: Group[] = []; try { groups = await loadGroupsFromD1(); if (groups.length > 0) { syncGroupsToKV(groups, KV_CACHE_TTL).catch(() => undefined); } else { groups = await loadGroupsFromKV(); if (groups.length > 0) { seedGroupsToD1(groups).catch((err) => log.warn({ err }, "Failed to seed groups from KV to D1"), ); } } } catch (err) { log.warn({ err }, "D1 groups unavailable, falling back to KV"); groups = await loadGroupsFromKV(); } groupsCache = { groups, expiresAt: Date.now() + CACHE_TTL }; return groups; }, async saveGroups(groups: Group[]): Promise { try { // Load existing group IDs to properly handle deletions const existing = await loadGroupsFromD1(); const existingIds = existing.map((g) => g.id); await db.batch(groupStatements(groups, existingIds)); await syncGroupsToKV(groups, KV_CACHE_TTL); } catch (err) { log.warn({ err }, "D1 groups unavailable, falling back to KV"); await syncGroupsToKV(groups, 0); } groupsCache = null; }, invalidateCache(): void { routesCache = null; groupsCache = null; }, }; }