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,
saveImage,
storageMode,
withWriteLock,
writeCards
} from "./store";
@@ -251,16 +252,18 @@ const app = new Elysia()
}
try {
const form = await request.formData();
const cards = await readCards();
const id = String(form.get("id") || "");
const existing = cards.find((card) => card.id === id);
const nextCard = await cardFromForm(form, existing);
const nextCards = existing
? cards.map((card) => (card.id === existing.id ? nextCard : card))
: [nextCard, ...cards];
await withWriteLock(async () => {
const form = await request.formData();
const cards = await readCards();
const id = String(form.get("id") || "");
const existing = cards.find((card) => card.id === id);
const nextCard = await cardFromForm(form, existing);
const nextCards = existing
? cards.map((card) => (card.id === existing.id ? nextCard : card))
: [nextCard, ...cards];
await writeCards(nextCards);
await writeCards(nextCards);
});
return redirect("/admin");
} catch (error) {
return renderAdminWithMessage(error instanceof Error ? error.message : "Could not save card.");
@@ -278,16 +281,18 @@ const app = new Elysia()
}
try {
const form = await request.formData();
const id = String(form.get("id") || "");
const cards = await readCards();
const existing = cards.find((card) => card.id === id);
await withWriteLock(async () => {
const form = await request.formData();
const id = String(form.get("id") || "");
const cards = await readCards();
const existing = cards.find((card) => card.id === id);
if (existing?.imageUrl) {
await deleteRemoteImage(existing.imageUrl);
}
if (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");
} catch (error) {
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 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 async function readCards(): Promise<BoardCard[]> {
if (hasBlobToken()) {
try {
const { blobs } = await list({ prefix: dataBlobPath, limit: 1 });
if (blobs.length === 0) return [];
const { blobs } = await list({ prefix: dataBlobPath, limit: 1 });
if (blobs.length === 0) return [];
const res = await fetch(blobs[0].url);
if (!res.ok) return [];
const res = await fetch(blobs[0].url);
if (!res.ok) throw new Error(`Blob read failed: ${res.status}`);
const parsed = (await res.json()) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
const parsed = (await res.json()) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
}
try {
const text = await readFile(localCardsPath, "utf8");
const parsed = JSON.parse(text) as BoardCard[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
} catch (err: any) {
if (err?.code === "ENOENT") return [];
throw err;
}
}