Scotty Fermo

Software developer · scadoshi

rustfull-stackiospostgresqlsystems

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
imports zwipe-core
ziteweb · Dioxus + WASM
  • marketing & landing
  • password reset flow
  • email verification flow
imports zwipe-core
HTTPS / JSON
zerverREST API · Axum · Tokio · SQLx
  • auth · sessions · JWT
  • decks · cards · users
zervicescheduled job (runs on the zerver)
  • nightly Scryfall card-catalog sync
  • writes into the same Postgres
imports zwipe-core
Scryfall APIexternal service
  • card catalog source
  • nightly sync → zervice
SQL
PostgreSQLprimary datastore
  • users
  • decks
  • cards
zwipe-coreshared domain crate

models, filter logic, traits — no server- or client-only deps. Imported by zwiper · zerver · zite.

runtime data flowbuild-time dependency (cargo)

Under the Hood

What the diagram doesn't show.

Design

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
Quality

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 fail cargo build, not runtime
  • Commander eligibility, partner validation, deck metrics — all covered
  • Security audit complete; nightly Cloudflare R2 backups
Auth

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
  • Password newtype is consumed on hash so plaintext can't leak past the boundary
  • Rate limiting, audit logs, transactional email
Types

Type Safety

Newtypes everywhere — invalid states can't compile, let alone reach production.

  • UserId, DeckId, Email, Password — distinct types, not String aliases
  • 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
Sync

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
  • PartialEq delta 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
Ops

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
Enrichment

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