Read-only, enforced by SQLite
The database opens with mode=ro and query_only, so even the raw SQL tool can't write. SQLite enforces it, not a check on the query text.
Featured build · Go systems case study
LiveA Go CLI that prices an 8,800-card Pokémon collection on a free budget of 1,000 API requests a day, and never guesses which print a card is.
$ pricewatch run --budget 100 --no-wait run 3 (pokewallet) 38 requests, 52 cards ok 50 failed 2 deferred 0 4886 cards not due yet changed since each card's last price BEFORE NOW DELTA % 50.47 60.00 +9.53 +18.9 ex ruby & sapphire 59/109 reverse holo progress saved · next run resumes here
Why I built it
After nine years of C# backend work, I wanted to learn Go on something that would push back. My own collection, 8,800 rows exported from TCG Collector, gave me a real product with a hard constraint. Where two designs worked, I picked the one that exercised more Go.
The problem
The free price API allows 100 requests an hour and 1,000 a day. The collection can't be priced in one go, so pricewatch never tries. Each run is a bounded slice of work, and progress lives in SQLite so the next run carries on where the last one stopped.
How a run works
Import resolves the collection once. Every hour after that, a run picks the cards that are due, prices them within the rate limits, saves each observation and republishes the status page.
Step 1
Match each CSV row to one card by region, set, number, variant and language.
Step 2
Never priced first, then most overdue, then most valuable.
Step 3
Spend against the API's own remaining count, pause at the hour limit.
Step 4
A bounded worker pool. One request prices every variant of a card.
Step 5
One observation per card, plus every card that moved.
Step 6
Rebuild the public status page from derived numbers only.
Hourly loop ↶
GitHub Actions runs at seven past each hour. A missed hour just means the next run has a little more to do.
Value tiers
A card's value sets how often it's due. Pricier cards move more in dollars, so they're checked more often.
| Card value | Checked every |
|---|---|
| $100+ | 1 day |
| $20 to $100 | 2 days |
| $5 to $20 | 4 days |
| Under $5 | 7 days |
Daily budget
~915 / 1,000
requests a day to keep about 4,900 priceable cards on schedule.
What it refuses to guess
The export has no card IDs. When a row can't be matched to exactly one card, pricewatch reports it instead of picking one. On chase cards, the gap between prints dwarfs any price movement.
Tropius 001/084 · Normal
$0.06
Tropius 001/084 · Reverse Holo
$0.20
Ask it anything
pricewatch mcp is a local MCP server. Claude starts it, and seven read-only tools answer questions from the same database the hourly job writes.
Which Pokémon is worth the most across every card I own?
Pikachu, by a long way. 52 cards worth $2,849.93, and one of them, Pikachu with Grey Felt Hat, is over a third of that at $1,015.89.
| Pokémon | Cards | Value |
|---|---|---|
| Pikachu | 52 | $2,849.93 |
| Sylveon | 21 | $2,361.85 |
| Umbreon | 8 | $1,553.93 |
Is TCG Collector overvaluing my chase cards?
Mostly no. Nearly every card worth $100 or more is within a few dollars of market. Four are listed more than $50 high.
| Card | Listed | Market |
|---|---|---|
| Paradise Resort (Staff) | $641.76 | $433.51 |
| Umbreon ex | $1,410.28 | $1,326.66 |
| Pikachu with Grey Felt Hat | $1,081.11 | $1,015.89 |
| Sylveon ex | $533.18 | $476.49 |
data_as_of Oct 5, 2026
The database opens with mode=ro and query_only, so even the raw SQL tool can't write. SQLite enforces it, not a check on the query text.
Results carry data_as_of. If a fresh copy can't be downloaded, the server answers from its cache and says the data is stale.
find_cards returns one row per print, and price history takes one exact print. When a name matches several, Claude sees them all and has to pick or ask.
Engineering decisions
The first press stops new cards and lets in-flight ones finish. The second cancels them. Completed work is saved either way, using context.WithoutCancel so the checkpoint outlives the cancel.
The daily count lives in SQLite and is corrected from response headers, so a restart can't overspend. A “too many requests” answer is read, waited out, and the run carries on.
Two third-party packages: pure-Go SQLite and a rate limiter. CSV, HTTP, JSON, logging, embedded templates and fake-clock tests all come from the standard library.
Coming from C#
if err != nilthrow / catch
Repetitive, but failure is in the signature and every caller has to acknowledge it. I wouldn't swap back, though I'd take a shorter syntax.
context.ContextCancellationToken
Cancellation and deadline travel together, which made the three lifetimes in shutdown easy to keep apart.
go + selectasync / Task.WhenAny
No async coloring up the call chain. One select reads as “send the job, or stop.”
implicit: IStore + DI
Great at this size. For a bigger domain I'd still want C#'s explicit contracts and a container.
Current state
What’s next
The pipeline is done. The remaining work is matching, so more of the collection gets a price without guessing.
Explore the work