Prevent data loss from read errors and concurrent writes

readCards now throws on fetch/parse failures instead of returning []
so a broken read can never trigger an overwrite of valid data.
Added a write lock to serialize card mutations within a single instance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dexx
2026-06-05 22:53:17 +03:00
parent 79460fb47d
commit 5033ce8b4d
2 changed files with 39 additions and 29 deletions
+22 -17
View File
@@ -10,6 +10,7 @@ import {
readCards, readCards,
saveImage, saveImage,
storageMode, storageMode,
withWriteLock,
writeCards writeCards
} from "./store"; } from "./store";
@@ -251,16 +252,18 @@ const app = new Elysia()
} }
try { try {
const form = await request.formData(); await withWriteLock(async () => {
const cards = await readCards(); const form = await request.formData();
const id = String(form.get("id") || ""); const cards = await readCards();
const existing = cards.find((card) => card.id === id); const id = String(form.get("id") || "");
const nextCard = await cardFromForm(form, existing); const existing = cards.find((card) => card.id === id);
const nextCards = existing const nextCard = await cardFromForm(form, existing);
? cards.map((card) => (card.id === existing.id ? nextCard : card)) const nextCards = existing
: [nextCard, ...cards]; ? cards.map((card) => (card.id === existing.id ? nextCard : card))
: [nextCard, ...cards];
await writeCards(nextCards); await writeCards(nextCards);
});
return redirect("/admin"); return redirect("/admin");
} catch (error) { } catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card."); return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
@@ -278,16 +281,18 @@ const app = new Elysia()
} }
try { try {
const form = await request.formData(); await withWriteLock(async () => {
const id = String(form.get("id") || ""); const form = await request.formData();
const cards = await readCards(); const id = String(form.get("id") || "");
const existing = cards.find((card) => card.id === id); const cards = await readCards();
const existing = cards.find((card) => card.id === id);
if (existing?.imageUrl) { if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl); await deleteRemoteImage(existing.imageUrl);
} }
await writeCards(cards.filter((card) => card.id !== id)); await writeCards(cards.filter((card) => card.id !== id));
});
return redirect("/admin"); return redirect("/admin");
} catch (error) { } catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card."); return renderAdminWithMessage(error instanceof Error ? error.message : "Could not delete card.");
+17 -12
View File
@@ -20,30 +20,35 @@ const localCardsPath = path.join(localDataDir, "cards.json");
const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim()); const hasBlobToken = () => Boolean(process.env.BLOB_READ_WRITE_TOKEN?.trim());
const isVercel = () => process.env.VERCEL === "1"; const isVercel = () => process.env.VERCEL === "1";
let writeLock: Promise<void> = Promise.resolve();
export function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
const next = writeLock.then(fn, fn);
writeLock = next.then(() => {}, () => {});
return next;
}
export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file"; export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file";
export async function readCards(): Promise<BoardCard[]> { export async function readCards(): Promise<BoardCard[]> {
if (hasBlobToken()) { if (hasBlobToken()) {
try { const { blobs } = await list({ prefix: dataBlobPath, limit: 1 });
const { blobs } = await list({ prefix: dataBlobPath, limit: 1 }); if (blobs.length === 0) return [];
if (blobs.length === 0) return [];
const res = await fetch(blobs[0].url); const res = await fetch(blobs[0].url);
if (!res.ok) return []; if (!res.ok) throw new Error(`Blob read failed: ${res.status}`);
const parsed = (await res.json()) as BoardCard[]; const parsed = (await res.json()) as BoardCard[];
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
} }
try { try {
const text = await readFile(localCardsPath, "utf8"); const text = await readFile(localCardsPath, "utf8");
const parsed = JSON.parse(text) as BoardCard[]; const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch { } catch (err: any) {
return []; if (err?.code === "ENOENT") return [];
throw err;
} }
} }