Fix five high-severity bugs from the full-session code review

- ProductSync: bsc_work.wc_product_id could go stale if a product was ever
  deleted out-of-band (wp-admin, a cleanup script). wc_get_product() then
  correctly detects "no product" and creates a new one, but the repoint was
  gated behind `if (!$existing_id)` — which was already true, so the stale
  ID never got corrected. Every future sync repeated this, one duplicate
  product per run. Fixed by making the repoint unconditional (cheap,
  idempotent UPDATE either way). Reproduced the exact scenario against dev
  (deleted a product out-of-band, ran sync-products twice) and confirmed:
  one repoint, zero duplicates, product count and per-work product count
  both correct across repeated runs.

- Commands.php (sync-hardcover-tags): wp_set_object_terms() with an empty
  array clears the taxonomy rather than leaving it alone — verified
  directly. Hardcover legitimately returns no moods/content-warnings for
  plenty of books, so a --force re-sync could silently wipe existing tags,
  including anything hand-tagged. Fixed by skipping the call per-category
  when that category's array is empty. Verified via a direct eval test:
  pre-existing genre tag survives a sync where genre comes back empty,
  mood still gets set normally.

- docker-compose.yml: two stray root-owned secrets/db_password and
  secrets/db_root_password directories were already sitting on disk —
  Docker auto-creating a bind-mount source as a directory from an earlier
  manual `docker compose` call that ran without ENVIRONMENT exported
  (reproducing the exact bug already fixed once this session). Removed
  the stray dirs and changed every `${ENVIRONMENT}` in a volume mount to
  `${ENVIRONMENT:?ENVIRONMENT must be set}` so Compose now hard-fails
  instead of silently defaulting to empty. Verified: unset ENVIRONMENT now
  fails config validation with a clear error; set, it still works.

- HARDCOVER_API_TOKEN moved to the same _FILE secrets pattern already used
  for DB_PASSWORD/DB_ROOT_PASSWORD (docker-compose.yml, deploy.sh,
  HardcoverAdapter.php) — it was a live, consumed secret still going
  through Compose's ${VAR} interpolation, exposed to the same
  mangling bug already fixed for the DB passwords, plus visible via
  `docker inspect`. deploy.sh now writes secrets/<env>/hardcover_api_token
  (optionally empty). Verified via deploy.sh dev + wp eval: empty file ->
  is_configured() false, a real token value -> true.

- backup.sh: mariadb-dump had no --single-transaction, so a dump against a
  live site would either table-lock for its duration or produce a
  non-atomic/inconsistent dump. Added; verified a real dump still runs
  clean and produces a valid, restorable-looking .sql.gz.
This commit is contained in:
2026-08-27 15:40:25 -04:00
parent 05e97b53f6
commit df34fe8e54
6 changed files with 75 additions and 21 deletions
@@ -174,10 +174,23 @@ class Commands
$book = HardcoverAdapter::find_book($isbn13, $work->title, $work->primary_author);
if ($book) {
$tags = HardcoverAdapter::tags_for_book($book);
wp_set_object_terms($product_id, $tags['genre'], TagTaxonomies::GENRE, false);
wp_set_object_terms($product_id, $tags['mood'], TagTaxonomies::MOOD, false);
wp_set_object_terms($product_id, $tags['content_warning'], TagTaxonomies::CONTENT_WARNING, false);
wp_set_object_terms($product_id, $tags['tag'], TagTaxonomies::TAG, false);
// wp_set_object_terms() with an empty array CLEARS the
// taxonomy rather than leaving it untouched — verified
// directly. Hardcover legitimately returns no moods/
// content-warnings for plenty of books, and the schema
// here is still unverified, so an empty category must
// not be treated as "wipe whatever was there before"
// (including anything hand-tagged by an admin).
foreach ([
TagTaxonomies::GENRE => $tags['genre'],
TagTaxonomies::MOOD => $tags['mood'],
TagTaxonomies::CONTENT_WARNING => $tags['content_warning'],
TagTaxonomies::TAG => $tags['tag'],
] as $taxonomy => $terms) {
if (!empty($terms)) {
wp_set_object_terms($product_id, $terms, $taxonomy, false);
}
}
$matched++;
} else {
$missed++;
@@ -21,7 +21,25 @@ class HardcoverAdapter
public static function is_configured(): bool
{
return (bool) getenv('HARDCOVER_API_TOKEN');
return self::token() !== null;
}
/**
* Read from the file HARDCOVER_API_TOKEN_FILE points at, not a plain
* ${VAR}-style env var — same reasoning as DB_PASSWORD/DB_ROOT_PASSWORD
* (see docker-compose.yml): Compose's ${VAR} interpolation mangles any
* value containing `$` followed by a letter, and a plain env var is also
* visible via `docker inspect`/`/proc/<pid>/environ` to anything with
* host/container access, unlike a 644 file inside a 700 directory.
*/
private static function token(): ?string
{
$file = getenv('HARDCOVER_API_TOKEN_FILE');
if (!$file || !is_readable($file)) {
return null;
}
$value = trim((string) file_get_contents($file));
return $value !== '' ? $value : null;
}
/**
@@ -129,9 +147,9 @@ class HardcoverAdapter
private static function query(string $query, array $variables = []): array
{
$token = getenv('HARDCOVER_API_TOKEN');
$token = self::token();
if (!$token) {
throw new \RuntimeException('HARDCOVER_API_TOKEN is not set');
throw new \RuntimeException('HARDCOVER_API_TOKEN_FILE is not set or empty');
}
$response = wp_remote_post(self::ENDPOINT, [
@@ -165,9 +165,16 @@ class ProductSync
$product_id = $product->save();
update_post_meta($product_id, '_bsc_work_id', $work->work_id);
if (!$existing_id) {
Work::set_product_id((int) $work->work_id, $product_id);
}
// Unconditional, not "if new": $existing_id can be a stale pointer to
// a product that was deleted out-of-band (wp-admin, a cleanup script,
// anything that didn't also clear bsc_work.wc_product_id). In that
// case wc_get_product() above returned false, a *new* product was
// just created, and the guard this replaced (`if (!$existing_id)`)
// would have skipped repointing bsc_work at it — leaving the stale ID
// in place forever and creating another duplicate on every future
// run. This UPDATE is idempotent, so making it unconditional costs
// nothing on the normal "already correct" path.
Work::set_product_id((int) $work->work_id, (int) $product_id);
self::sync_categories($product_id, $work->subjects ?? null);