WebHooker/server/lib/storage/config-store.ts
RhenCloud b313cdc2fe
fix: prevent cascade deletion of routes when updating groups
Previously, saveGroups() used DELETE FROM d1_groups followed by re-insertion,
which triggered ON DELETE CASCADE and wiped all routes whenever any group was updated.

Changes:
- Use INSERT...ON CONFLICT DO UPDATE (upsert) instead of delete-then-insert
- Only delete groups that are actually being removed
- Delete routes first, then delete groups (proper cascade order)
- Load existing group IDs before saving to detect deletions

This fixes the critical bug where updating one group's metadata would
delete all routes across all groups.

Data recovery: Used D1 Time Travel to restore from bookmark
000018f9-00000002-000050de-f23b7bfe5ccdc00e9c9dec3a4de8bc81 (before
the problematic group update), recovering 18 routes.
2026-09-10 22:29:47 +08:00

267 lines
8.2 KiB
TypeScript

import type { Group, Route } from "../types";
import { log } from "../lib/log";
export interface ConfigStore {
loadRoutes(): Promise<Route[]>;
saveRoutes(routes: Route[]): Promise<void>;
loadGroups(): Promise<Group[]>;
saveGroups(groups: Group[]): Promise<void>;
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<Route[]> {
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<D1RouteRow>();
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<Group[]> {
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<D1GroupRow>();
if (!results || results.length === 0) return [];
return results.map((r) => JSON.parse(r.data) as Group);
}
async function loadRoutesFromKV(): Promise<Route[]> {
try {
const raw = await kv.get<Route[]>(KV_ROUTES_KEY, "json");
if (raw) return raw;
} catch (err) {
log.warn({ err }, "Failed to load routes from KV");
}
return [];
}
async function loadGroupsFromKV(): Promise<Group[]> {
try {
const raw = await kv.get<Group[]>(KV_GROUPS_KEY, "json");
if (raw) return raw;
} catch (err) {
log.warn({ err }, "Failed to load groups from KV");
}
return [];
}
function routeStatements(routes: Route[]): D1PreparedStatement[] {
const now = Date.now();
return [
db.prepare("DELETE FROM d1_routes"),
...routes.map((r) =>
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, ?, ?)`,
)
.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,
),
),
];
}
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<void> {
if (routes.length === 0) return;
await db.batch(routeStatements(routes));
}
async function seedGroupsToD1(groups: Group[]): Promise<void> {
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<void> {
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<void> {
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<Route[]> {
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<void> {
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<Group[]> {
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<void> {
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;
},
};
}