Appearance
Catalog updates — new sets, new cards
Operational runbook for keeping the backend in sync with upstream when new cards or new expansions ship. Read this when a new Pokémon set drops, when Bandai releases an OP starter deck, when Wizards prints a Standard set — anything that changes the universe of scannable cards.
TL;DR
- All ingest + embed steps are idempotent. Re-running the same command after a partial run picks up exactly where it left off; a full re-run after a new set adds the delta and skips everything already on disk + in DB.
- A new set takes ~1-5 min of operator time depending on the TCG. The slowest part is embedding new cards (~10 cards/s on the DINOv2 sidecar).
One asymmetry: Pokémon and One Piece scans rely on Tormknd's FAISS index (frozen at our container build). New cards land in our DB but won't resolve throughResolved 2026-05-07 — Phase F flipped the RN app to/scan-compatuntil Tormknd rebuilds./scan-labfor all three TCGs, so the asymmetry is gone. New cards from any TCG are scannable immediately after embed.
End-user impact when a new set drops
The mobile-app user interacts with two things: the catalog (browsing cards, viewing metadata + image) and the scanner (take a photo, get an identification). Those two functions touch different storage axes that don't refresh at the same rate.
| What the user does | Data layer | Available once… | Operator step |
|---|---|---|---|
| Browse the catalog by set / rarity | cards + sets rows in Postgres | Ingest finishes (~30 s - 1 min) | INGEST_TCG=… ingest |
| See a card's image in its detail screen | data/images/<set>/<num>/large.jpg files | Ingest finishes (download included) | INGEST_TCG=… ingest (no extra flag) |
| Search by name | Trigram index on cards.name | Ingest finishes (the trigram index auto-maintains) | INGEST_TCG=… ingest |
| Scan a card from the new set (MTG) | card_embeddings_lab vectors (pgvector HNSW) | Embed finishes (~30 s - 5 min depending on set size) | INGEST_LAB_TCG=mtg embedCatalogLab |
| Scan a card from the new set (Pokémon / OP) | Tormknd's FAISS index (frozen at build) | Tormknd rebuilds his repo OR we flip to /scan-lab (Phase F) | Nothing on our side suffices today |
In short, for a new set:
- MTG → Zero friction. Ingest + embed (~1-2 min total) = scannable and browsable.
- Pokémon / OP → Catalog + images + name search: instant after ingest. Scan via the app: depends on Tormknd, or wait for Phase F.
This asymmetry is the main friction we're trying to remove with Phase F. Until that's done, scanning a freshly-released Pokémon booster card will return "no-match" on the mobile app, even though we have the data in DB.
Lifecycle: from upstream release to scannable
Day 0 Day 0 - 7 Day 0 (us) Day 0+ (us)
[Set released] → [Upstream publishes] → [Operator runs ingest] → [Operator runs embed] → [Scannable]
(Wizards / Bandai / (Scryfall / (~10 s - 1 min (~30 s - 5 min)
The Pokémon TCGdex / per TCG)
Company) OPTCG API)The dashed gap between "set released" and "upstream publishes" varies a lot:
- Scryfall: same-day or next-day refresh of
default_cardsbulk. Best in class. - TCGdex: typically 2-7 days after the official Pokémon release. Community-driven.
- OPTCG API: usually same-week as Bandai's release.
Per-TCG cadence and commands
| TCG | Source | When upstream sees the new set | Our command | Time |
|---|---|---|---|---|
| Magic | Scryfall default_cards bulk | Day 0 (often within hours of the set going live) | INGEST_TCG=mtg ingest then INGEST_LAB_TCG=mtg embedCatalogLab | ~1 min ingest + ~30 s embed per ~300-card set |
| Pokémon | TCGdex public API | 2-7 days post-release | INGEST_TCG=pokemon INGEST_SETS=<id> ingest then embed | ~30 s ingest + ~30 s embed per ~200 cards |
| One Piece | OPTCG API (optcgapi.com) | Same week as Bandai | INGEST_TCG=onepiece INGEST_SETS=<id> ingest then embed | ~10 s ingest + ~30 s embed per ~200 cards |
Step-by-step recipes
All commands run from the tcg-collector-backend repo root, with .env populated.
Magic — a new set just dropped on Scryfall
bash
# 1. Re-ingest. The bulk file is auto-refreshed if Scryfall has a newer
# `updated_at`, so this picks up new cards transparently. Existing
# images on disk are skipped.
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=mtg ingest
# 2. Embed only the new cards (the NOT EXISTS filter skips already-embedded).
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" \
-e INGEST_LAB_TCG=mtg ingest
# 3. Smoke — pick a card from the new set, scan it via /scan-lab.
curl -s -F "photo=@/path/to/test.jpg" http://<vps>:8080/scan-lab | jq .matchPokémon — a new set just landed on TCGdex
bash
# 1. Re-ingest only the new set (use INGEST_SETS=all if you want a full sweep).
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=pokemon -e INGEST_SETS=<set-id> ingest
# 2. Embed for /scan-lab path. Until Phase F, /scan-compat won't see the
# new cards because Tormknd's FAISS is frozen — but when we flip the
# RN app to /scan-lab, the new cards become scannable.
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" \
-e INGEST_LAB_TCG=pokemon -e INGEST_LAB_SETS=<set-id> ingest
# 3. Smoke — same as Magic.One Piece — a new booster set or starter deck
bash
# 1. Find the set id on optcgapi.com (e.g. OP-16 for the next booster).
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=onepiece -e INGEST_SETS=<set-id> ingest
# 2. Embed.
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" \
-e INGEST_LAB_TCG=onepiece -e INGEST_LAB_SETS=<set-id> ingest
# 3. Smoke. Note: ST starter decks are still excluded post-#101 revert
# until variant logic is exercised on them — see scan-lab-plan.md.Post-update verifications
Did the rows land?
sql
-- Per-TCG card + set counts (run inside the postgres container).
SELECT tcg, count(*) AS cards, count(DISTINCT set_id) AS sets
FROM cards GROUP BY tcg;
-- New cards from the last 24h.
SELECT id, name, set_id FROM cards
WHERE fetched_at > now() - interval '24 hours'
ORDER BY fetched_at DESC LIMIT 20;Did embeddings land?
sql
-- Should match (cards count - cards without an image).
SELECT count(*) FROM card_embeddings_lab ce
JOIN cards c ON c.id = ce.card_id
WHERE c.tcg = '<tcg>';Self-recall sanity check
Pick any indexed card, query its own embedding, expect sim ≈ 1.0:
sql
WITH q AS (SELECT embedding FROM card_embeddings_lab WHERE card_id = '<some-id>' LIMIT 1)
SELECT ce.card_id, c.name, (1 - (ce.embedding <=> q.embedding) / 2)::numeric(5,4) AS sim
FROM card_embeddings_lab ce
JOIN cards c ON c.id = ce.card_id, q
WHERE c.tcg = '<tcg>'
ORDER BY ce.embedding <=> q.embedding
LIMIT 3;The first row should have sim = 1.0000. If it doesn't, the embed step missed it — re-run with EMBEDDINGS_FORCE=true for that subset to overwrite stale vectors.
Edge cases
Errata / reprint with a new image
If a card gets re-illustrated (rare but happens for Pokémon promos and MTG Mystery Booster), the upstream image_uris URL changes but the id stays the same. Our ingest will:
- Upsert the
cardsrow (image_base updated) - Re-download the image (the disk path embeds the id — collision detected, file overwritten)
- NOT re-embed by default — the
NOT EXISTSskip means the old vector stays. Override withEMBEDDINGS_FORCE=trueto refresh embeddings for a known TCG.
A new variant for an existing card (OP parallel art, MTG showcase)
For OP, assignVariantIds already handles parallel-art collisions: a new entry sharing card_set_id with an existing one becomes id=<base>_v2 (or _v3, etc.) on next ingest. Disk path key is the variant-suffixed id, so no overwrite.
For MTG, Scryfall already emits a distinct collector_number per variant, so the variant lands as a fresh row with its own id (e.g. neo-100 vs neo-327 for showcase). No collision.
A new language for a set already ingested
Pokémon-only concern (TCGdex multi-lang). Re-running with INGEST_LANG=ja (or whatever new lang) lands the language-specific entries as separate rows, gated by the first-seen-wins de-dup in index.ts.
Tormknd FAISS lag for Pokémon / OP
Until Phase F flips the RN app to /scan-lab, scans for Pokémon and OP go through tcg-vision's baked FAISS. New cards we ingest will not be scannable through /scan-compat until either:
- Tormknd rebuilds his FAISS image folders + ships a new release; we bump
TCG_APP_COMMITand rebuild ourtcg-visionimage. Inertia depends on him. - We finish Phase F and route everything through
/scan-lab. Then a new Pokémon set is identical to a new MTG set: ingest + embed, done.
In the interim, the new cards are still useful for catalog browsing (they're in cards), just not for scanning via the live RN app.
Why everything is idempotent
Each layer of the pipeline has a deterministic skip rule, so re-running anywhere along the chain is safe:
| Layer | Skip rule |
|---|---|
| Set / card upserts | INSERT … ON CONFLICT (id) DO UPDATE — refreshes the row, never duplicates |
| Image download | if (await exists(abs)) return skipped — same path, second run skips disk write |
| Scryfall bulk download | Cached filename embeds updated_at; same manifest = same filename = re-use |
| Embedding | NOT EXISTS (… ce.card_id = c.id AND ce.model = $1) — skips already-embedded cards for that model |
The implication: you cannot break the catalog by re-running. The worst case is you waste a few seconds doing nothing. This is the property that makes a cron-driven nightly refresh practical.
Future automation (not yet implemented)
A nightly cron job could run all three TCG ingests + embeds:
bash
# /etc/cron.d/tcg-catalog-refresh (illustrative — not yet wired)
0 4 * * * cd /opt/tcg-collector-backend && \
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=mtg ingest >> /var/log/tcg-ingest.log 2>&1
0 5 * * * cd /opt/tcg-collector-backend && \
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" -e INGEST_LAB_TCG=mtg ingest \
>> /var/log/tcg-embed.log 2>&1Cost: ~2-5 min of compute per night per TCG when nothing's changed (Scryfall manifest check + skip), much more when a set drops. The bulk file cache + idempotent skip rules make this safe even if Claude / a human forgets to trigger it manually.
Troubleshooting
"I ingested but /scan-lab still says no-match"
You forgot the embed step. Ingest writes to cards + data/images/ only; without the embed step, card_embeddings_lab has no row for the new card and pgvector returns nothing. Run the embed command for that TCG.
"Embed reports done: 0"
The NOT EXISTS skip thinks everything's already embedded. If you really want to re-embed (e.g. after a Scryfall image refresh), set EMBEDDINGS_FORCE=true to bypass the skip.
"Upstream is returning 4xx / 5xx"
- Scryfall: rare; their bulk endpoint is robust. Check
https://api.scryfall.com/bulk-datamanually. - TCGdex: occasional; the ingest's retry-with-backoff handles transient failures. If it consistently fails for one set, that set's data may not yet be published.
- OPTCG API: their host (optcgapi.com) has had downtime; deferred Bandai L3 fallback (see
docs/INGEST.md§One Piece) would close that gap but isn't wired.
"I see fewer rows than expected"
Likely a collision after sanitization. For MTG specifically, ~1.7 % of default_cards rows collide with another printing after Unicode collector_number sanitization (100 and 100★ both become set-100). The image is identical between collided rows, so the scan still resolves correctly — but the row count is lower than the ingest log's "rowsKept" number.
See also
- Architecture overview — the big picture
- Card scan architecture — deeper component-by-component view
- Magic the Gathering ingest — the precedent for adding a new TCG
tcg-collector-backend/docs/INGEST.md— backend technical reference (pipeline, per-TCG sources, multi-language)