The shop can be backed up while it is running, and the copy is proved good before it is kept.
Why. Everything this app is — the catalog, the orders, the payments, the gift card balances, the stock ledger — is one SQLite file under FORGE_APP_DATA. There is no copy of it, no way for an admin to take one, and nothing that would notice if the file went bad. "The money trail is complete" is a promise about rows that only exist in one place.
Done looks like
[ ] src/backup.ts: one concept — make a consistent copy of the live database with VACUUM INTO (which is safe while the app is serving) into FORGE_APP_DATA/backups/bazaar-<UTC stamp>.sqlite, and nothing is ever written outside FORGE_APP_DATA.
[ ] The copy is proved before it counts: open it read-only, run PRAGMA integrity_check, and count the orders and payments against the live database; a copy that does not match is deleted and the failure is a row, not a silent success.
[ ] A retention rule — keep the last N (say seven) and delete the rest — and a size in the listing, so the disk cannot fill quietly.
[ ] /admin/backups: the list with when, how big and whether it verified, a Back up now POST (same-origin, CSRF, admin only, one at a time — a second press while one runs says so rather than starting two), and a download link per file.
[ ] Downloading is treated as what it is: the whole shop's customer data and every gift card's HMAC in one file. Admin only, no-store, content-disposition: attachment, rate limited, and the download is a row naming who took it.
[ ] The same two actions in the terminal (src/desk.ts), admin only: make one, list them.
[ ] A scheduled copy on an unref'd timer (daily), skipped when one is already running, so a forge restart never leaves a lock behind; it runs after the response, never on a request's path.
[ ] The README says where backups live, how long they are kept, how to take one, and — plainly — how to restore: stop the app, put the file in place of bazaar.sqlite, start it.
[ ] Tests: a backup of a seeded database verifies and has the same row counts, a corrupted copy is rejected and deleted, retention keeps exactly N, two at once does not run twice, and a non-admin gets the 403 page.
Files I expect to change:src/backup.ts (new), src/db.ts (the path and a read-only open), src/admin.ts, src/pages/admin.ts, src/routes.ts, src/desk.ts, src/server.test.ts, src/units.test.ts, README.md.
Risks. A backup file is a complete copy of every customer's name, address and email and of every gift card's stored digest: the route that serves it is the most sensitive in the app, and the filename must be built by us, never from a query parameter — path traversal here is the whole shop. VACUUM INTO writes as much again as the database is big, so check the retention rule before making the copy, not after. A timer that holds the process open is a deploy that never finishes: unref it.
Whose brief fits:ledger to build, sentinel to read the download route and the filename handling.
The shop can be backed up while it is running, and the copy is proved good before it is kept.
Why. Everything this app is — the catalog, the orders, the payments, the gift card balances, the stock ledger — is one SQLite file under
FORGE_APP_DATA. There is no copy of it, no way for an admin to take one, and nothing that would notice if the file went bad. "The money trail is complete" is a promise about rows that only exist in one place.Done looks like
src/backup.ts: one concept — make a consistent copy of the live database withVACUUM INTO(which is safe while the app is serving) intoFORGE_APP_DATA/backups/bazaar-<UTC stamp>.sqlite, and nothing is ever written outsideFORGE_APP_DATA.PRAGMA integrity_check, and count the orders and payments against the live database; a copy that does not match is deleted and the failure is a row, not a silent success./admin/backups: the list with when, how big and whether it verified, a Back up now POST (same-origin, CSRF, admin only, one at a time — a second press while one runs says so rather than starting two), and a download link per file.no-store,content-disposition: attachment, rate limited, and the download is a row naming who took it.src/desk.ts), admin only: make one, list them.unref'd timer (daily), skipped when one is already running, so a forge restart never leaves a lock behind; it runs after the response, never on a request's path.bazaar.sqlite, start it.Files I expect to change:
src/backup.ts(new),src/db.ts(the path and a read-only open),src/admin.ts,src/pages/admin.ts,src/routes.ts,src/desk.ts,src/server.test.ts,src/units.test.ts,README.md.Risks. A backup file is a complete copy of every customer's name, address and email and of every gift card's stored digest: the route that serves it is the most sensitive in the app, and the filename must be built by us, never from a query parameter — path traversal here is the whole shop.
VACUUM INTOwrites as much again as the database is big, so check the retention rule before making the copy, not after. A timer that holds the process open is a deploy that never finishes:unrefit.Whose brief fits: ledger to build, sentinel to read the download route and the filename handling.