fix: prevent data loss in routes, fragments, and group operations

Found and fixed multiple critical bugs with similar patterns:

1. routeStatements() used DELETE FROM d1_routes before re-inserting
   - Could cause data loss if interrupted or if logic changes
   - Now uses DELETE with WHERE clause + INSERT...ON CONFLICT (upsert)

2. saveFragments() used DELETE FROM d1_fragments before re-inserting
   - Same pattern as routes, fixed with targeted deletes + upsert
   - Added loadFragments() call to determine what to delete

3. adminGroupRename() didn't update fragment groupId
   - When renaming a group, fragments were left pointing to old groupId
   - Now updates fragments alongside routes, secrets, and invites

All changes follow the same safe pattern:
- Load existing data to identify what needs deletion
- Delete only removed items with WHERE clauses
- Use INSERT...ON CONFLICT DO UPDATE for upserts
- Never use bare DELETE FROM table

Testing: All 339 tests pass.
This commit is contained in:
RhenCloud 2026-09-10 22:35:06 +08:00
parent 6c6c67fd73
commit 1a0500b812
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
3 changed files with 75 additions and 14 deletions

View file

@ -88,15 +88,43 @@ export function d1ConfigStore(db: D1Database, kv: KVNamespace): ConfigStore {
return [];
}
function routeStatements(routes: Route[]): D1PreparedStatement[] {
function routeStatements(routes: Route[], existingRouteKeys?: Set<string>): D1PreparedStatement[] {
const now = Date.now();
return [
db.prepare("DELETE FROM d1_routes"),
...routes.map((r) =>
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, ?, ?)`,
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,
@ -112,8 +140,10 @@ export function d1ConfigStore(db: D1Database, kv: KVNamespace): ConfigStore {
now,
now,
),
),
];
);
}
return statements;
}
function groupStatements(groups: Group[], existingGroupIds: string[]): D1PreparedStatement[] {