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:
+22
-17
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user