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,
|
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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user