- SupplierOffer::insert(): now checks $wpdb->insert()'s return value and
throws, matching Work/Edition/Isbn (the one Catalog class that hadn't
been hardened this way). Work::set_product_id() gets the same treatment
(was flagged low-severity but same fix, bundled here) — a real update
failure now counts as a per-work sync failure instead of silently
leaving a stale wc_product_id pointer.
- SupplierOffer: fetched_at was written via current_time('mysql') (site-
local) while expires_at (SyntheticOfferGenerator) is written in UTC, and
best_offer_for_isbns() compared against site-local time too — a mismatch
masked today only because dev's gmt_offset is 0. Switched both writer and
reader to current_time('mysql', true) (UTC). Verified the read path
still finds all active offers correctly under a simulated -5 (US
Eastern) offset, not just at offset 0.
- HardcoverAdapter: rate-limit throttling moved from "once per work" (in
Commands.php) to "once per actual HTTP request" (inside query() itself).
find_book()'s ISBN-then-title/author fallback can fire two real requests
per work — under the old scheme both shared one throttle sleep, roughly
doubling the real request rate against a beta API. query() also now
retries network errors/5xx/429 up to 3x (mirroring
OpenLibraryAdapter::get_with_retry()), while a GraphQL-level `errors` field
or other 4xx throws immediately (retrying a rejected query can't fix it).
Commands.php adds a 5-consecutive-failure circuit breaker so a bad token
or a wrong field in the still-unverified schema can't silently burn
through the whole catalog with zero progress. Verified all of this
directly against Hardcover's real API with a deliberately invalid token:
5 fast (non-retried) 401s, correct abort message, and confirmed the
failed works were NOT marked synced (so a real token can retry them).
- restore.sh: now drops and recreates the target database before restoring
the dump, and clears the uploads directory before extracting the
archive — previously both restored on top of existing state, so a stray
table or file NOT in the backup would silently survive a restore drill.
Verified end-to-end against a real local staging stack: planted a stray
table and a stray upload file after taking a backup, ran restore.sh, and
confirmed both were gone afterward while the actual backed-up data (20
works, a known upload file) came back correctly. Also fixed a real
permission gap hit during that same test: a fresh volume's uploads dir
is root-owned until something chowns it, which broke the new www-data
clear step — now clears as root and chowns to www-data afterward, which
also means restore self-heals the exact root-owned-uploads class of bug
fixed earlier this session for the cron sidecar.
- poll-deploy.sh: added a non-blocking flock so a deploy that runs longer
than the cron interval can't have a second poll fire mid-deploy and race
its git checkout/reset against the same live working tree. Verified: a
concurrent run correctly skips instantly while the lock is held, and
proceeds normally once it's released. (Full atomicity of the live PHP
file swap under real traffic is a bigger architectural question —
blue-green or symlinked releases — flagged to the user rather than
attempted here.)
- backup.sh: now also archives .env.<environment> itself (chmod 600) and
includes it in the off-host rclone sync alongside the DB dump and
uploads archive. Every API key and both DB passwords previously lived
only on the host in this one gitignored file — losing the host lost all
of it even with DB/uploads backups intact.
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:
- Purchase/retrieve the Blocksy Business license key.
- Blocksy → General → License → activate it.
- 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.
- Clone this repo onto the box (e.g. into
/srv/bookstore),cp .env.example .env.staging(or.env.production) and fill it in. - Add a host crontab entry (not inside a container —
crontab -eon the box itself):(*/2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1production+mainon the production box.) poll-deploy.shfetches, compares against the last-deployed commit, and — only if it moved — checks out the branch and runsdeploy.sh. Silent otherwise, so it's safe to run every couple of minutes.- 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
originremote.
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 2–4, see the design doc.
- A real TLS-terminating public domain — Caddy will auto-provision certs
once
SITE_DOMAINpoints at a real host with 80/443 reachable; until thendocker-compose.dev.ymlis the only one that works fromlocalhost. - CI test/lint automation — nothing here runs tests yet because there's no application code to test yet.