From 5033ce8b4de3debbcfbe824ed8c2bf1a2eb1e448 Mon Sep 17 00:00:00 2001 From: dexx Date: Fri, 5 Jun 2026 22:53:17 +0300 Subject: [PATCH] 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 --- apps/api/src/index.ts | 39 ++++++++++++++++++++++----------------- apps/api/src/store.ts | 29 +++++++++++++++++------------ 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c051e44..8af50d5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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."); diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index b210ddf..432c175 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -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 = Promise.resolve(); + +export function withWriteLock(fn: () => Promise): Promise { + const next = writeLock.then(fn, fn); + writeLock = next.then(() => {}, () => {}); + return next; +} + export const storageMode = hasBlobToken() ? "Vercel Blob" : "Local file"; export async function readCards(): Promise { 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; } }