twooey 9253c6638f Fix a real regression plus three hardening gaps found in a follow-up review
A second review pass specifically targeted at the fixes just made (not the
original codebase) — caught one genuine regression from this session's own
earlier work, plus a couple of gaps the first pass didn't probe deeply
enough to find.

- REGRESSION: SupplierOffer::insert() throwing on failure (a Medium-severity
  fix earlier this session, matching Work/Edition/Isbn) was never given a
  catch anywhere in the offer-generation path. Before that fix, a bad
  insert silently continued; after it, nothing stopped the exception from
  aborting the ENTIRE generate-offers run on the first failure. Fixed with
  the same per-item catch pattern GutenbergImporter already established.
  Verified by forcing a real DB failure (renamed the table mid-run): before
  this fix that aborted the batch immediately; after, it correctly
  processed all 2000 ISBNs, reported each failure individually, and
  completed with an accurate failed count — table restored after, 2000
  offers confirmed intact.

- ProductSync::sync_one() never checked WC_Product::save()'s return value.
  Verified directly (forced via the wp_insert_post_empty_content filter):
  save() returns 0 rather than throwing on a wp_insert_post()-level
  rejection, which would have gone through as Work::set_product_id($id, 0)
  — silently "succeeding" and invisible in $failed[], inconsistent with
  every other failure mode in this method already routing through the
  per-product SAVEPOINT + $failed[] reporting added this session. Now
  throws instead, confirmed it does NOT corrupt the existing pointer (stays
  at its prior value, self-heals on a future successful run) rather than
  writing 0.

- docker-compose.yml: the wordpress healthcheck's timeout budget (~130s)
  could plausibly be exceeded by a legitimately slow (not broken) first
  boot on the 2-core/8GB box this actually runs on — confirmed that a
  service_healthy dependency timing out makes `docker compose up -d` (and
  so deploy.sh, under set -euo pipefail) hard-fail rather than just start
  late, a new deploy failure mode this session's healthcheck introduced.
  Widened to ~4.5 minutes of headroom (retries 20->40, start_period
  30s->60s), well above deploy.sh's own existing 120s precedent for just
  the core-file-copy portion of the same boot.

- Makefile: the per-target `; rc=$?; rm -f ...; exit $rc` cleanup added
  earlier this session doesn't reliably run under a real Ctrl+C — a
  foreground SIGINT terminates that shell chain before the `;` continues.
  Switched to a `trap 'rm -f ...' EXIT` (matching the idiom deploy.sh/
  backup.sh already use), which fires on any shell termination and doesn't
  need to manually thread the exit code through. Verified by actually
  sending SIGINT to the whole process group (matching real terminal Ctrl+C,
  not just a backgrounded job) during `make logs` — the compose env file
  was correctly removed.

Also cleaned up one duplicate leftover product (work_id=1 briefly had two
products, from repeated manual test scenarios reusing the same work_id
across this long session's testing, not from an active bug — confirmed the
current code produces 0 new duplicates on repeated sync-products runs)
found while verifying the ProductSync fix above.
2026-08-27 16:51:58 -04:00

Bookstore

WordPress + WooCommerce bookstore. Design reference: see docs/ for the pre-launch plan and the technical design doc (bookstore-core schema, interfaces, order state machine — the design doc is the source of truth for why this repo is laid out the way it is).

This week's scope (Week 1 of the 5-week plan): environments, deploy pipeline, and the WooCommerce/HPOS plugin scaffold. Catalog schema, pricing, supplier adapters, and the order state machine are Week 2+ and live under wp-content/plugins/bookstore-core/includes/, currently empty.

Layout

docker-compose.yml              base services: db, redis, wordpress, cron
docker-compose.{dev,staging,production}.yml   per-environment overrides (caddy, mailhog)
docker/php/                     app image: WP core image + wp-cli, composer, redis ext
docker/caddy/                   one Caddyfile per environment
docker/cron/                    Action Scheduler driver (system cron has no host to run on in Docker)
deploy/deploy.sh                idempotent bring-up + WP/WooCommerce config
deploy/poll-deploy.sh           host-crontab script: redeploys when its branch moves on Gitea
deploy/backup.sh, restore.sh    off-host backup; restore is staging-only, on purpose
secrets/<env>/                  DB passwords, written fresh by deploy.sh — gitignored, not manually edited
wp-content/plugins/bookstore-core/   the one plugin that owns business logic
wp-content/mu-plugins/          local-mail-catcher.php — routes mail to MailHog outside production

Same code runs in every environment; only .env.<env> and which docker-compose.<env>.yml you layer in differ (design doc §01).

Local development

cp .env.example .env.dev        # fill in DB_PASSWORD etc.; localhost values are fine
make up ENV=dev
make deploy ENV=dev             # installs WP, WooCommerce, activates bookstore-core, enables HPOS

Site is at http://localhost:8080. make wp ENV=dev ARGS="plugin list" runs any wp-cli command; make logs ENV=dev tails everything; make shell ENV=dev drops into the app container.

Staging (this is what goes on your Docker server)

cp .env.example .env.staging
# fill in SITE_DOMAIN, SITE_URL, DB_*
# SITE_DOMAIN must be a HOSTNAME, not a bare IP — see below

make deploy ENV=staging

SITE_DOMAIN needs to be a hostname, not the VM's IP directly. TLS SNI (how Caddy picks which certificate to present) isn't sent for literal IP connections per spec, so HTTPS to a bare IP fails at the handshake itself no matter what Caddy does. Pick any hostname (e.g. bookstore.lan), add it to /etc/hosts (or the Windows equivalent) on whatever machine you're browsing from, pointing at the VM's LAN IP, and use that hostname as both SITE_DOMAIN and in SITE_URL.

Caddyfile.staging forces tls internal — Caddy's own self-signed CA, issued locally with no external network calls — rather than letting Caddy guess whether the name looks "public" (its automatic heuristic only recognizes bare IPs and localhost as obviously-private; anything else, including a made-up LAN hostname, it assumes might be real and tries Let's Encrypt, which then fails). Your browser will show an untrusted-cert warning once; click through it, or trust Caddy's root cert to skip that:

docker compose -p bookstore-staging exec caddy cat /data/caddy/pki/authorities/local/root.crt

Staging is noindex'd (blog_public=0 plus the X-Robots-Tag header in Caddyfile.staging) so search engines won't index it — there's no basic-auth wall on top of that, since this box is LAN-only and not reachable from outside. If that ever changes (a public domain, port-forwarding, etc.), basic-auth is worth adding back before that happens, not after. Mail never leaves the box: it's caught by MailHog, viewable at :8025.

Theme: Blocksy + the Book Store starter site

deploy.sh installs and activates the Blocksy theme and the free Blocksy Companion plugin from wordpress.org automatically — nothing to do here, in any environment.

The Book Store starter site itself is a paid Companion Pro template (Business plan, $99/year — design doc Appendix A), so it can't be scripted against a public API the way the free theme can. One-time manual step per environment, in wp-admin:

  1. Purchase/retrieve the Blocksy Business license key.
  2. Blocksy → General → License → activate it.
  3. Blocksy → Extensions → Starter Sites (Companion Pro) → import Book Store.

After that, the starter site's content and Customizer settings persist in the database like any other WordPress content — a redeploy or a fresh deploy.sh run doesn't touch it or need to repeat it.

Deploy pipeline

The Git server (Gitea) and the staging/production Docker hosts are separate machines, so instead of a webhook receiver listening for an inbound POST from Gitea, each box just polls its branch and redeploys when it moves — no inbound port to expose or secure.

  1. Clone this repo onto the box (e.g. into /srv/bookstore), cp .env.example .env.staging (or .env.production) and fill it in.
  2. Add a host crontab entry (not inside a container — crontab -e on the box itself):
    */2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1
    
    (production + main on the production box.)
  3. poll-deploy.sh fetches, compares against the last-deployed commit, and — only if it moved — checks out the branch and runs deploy.sh. Silent otherwise, so it's safe to run every couple of minutes.
  4. The box needs its own git credentials to fetch from Gitea (a read-only access token works well) — set those up once via a credential helper or an embedded token in that box's origin remote.

You can also just run ./deploy/deploy.sh <env> by hand at any time; the poll script is only automation on top of the same idempotent script.

Backups

./deploy/backup.sh staging       # or production

Dumps the database and archives wp-content/uploads, gzips both, and syncs to $BACKUP_REMOTE via rclone if set (configure rclone config on the server first — this repo doesn't manage remote credentials). Before launch, run a real restore drill:

./deploy/restore.sh ./backups/production/db-<ts>.sql.gz ./backups/production/uploads-<ts>.tar.gz

restore.sh only ever targets staging — there's no production argument, so a restore drill can't accidentally overwrite the live site.

What's deliberately not here yet

  • Catalog tables, pricing engine, supplier adapters, order state machine — Week 24, see the design doc.
  • A real TLS-terminating public domain — Caddy will auto-provision certs once SITE_DOMAIN points at a real host with 80/443 reachable; until then docker-compose.dev.yml is the only one that works from localhost.
  • CI test/lint automation — nothing here runs tests yet because there's no application code to test yet.
S
Description
No description provided
Readme
367 KiB
Languages
PHP 85.3%
Shell 12.7%
Makefile 1.5%
Dockerfile 0.5%