A solo project — designed, built, and shipped by one person. The goal: make deck building feel good on mobile. Built mobile-first from the ground up.
System Architecture
Four-crate Rust workspace — every box links to its source.
- swipe-to-build deckbuilder
- card search · deck profiles
- authenticated user UI
- marketing & landing
- password reset flow
- email verification flow
- auth · sessions · JWT
- decks · cards · users
- nightly Scryfall card-catalog sync
- writes into the same Postgres
- card catalog source
- nightly sync → zervice
- users
- decks
- cards
models, filter logic, traits — no server- or client-only deps. Imported by zwiper · zerver · zite.
Under the Hood
What the diagram doesn't show.
Hexagonal Architecture
Ports & adapters, in practice — not just on the whiteboard.
- zwipe-core has zero framework deps — no Axum, no Dioxus, no sqlx
- Inbound adapters (HTTP, UI) and outbound adapters (sqlx, HTTP client) swap freely
- Same domain code drives server-side SQL filtering and on-device in-memory filtering
- One Rust codebase compiles to iOS, Android, and web from the same source
Testing & Lint Discipline
416 unit tests; 277 in zwipe-core alone. Production posture enforced by the compiler.
.unwrap,.expect,panic!,todo!,dbg!,print!— all denied at compile time- 33 enforced Clippy rules across the workspace
- Compile-time SQL verification via sqlx's
query!macro — bad queries failcargo build, not runtime - Commander eligibility, partner validation, deck metrics — all covered
- Security audit complete; nightly Cloudflare R2 backups
Authentication
Hand-rolled — probably more security than a deckbuilder needs. Worth it.
- Argon2id hashing with a NIST-compliant 170+ pattern password blocklist
- Rotating refresh tokens, replay-safe via delete-on-use
- JWT access tokens, short-lived; refresh tokens stored hashed
Passwordnewtype is consumed on hash so plaintext can't leak past the boundary- Rate limiting, audit logs, transactional email
Type Safety
Newtypes everywhere — invalid states can't compile, let alone reach production.
UserId,DeckId,Email,Password— distinct types, notStringaliases- Builder types enforce required-field rules at construction
- Format eligibility (commander, oathbreaker) modeled as enum + traits, not bool flags
- Newtype wrappers parse-and-validate on the boundary — no defensive checks downstream
Card Data Pipeline
110k+ printings synced nightly from Scryfall. The hard parts aren't the cron.
- Five-strategy upsert chain: batch first, fall through to per-row on conflict
- 88-column rows respect Postgres's 65k-parameter cap — ~327 cards per batch
PartialEqdelta detection — only changed rows are written, not the whole catalog nightly- Materialized view refresh for deduplicated search (~35k unique cards)
- Zero Scryfall dependency at query time — all lookups hit Postgres
Infrastructure
Self-hosted on a home Ubuntu server. No cloud bill.
- Cloudflare Tunnel → api.zwipe.net, no public ports exposed
- Self-hosted GitHub Actions runner, deploys on push to main
- systemd auto-restart, automatic migrations on deploy
- Nightly pg_dump → Cloudflare R2, 30-day retention
Mechanical Categories
Scryfall ships raw oracle text. Players think in roles — ramp, removal, anthem, counterspell. So zwipe classifies every card into one or more of 24 strategic roles.
- 24 roles (ramp, removal, anthem, tokens, blink, mill, tutor, …) — see mechanical_category
- Multi-label: Lightning Bolt = burn + removal; Sol Ring = ramp
- Deterministic heuristic classifier — oracle text + type line, no AI, runs at sync time
- Stored on the card row; filtering hits one Postgres column, not a runtime classifier