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
+9 -8
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router();
@@ -36,7 +37,7 @@ async function parentExists(id: number): Promise<boolean> {
return rows.length > 0;
}
router.get('/', async (_req: Request, res: Response) => {
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT c.id, c.name, c.parent_id, c.sort_order,
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
@@ -44,9 +45,9 @@ router.get('/', async (_req: Request, res: Response) => {
ORDER BY c.sort_order, lower(c.name)`
);
res.json(rows);
});
}));
router.post('/', async (req: Request, res: Response) => {
router.post('/', asyncRoute(async (req: Request, res: Response) => {
const name = readName(req.body.name);
if (!name) {
return res.status(400).json({ error: 'name is required' });
@@ -76,9 +77,9 @@ router.post('/', async (req: Request, res: Response) => {
}
throw err;
}
});
}));
router.put('/:id', async (req: Request, res: Response) => {
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id);
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
if (!existing.rows.length) {
@@ -134,9 +135,9 @@ router.put('/:id', async (req: Request, res: Response) => {
}
throw err;
}
});
}));
router.delete('/:id', async (req: Request, res: Response) => {
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id);
const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
if (!subtree.length) {
@@ -154,6 +155,6 @@ router.delete('/:id', async (req: Request, res: Response) => {
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
});
}));
export default router;