fix: run migrations on boot and stop failures rendering as empty (#23)
The storefront showed no inventory after deploying the categories/tags release. No data was lost: the code queried categories/item_tags/ items.category_id against a database where the migration had not been run, and that failure was invisible at every layer. Three changes, each addressing one layer: Migrations now run at container start, so deployed code cannot be ahead of the schema and the easily-forgotten manual `docker exec migrate.js up` step disappears. migrate.js waits for Postgres to accept connections first, since the NAS brings the DB container up slower than the app, and still exits non-zero so a bad migration stops the container rather than serving a half-migrated schema. Express 4 does not forward a rejected async handler, and no error middleware was mounted, so a failing query never responded at all. Async routes are now wrapped and an error middleware guarantees a 500. A hung request is indistinguishable from an empty result in the UI, which is how a schema mismatch came to read as "the store has no items". The storefront now separates "request failed" from "no items" and offers a retry. fetchItems/fetchFilterOptions throw on a non-OK response rather than returning the parsed error body, which would have been set as the item list and crashed the grid on .map. Also restores the project-context update from 7fb5764, which was left out of PR #24 and ended up dangling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+20
-4
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd';
|
||||
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty, Alert } from 'antd';
|
||||
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||
@@ -27,6 +27,7 @@ const FILTER_DEBOUNCE_MS = 250;
|
||||
export default function App() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -55,7 +56,14 @@ export default function App() {
|
||||
|
||||
const load = useCallback(() => {
|
||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||
.then(setItems)
|
||||
.then((loaded) => {
|
||||
setItems(loaded);
|
||||
setFailed(false);
|
||||
})
|
||||
// A failed request must never fall through to the empty state: telling a
|
||||
// customer "no items yet" when the server is broken hides the outage and
|
||||
// reads as an empty shop.
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterKey]);
|
||||
|
||||
@@ -124,8 +132,16 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && !items.length ? <Spin /> : null}
|
||||
{!loading && !items.length ? (
|
||||
{loading && !items.length && !failed ? <Spin /> : null}
|
||||
{failed ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Couldn't load items"
|
||||
description="The server didn't return the catalogue. This is usually temporary."
|
||||
action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>}
|
||||
/>
|
||||
) : !loading && !items.length ? (
|
||||
<Empty
|
||||
description={
|
||||
hasActiveFilters(filters)
|
||||
|
||||
@@ -54,11 +54,16 @@ export async function fetchConfig(): Promise<SiteConfig> {
|
||||
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
||||
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
||||
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
||||
// An error response still parses as JSON — as `{ error: ... }`, not an array.
|
||||
// Returning that unchecked would set it as the item list and crash the grid
|
||||
// on `.map`, so a failure has to surface as a rejection the caller can show.
|
||||
if (!res.ok) throw new Error('failed to load items');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
||||
const res = await fetch('/api/filters');
|
||||
if (!res.ok) throw new Error('failed to load filters');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Storefront failure states', () => {
|
||||
test('reports a server failure instead of claiming the store is empty', async ({ page }) => {
|
||||
await page.route('**/api/items*', (route) =>
|
||||
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' })
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Telling a customer "no items yet" when the server is broken is worse than
|
||||
// saying nothing — it reads as an empty catalogue and hides the outage.
|
||||
await expect(page.getByText('No items yet')).toBeHidden();
|
||||
await expect(page.getByText("Couldn't load items")).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('recovers when the server comes back', async ({ page }) => {
|
||||
let failing = true;
|
||||
await page.route('**/api/items*', (route) => {
|
||||
if (failing) {
|
||||
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
||||
|
||||
failing = false;
|
||||
await page.getByRole('button', { name: 'Retry' }).click();
|
||||
|
||||
await expect(page.getByText("Couldn't load items")).toBeHidden();
|
||||
});
|
||||
|
||||
test('a request that never resolves does not render as an empty catalogue', async ({ page }) => {
|
||||
// Mirrors the real incident: an un-migrated database left every item query
|
||||
// hanging with no response at all.
|
||||
await page.route('**/api/items*', () => { /* never fulfilled */ });
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('No items yet')).toBeHidden();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user