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:
2026-08-17 10:41:24 -05:00
co-authored by Claude Opus 5
parent 5ba33899c2
commit c77fdad2b9
15 changed files with 332 additions and 47 deletions
+20 -4
View File
@@ -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)